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:
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