logo

pleroma

My custom branche(s) on git.pleroma.social/pleroma/pleroma git clone https://anongit.hacktivis.me/git/pleroma.git/
commit: 50a962ec6c65c02d98bf2708758ae0f4b51e9ba3
parent 62993871e40ba54d83fcfc8685587f2f0e80c7b6
Author: lain <lain@soykaf.club>
Date:   Sun, 10 Aug 2025 18:10:38 +0000

Merge branch 'emoji-pack-upload' into 'develop'

Add a way to upload emoji pack from zip/url easily

See merge request pleroma/pleroma!4314

Diffstat:

M.gitlab-ci.yml22+++++++++++++++++++---
Achangelog.d/emoji-pack-upload-zip.add2++
Mlib/pleroma/emoji/pack.ex91+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mlib/pleroma/web/api_spec/operations/pleroma_emoji_pack_operation.ex33+++++++++++++++++++++++++++++++++
Mlib/pleroma/web/pleroma_api/controllers/emoji_pack_controller.ex22++++++++++++++++++++++
Mlib/pleroma/web/router.ex1+
Atest/pleroma/web/pleroma_api/controllers/emoji_pack_controller_download_zip_test.exs371+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
7 files changed, 539 insertions(+), 3 deletions(-)

diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml @@ -132,10 +132,25 @@ unit-testing-1.14.5-otp-25: - name: postgres:13-alpine alias: postgres command: ["postgres", "-c", "fsync=off", "-c", "synchronous_commit=off", "-c", "full_page_writes=off"] + before_script: &testing_before_script + - echo $MIX_ENV + - rm -rf _build/*/lib/pleroma + # Create a non-root user for running tests + - useradd -m -s /bin/bash testuser + # Install dependencies as root first + - mix deps.get + # Set proper ownership for everything + - chown -R testuser:testuser . + - chown -R testuser:testuser /root/.mix || true + - chown -R testuser:testuser /root/.hex || true + # Create user-specific directories + - su testuser -c "HOME=/home/testuser mix local.hex --force" + - su testuser -c "HOME=/home/testuser mix local.rebar --force" script: &testing_script - - mix ecto.create - - mix ecto.migrate - - mix pleroma.test_runner --cover --preload-modules + # Run tests as non-root user + - su testuser -c "HOME=/home/testuser mix ecto.create" + - su testuser -c "HOME=/home/testuser mix ecto.migrate" + - su testuser -c "HOME=/home/testuser mix pleroma.test_runner --cover --preload-modules" coverage: '/^Line total: ([^ ]*%)$/' artifacts: reports: @@ -151,6 +166,7 @@ unit-testing-1.18.3-otp-27: image: git.pleroma.social:5050/pleroma/pleroma/ci-base:elixir-1.18.3-otp-27 cache: *testing_cache_policy services: *testing_services + before_script: *testing_before_script script: *testing_script formatting-1.15: diff --git a/changelog.d/emoji-pack-upload-zip.add b/changelog.d/emoji-pack-upload-zip.add @@ -0,0 +1 @@ +Added a way to upload new packs from a URL or ZIP file via Admin API +\ No newline at end of file diff --git a/lib/pleroma/emoji/pack.ex b/lib/pleroma/emoji/pack.ex @@ -225,6 +225,97 @@ defmodule Pleroma.Emoji.Pack do end end + def download_zip(name, opts \\ %{}) do + with :ok <- validate_not_empty([name]), + :ok <- validate_new_pack(name), + {:ok, archive_data} <- fetch_archive_data(opts), + pack_path <- path_join_name_safe(emoji_path(), name), + :ok <- create_pack_dir(pack_path), + :ok <- safe_unzip(archive_data, pack_path) do + ensure_pack_json(pack_path, archive_data, opts) + else + {:error, :empty_values} -> {:error, "Pack name cannot be empty"} + {:error, reason} when is_binary(reason) -> {:error, reason} + _ -> {:error, "Could not process pack"} + end + end + + defp create_pack_dir(pack_path) do + case File.mkdir_p(pack_path) do + :ok -> :ok + {:error, _} -> {:error, "Could not create the pack directory"} + end + end + + defp safe_unzip(archive_data, pack_path) do + case SafeZip.unzip_data(archive_data, pack_path) do + {:ok, _} -> :ok + {:error, reason} when is_binary(reason) -> {:error, reason} + _ -> {:error, "Could not unzip pack"} + end + end + + defp validate_new_pack(name) do + pack_path = path_join_name_safe(emoji_path(), name) + + if File.exists?(pack_path) do + {:error, "Pack already exists, refusing to import #{name}"} + else + :ok + end + end + + defp fetch_archive_data(%{url: url}) do + case Pleroma.HTTP.get(url) do + {:ok, %{status: 200, body: data}} -> {:ok, data} + _ -> {:error, "Could not download pack"} + end + end + + defp fetch_archive_data(%{file: %Plug.Upload{path: path}}) do + case File.read(path) do + {:ok, data} -> {:ok, data} + _ -> {:error, "Could not read the uploaded pack file"} + end + end + + defp fetch_archive_data(_) do + {:error, "Neither file nor URL was present in the request"} + end + + defp ensure_pack_json(pack_path, archive_data, opts) do + pack_json_path = Path.join(pack_path, "pack.json") + + if not File.exists?(pack_json_path) do + create_pack_json(pack_path, pack_json_path, archive_data, opts) + end + + :ok + end + + defp create_pack_json(pack_path, pack_json_path, archive_data, opts) do + emoji_map = + Pleroma.Emoji.Loader.make_shortcode_to_file_map( + pack_path, + Map.get(opts, :exts, [".png", ".gif", ".jpg"]) + ) + + archive_sha = :crypto.hash(:sha256, archive_data) |> Base.encode16() + + pack_json = %{ + pack: %{ + license: Map.get(opts, :license, ""), + homepage: Map.get(opts, :homepage, ""), + description: Map.get(opts, :description, ""), + src: Map.get(opts, :url), + src_sha256: archive_sha + }, + files: emoji_map + } + + File.write!(pack_json_path, Jason.encode!(pack_json, pretty: true)) + end + @spec download(String.t(), String.t(), String.t()) :: {:ok, t()} | {:error, atom()} def download(name, url, as) do uri = url |> String.trim() |> URI.parse() diff --git a/lib/pleroma/web/api_spec/operations/pleroma_emoji_pack_operation.ex b/lib/pleroma/web/api_spec/operations/pleroma_emoji_pack_operation.ex @@ -127,6 +127,20 @@ defmodule Pleroma.Web.ApiSpec.PleromaEmojiPackOperation do } end + def download_zip_operation do + %Operation{ + tags: ["Emoji pack administration"], + summary: "Download a pack from a URL or an uploaded file", + operationId: "PleromaAPI.EmojiPackController.download_zip", + security: [%{"oAuth" => ["admin:write"]}], + requestBody: request_body("Parameters", download_zip_request(), required: true), + responses: %{ + 200 => ok_response(), + 400 => Operation.response("Bad Request", "application/json", ApiError) + } + } + end + defp download_request do %Schema{ type: :object, @@ -143,6 +157,25 @@ defmodule Pleroma.Web.ApiSpec.PleromaEmojiPackOperation do } end + defp download_zip_request do + %Schema{ + type: :object, + required: [:name], + properties: %{ + url: %Schema{ + type: :string, + format: :uri, + description: "URL of the file" + }, + file: %Schema{ + description: "The uploaded ZIP file", + type: :object + }, + name: %Schema{type: :string, format: :uri, description: "Pack Name"} + } + } + end + def create_operation do %Operation{ tags: ["Emoji pack administration"], diff --git a/lib/pleroma/web/pleroma_api/controllers/emoji_pack_controller.ex b/lib/pleroma/web/pleroma_api/controllers/emoji_pack_controller.ex @@ -16,6 +16,7 @@ defmodule Pleroma.Web.PleromaAPI.EmojiPackController do :import_from_filesystem, :remote, :download, + :download_zip, :create, :update, :delete @@ -113,6 +114,27 @@ defmodule Pleroma.Web.PleromaAPI.EmojiPackController do end end + def download_zip( + %{private: %{open_api_spex: %{body_params: params}}} = conn, + _ + ) do + name = Map.get(params, :name) + + with :ok <- Pack.download_zip(name, params) do + json(conn, "ok") + else + {:error, error} when is_binary(error) -> + conn + |> put_status(:bad_request) + |> json(%{error: error}) + + {:error, _} -> + conn + |> put_status(:bad_request) + |> json(%{error: "Could not process pack"}) + end + end + def download( %{private: %{open_api_spex: %{body_params: %{url: url, name: name} = params}}} = conn, _ diff --git a/lib/pleroma/web/router.ex b/lib/pleroma/web/router.ex @@ -466,6 +466,7 @@ defmodule Pleroma.Web.Router do get("/import", EmojiPackController, :import_from_filesystem) get("/remote", EmojiPackController, :remote) post("/download", EmojiPackController, :download) + post("/download_zip", EmojiPackController, :download_zip) post("/files", EmojiFileController, :create) patch("/files", EmojiFileController, :update) diff --git a/test/pleroma/web/pleroma_api/controllers/emoji_pack_controller_download_zip_test.exs b/test/pleroma/web/pleroma_api/controllers/emoji_pack_controller_download_zip_test.exs @@ -0,0 +1,371 @@ +# Pleroma: A lightweight social networking server +# Copyright © Pleroma Authors <https://pleroma.social/> +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.PleromaAPI.EmojiPackControllerDownloadZipTest do + use Pleroma.Web.ConnCase, async: false + + import Tesla.Mock + import Pleroma.Factory + + setup_all do + # Create a base temp directory for this test module + base_temp_dir = Path.join(System.tmp_dir!(), "emoji_test_#{Ecto.UUID.generate()}") + + # Clean up when all tests in module are done + on_exit(fn -> + File.rm_rf!(base_temp_dir) + end) + + {:ok, %{base_temp_dir: base_temp_dir}} + end + + setup %{base_temp_dir: base_temp_dir} do + # Create a unique subdirectory for each test + test_id = Ecto.UUID.generate() + temp_dir = Path.join(base_temp_dir, test_id) + emoji_dir = Path.join(temp_dir, "emoji") + + # Create the directory structure + File.mkdir_p!(emoji_dir) + + # Configure this test to use the temp directory + clear_config([:instance, :static_dir], temp_dir) + + admin = insert(:user, is_admin: true) + token = insert(:oauth_admin_token, user: admin) + + admin_conn = + build_conn() + |> assign(:user, admin) + |> assign(:token, token) + + Pleroma.Emoji.reload() + + {:ok, %{admin_conn: admin_conn, emoji_path: emoji_dir}} + end + + describe "POST /api/pleroma/emoji/packs/download_zip" do + setup do + clear_config([:instance, :admin_privileges], [:emoji_manage_emoji]) + end + + test "creates pack from uploaded ZIP file", %{admin_conn: admin_conn, emoji_path: emoji_path} do + # Create a test ZIP file with emojis + {:ok, zip_path} = create_test_emoji_zip() + + upload = %Plug.Upload{ + content_type: "application/zip", + path: zip_path, + filename: "test_pack.zip" + } + + assert admin_conn + |> put_req_header("content-type", "multipart/form-data") + |> post("/api/pleroma/emoji/packs/download_zip", %{ + name: "test_zip_pack", + file: upload + }) + |> json_response_and_validate_schema(200) == "ok" + + # Verify pack was created + assert File.exists?("#{emoji_path}/test_zip_pack/pack.json") + assert File.exists?("#{emoji_path}/test_zip_pack/test_emoji.png") + + # Verify pack.json contents + {:ok, pack_json} = File.read("#{emoji_path}/test_zip_pack/pack.json") + pack_data = Jason.decode!(pack_json) + + assert pack_data["files"]["test_emoji"] == "test_emoji.png" + assert pack_data["pack"]["src_sha256"] != nil + + # Clean up + File.rm!(zip_path) + end + + test "creates pack from URL", %{admin_conn: admin_conn, emoji_path: emoji_path} do + # Mock HTTP request to download ZIP + {:ok, zip_path} = create_test_emoji_zip() + {:ok, zip_data} = File.read(zip_path) + + mock(fn + %{method: :get, url: "https://example.com/emoji_pack.zip"} -> + %Tesla.Env{status: 200, body: zip_data} + end) + + assert admin_conn + |> put_req_header("content-type", "multipart/form-data") + |> post("/api/pleroma/emoji/packs/download_zip", %{ + name: "test_zip_pack_url", + url: "https://example.com/emoji_pack.zip" + }) + |> json_response_and_validate_schema(200) == "ok" + + # Verify pack was created + assert File.exists?("#{emoji_path}/test_zip_pack_url/pack.json") + assert File.exists?("#{emoji_path}/test_zip_pack_url/test_emoji.png") + + # Verify pack.json has URL as source + {:ok, pack_json} = File.read("#{emoji_path}/test_zip_pack_url/pack.json") + pack_data = Jason.decode!(pack_json) + + assert pack_data["pack"]["src"] == "https://example.com/emoji_pack.zip" + assert pack_data["pack"]["src_sha256"] != nil + + # Clean up + File.rm!(zip_path) + end + + test "refuses to overwrite existing pack", %{admin_conn: admin_conn, emoji_path: emoji_path} do + # Create existing pack + pack_path = Path.join(emoji_path, "test_zip_pack") + File.mkdir_p!(pack_path) + File.write!(Path.join(pack_path, "pack.json"), Jason.encode!(%{files: %{}})) + + {:ok, zip_path} = create_test_emoji_zip() + + upload = %Plug.Upload{ + content_type: "application/zip", + path: zip_path, + filename: "test_pack.zip" + } + + assert admin_conn + |> put_req_header("content-type", "multipart/form-data") + |> post("/api/pleroma/emoji/packs/download_zip", %{ + name: "test_zip_pack", + file: upload + }) + |> json_response_and_validate_schema(400) == %{ + "error" => "Pack already exists, refusing to import test_zip_pack" + } + + # Clean up + File.rm!(zip_path) + end + + test "handles invalid ZIP file", %{admin_conn: admin_conn} do + # Create invalid ZIP file + invalid_zip_path = Path.join(System.tmp_dir!(), "invalid.zip") + File.write!(invalid_zip_path, "not a zip file") + + upload = %Plug.Upload{ + content_type: "application/zip", + path: invalid_zip_path, + filename: "invalid.zip" + } + + assert admin_conn + |> put_req_header("content-type", "multipart/form-data") + |> post("/api/pleroma/emoji/packs/download_zip", %{ + name: "test_invalid_pack", + file: upload + }) + |> json_response_and_validate_schema(400) == %{ + "error" => "Could not unzip pack" + } + + # Clean up + File.rm!(invalid_zip_path) + end + + test "handles URL download failure", %{admin_conn: admin_conn} do + mock(fn + %{method: :get, url: "https://example.com/bad_pack.zip"} -> + %Tesla.Env{status: 404, body: "Not found"} + end) + + assert admin_conn + |> put_req_header("content-type", "multipart/form-data") + |> post("/api/pleroma/emoji/packs/download_zip", %{ + name: "test_bad_url_pack", + url: "https://example.com/bad_pack.zip" + }) + |> json_response_and_validate_schema(400) == %{ + "error" => "Could not download pack" + } + end + + test "requires either file or URL parameter", %{admin_conn: admin_conn} do + assert admin_conn + |> put_req_header("content-type", "multipart/form-data") + |> post("/api/pleroma/emoji/packs/download_zip", %{ + name: "test_no_source_pack" + }) + |> json_response_and_validate_schema(400) == %{ + "error" => "Neither file nor URL was present in the request" + } + end + + test "returns error when pack name is empty", %{admin_conn: admin_conn} do + {:ok, zip_path} = create_test_emoji_zip() + + upload = %Plug.Upload{ + content_type: "application/zip", + path: zip_path, + filename: "test_pack.zip" + } + + assert admin_conn + |> put_req_header("content-type", "multipart/form-data") + |> post("/api/pleroma/emoji/packs/download_zip", %{ + name: "", + file: upload + }) + |> json_response_and_validate_schema(400) == %{ + "error" => "Pack name cannot be empty" + } + + # Clean up + File.rm!(zip_path) + end + + test "returns error when unable to create pack directory", %{ + admin_conn: admin_conn, + emoji_path: emoji_path + } do + # Make the emoji directory read-only to trigger mkdir_p failure + + # Save original permissions + {:ok, %{mode: original_mode}} = File.stat(emoji_path) + + # Make emoji directory read-only (no write permission) + File.chmod!(emoji_path, 0o555) + + {:ok, zip_path} = create_test_emoji_zip() + + upload = %Plug.Upload{ + content_type: "application/zip", + path: zip_path, + filename: "test_pack.zip" + } + + # Try to create a pack in the read-only emoji directory + assert admin_conn + |> put_req_header("content-type", "multipart/form-data") + |> post("/api/pleroma/emoji/packs/download_zip", %{ + name: "test_readonly_pack", + file: upload + }) + |> json_response_and_validate_schema(400) == %{ + "error" => "Could not create the pack directory" + } + + # Clean up - restore original permissions + File.chmod!(emoji_path, original_mode) + File.rm!(zip_path) + end + + test "preserves existing pack.json if present in ZIP", %{ + admin_conn: admin_conn, + emoji_path: emoji_path + } do + # Create ZIP with pack.json + {:ok, zip_path} = create_test_emoji_zip_with_pack_json() + + upload = %Plug.Upload{ + content_type: "application/zip", + path: zip_path, + filename: "test_pack_with_json.zip" + } + + assert admin_conn + |> put_req_header("content-type", "multipart/form-data") + |> post("/api/pleroma/emoji/packs/download_zip", %{ + name: "test_zip_pack_with_json", + file: upload + }) + |> json_response_and_validate_schema(200) == "ok" + + # Verify original pack.json was preserved + {:ok, pack_json} = File.read("#{emoji_path}/test_zip_pack_with_json/pack.json") + pack_data = Jason.decode!(pack_json) + + assert pack_data["pack"]["description"] == "Test pack from ZIP" + assert pack_data["pack"]["license"] == "Test License" + + # Clean up + File.rm!(zip_path) + end + + test "rejects malicious pack names", %{admin_conn: admin_conn} do + {:ok, zip_path} = create_test_emoji_zip() + + upload = %Plug.Upload{ + content_type: "application/zip", + path: zip_path, + filename: "test_pack.zip" + } + + # Test path traversal attempts + malicious_names = ["../evil", "../../evil", ".", "..", "evil/../../../etc"] + + Enum.each(malicious_names, fn name -> + assert_raise RuntimeError, ~r/Invalid or malicious pack name/, fn -> + admin_conn + |> put_req_header("content-type", "multipart/form-data") + |> post("/api/pleroma/emoji/packs/download_zip", %{ + name: name, + file: upload + }) + end + end) + + # Clean up + File.rm!(zip_path) + end + end + + defp create_test_emoji_zip do + tmp_dir = System.tmp_dir!() + zip_path = Path.join(tmp_dir, "test_emoji_pack_#{:rand.uniform(10000)}.zip") + + # 1x1 pixel PNG + png_data = + Base.decode64!( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==" + ) + + files = [ + {~c"test_emoji.png", png_data}, + # Will be treated as GIF based on extension + {~c"another_emoji.gif", png_data} + ] + + {:ok, {_name, zip_binary}} = :zip.zip(~c"test_pack.zip", files, [:memory]) + File.write!(zip_path, zip_binary) + + {:ok, zip_path} + end + + defp create_test_emoji_zip_with_pack_json do + tmp_dir = System.tmp_dir!() + zip_path = Path.join(tmp_dir, "test_emoji_pack_json_#{:rand.uniform(10000)}.zip") + + png_data = + Base.decode64!( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==" + ) + + pack_json = + Jason.encode!(%{ + pack: %{ + description: "Test pack from ZIP", + license: "Test License" + }, + files: %{ + "test_emoji" => "test_emoji.png" + } + }) + + files = [ + {~c"test_emoji.png", png_data}, + {~c"pack.json", pack_json} + ] + + {:ok, {_name, zip_binary}} = :zip.zip(~c"test_pack.zip", files, [:memory]) + File.write!(zip_path, zip_binary) + + {:ok, zip_path} + end +end