From 321bd75dca71e395544c05de3583261a2793c7af Mon Sep 17 00:00:00 2001 From: Ekaterina Vaartis Date: Tue, 14 Jan 2025 02:02:46 +0300 Subject: [PATCH 01/10] Add a way to upload emoji pack from zip/url easily Essentially the same as the mix task --- changelog.d/emoji-pack-upload-zip.add | 1 + lib/pleroma/emoji/pack.ex | 61 +++++++++++++++++++ .../pleroma_emoji_pack_operation.ex | 33 ++++++++++ .../controllers/emoji_pack_controller.ex | 30 +++++++++ lib/pleroma/web/router.ex | 1 + 5 files changed, 126 insertions(+) create mode 100644 changelog.d/emoji-pack-upload-zip.add diff --git a/changelog.d/emoji-pack-upload-zip.add b/changelog.d/emoji-pack-upload-zip.add new file mode 100644 index 000000000..3f1973269 --- /dev/null +++ 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 index 99fa1994f..3c6603b5f 100644 --- a/lib/pleroma/emoji/pack.ex +++ b/lib/pleroma/emoji/pack.ex @@ -225,6 +225,67 @@ defmodule Pleroma.Emoji.Pack do end end + def download_zip(name, opts \\ %{}) do + pack_path = + Path.join([ + Pleroma.Config.get!([:instance, :static_dir]), + "emoji", + name + ]) + + with {_, false} <- + {"Pack already exists, refusing to import #{name}", File.exists?(pack_path)}, + {_, :ok} <- {"Could not create the pack directory", File.mkdir_p(pack_path)}, + {_, {:ok, %{body: binary_archive}}} <- + (case opts do + %{url: url} -> + {"Could not download pack", Pleroma.HTTP.get(url)} + + %{file: file} -> + case File.read(file.path) do + {:ok, data} -> {nil, {:ok, %{body: data}}} + {:error, _e} -> {"Could not read the uploaded pack file", :error} + end + + _ -> + {"Neither file nor URL was present in the request", :error} + end), + {_, {:ok, _}} <- + {"Could not unzip pack", + :zip.unzip(binary_archive, cwd: String.to_charlist(pack_path))} do + # Get the pack SHA + archive_sha = :crypto.hash(:sha256, binary_archive) |> Base.encode16() + + pack_json_path = Path.join([pack_path, "pack.json"]) + # Make a json if it does not exist + if not File.exists?(pack_json_path) do + # Make a list of the emojis + emoji_map = + Pleroma.Emoji.Loader.make_shortcode_to_file_map( + pack_path, + Map.get(opts, :exts, [".png", ".gif", ".jpg"]) + ) + + 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 + + :ok + else + {err, _} -> {:error, err} + end + 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 index efa36ffdc..dd503997a 100644 --- 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 index 32360d2a2..cc4493cdf 100644 --- 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,35 @@ defmodule Pleroma.Web.PleromaAPI.EmojiPackController do end end + def download_zip( + %{private: %{open_api_spex: %{body_params: %{url: url, name: name}}}} = conn, + _ + ) do + with :ok <- Pack.download_zip(name, %{url: url}) do + json(conn, "ok") + else + {:error, error} -> + conn + |> put_status(:bad_request) + |> json(%{error: error}) + end + end + + def download_zip( + %{private: %{open_api_spex: %{body_params: %{file: %Plug.Upload{} = file, name: name}}}} = + conn, + _ + ) do + with :ok <- Pack.download_zip(name, %{file: file}) do + json(conn, "ok") + else + {:error, error} -> + conn + |> put_status(:bad_request) + |> json(%{error: error}) + 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 index dfab1b216..cd9cfd3ed 100644 --- 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) From 26ac875bc8f1853cb2718c57292fbd336584359e Mon Sep 17 00:00:00 2001 From: Ekaterina Vaartis Date: Wed, 6 Aug 2025 22:50:44 +0300 Subject: [PATCH 02/10] Use path_join_name_safe for pathname joining --- lib/pleroma/emoji/pack.ex | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/lib/pleroma/emoji/pack.ex b/lib/pleroma/emoji/pack.ex index 3c6603b5f..9b50fb74c 100644 --- a/lib/pleroma/emoji/pack.ex +++ b/lib/pleroma/emoji/pack.ex @@ -227,11 +227,10 @@ defmodule Pleroma.Emoji.Pack do def download_zip(name, opts \\ %{}) do pack_path = - Path.join([ - Pleroma.Config.get!([:instance, :static_dir]), - "emoji", + path_join_name_safe( + Path.join(Pleroma.Config.get!([:instance, :static_dir]), "emoji"), name - ]) + ) with {_, false} <- {"Pack already exists, refusing to import #{name}", File.exists?(pack_path)}, From 8d0b29d7183f11c05f695d0c3cf4b4ec1d2d2d67 Mon Sep 17 00:00:00 2001 From: Ekaterina Vaartis Date: Thu, 7 Aug 2025 11:22:51 +0300 Subject: [PATCH 03/10] Only calculate SHA when there's no pack json --- lib/pleroma/emoji/pack.ex | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/pleroma/emoji/pack.ex b/lib/pleroma/emoji/pack.ex index 9b50fb74c..561bc69d8 100644 --- a/lib/pleroma/emoji/pack.ex +++ b/lib/pleroma/emoji/pack.ex @@ -252,9 +252,6 @@ defmodule Pleroma.Emoji.Pack do {_, {:ok, _}} <- {"Could not unzip pack", :zip.unzip(binary_archive, cwd: String.to_charlist(pack_path))} do - # Get the pack SHA - archive_sha = :crypto.hash(:sha256, binary_archive) |> Base.encode16() - pack_json_path = Path.join([pack_path, "pack.json"]) # Make a json if it does not exist if not File.exists?(pack_json_path) do @@ -265,6 +262,9 @@ defmodule Pleroma.Emoji.Pack do Map.get(opts, :exts, [".png", ".gif", ".jpg"]) ) + # Calculate the pack SHA. Only needed when there's no pack.json, as it would already include a hash + archive_sha = :crypto.hash(:sha256, binary_archive) |> Base.encode16() + pack_json = %{ pack: %{ license: Map.get(opts, :license, ""), From 897c1ced5f3f5a17ee80f34c0e7d8b378237c3e1 Mon Sep 17 00:00:00 2001 From: Lain Soykaf Date: Thu, 7 Aug 2025 13:47:54 +0400 Subject: [PATCH 04/10] EmojiPackControllerDownloadZipTest: Add test. --- ...moji_pack_controller_download_zip_test.exs | 311 ++++++++++++++++++ 1 file changed, 311 insertions(+) create mode 100644 test/pleroma/web/pleroma_api/controllers/emoji_pack_controller_download_zip_test.exs 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 new file mode 100644 index 000000000..ba72c8e27 --- /dev/null +++ b/test/pleroma/web/pleroma_api/controllers/emoji_pack_controller_download_zip_test.exs @@ -0,0 +1,311 @@ +# Pleroma: A lightweight social networking server +# Copyright © Pleroma Authors +# 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 + + @emoji_path Path.join( + Pleroma.Config.get!([:instance, :static_dir]), + "emoji" + ) + + setup do + 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() + + # Clean up any test packs from previous runs + on_exit(fn -> + test_packs = [ + "test_zip_pack", + "test_zip_pack_url", + "test_zip_pack_malicious", + "test_invalid_pack", + "test_bad_url_pack", + "test_no_source_pack" + ] + + Enum.each(test_packs, fn pack_name -> + pack_path = Path.join(@emoji_path, pack_name) + + if File.exists?(pack_path) do + File.rm_rf!(pack_path) + end + end) + end) + + {:ok, %{admin_conn: admin_conn}} + 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} 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} 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} 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 "preserves existing pack.json if present in ZIP", %{admin_conn: admin_conn} 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", + 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/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 From b249340fce23b1a4b30aa66688194b1eabfcefc7 Mon Sep 17 00:00:00 2001 From: Lain Soykaf Date: Thu, 7 Aug 2025 13:51:19 +0400 Subject: [PATCH 05/10] Emoji.Pack: Refactor and use safe_unzip. --- lib/pleroma/emoji/pack.ex | 129 ++++++++++++++++++++++---------------- 1 file changed, 76 insertions(+), 53 deletions(-) diff --git a/lib/pleroma/emoji/pack.ex b/lib/pleroma/emoji/pack.ex index 561bc69d8..1a4625db6 100644 --- a/lib/pleroma/emoji/pack.ex +++ b/lib/pleroma/emoji/pack.ex @@ -226,63 +226,86 @@ defmodule Pleroma.Emoji.Pack do end def download_zip(name, opts \\ %{}) do - pack_path = - path_join_name_safe( - Path.join(Pleroma.Config.get!([:instance, :static_dir]), "emoji"), - name + 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 <- File.mkdir_p(pack_path), + :ok <- safe_unzip(archive_data, pack_path) do + ensure_pack_json(pack_path, archive_data, opts) + else + {:error, reason} when is_binary(reason) -> {:error, reason} + _ -> {:error, "Could not process pack"} + 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"]) ) - with {_, false} <- - {"Pack already exists, refusing to import #{name}", File.exists?(pack_path)}, - {_, :ok} <- {"Could not create the pack directory", File.mkdir_p(pack_path)}, - {_, {:ok, %{body: binary_archive}}} <- - (case opts do - %{url: url} -> - {"Could not download pack", Pleroma.HTTP.get(url)} + archive_sha = :crypto.hash(:sha256, archive_data) |> Base.encode16() - %{file: file} -> - case File.read(file.path) do - {:ok, data} -> {nil, {:ok, %{body: data}}} - {:error, _e} -> {"Could not read the uploaded pack file", :error} - end + 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 + } - _ -> - {"Neither file nor URL was present in the request", :error} - end), - {_, {:ok, _}} <- - {"Could not unzip pack", - :zip.unzip(binary_archive, cwd: String.to_charlist(pack_path))} do - pack_json_path = Path.join([pack_path, "pack.json"]) - # Make a json if it does not exist - if not File.exists?(pack_json_path) do - # Make a list of the emojis - emoji_map = - Pleroma.Emoji.Loader.make_shortcode_to_file_map( - pack_path, - Map.get(opts, :exts, [".png", ".gif", ".jpg"]) - ) - - # Calculate the pack SHA. Only needed when there's no pack.json, as it would already include a hash - archive_sha = :crypto.hash(:sha256, binary_archive) |> 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 - - :ok - else - {err, _} -> {:error, err} - end + File.write!(pack_json_path, Jason.encode!(pack_json, pretty: true)) end @spec download(String.t(), String.t(), String.t()) :: {:ok, t()} | {:error, atom()} From f203e7bb4275c1ff1ddf844e4a7eb343e4be2947 Mon Sep 17 00:00:00 2001 From: Lain Soykaf Date: Thu, 7 Aug 2025 13:51:33 +0400 Subject: [PATCH 06/10] EmojiPackController: Refactor. --- .../controllers/emoji_pack_controller.ex | 26 +++++++------------ 1 file changed, 9 insertions(+), 17 deletions(-) diff --git a/lib/pleroma/web/pleroma_api/controllers/emoji_pack_controller.ex b/lib/pleroma/web/pleroma_api/controllers/emoji_pack_controller.ex index cc4493cdf..8c5e4c06a 100644 --- a/lib/pleroma/web/pleroma_api/controllers/emoji_pack_controller.ex +++ b/lib/pleroma/web/pleroma_api/controllers/emoji_pack_controller.ex @@ -115,31 +115,23 @@ defmodule Pleroma.Web.PleromaAPI.EmojiPackController do end def download_zip( - %{private: %{open_api_spex: %{body_params: %{url: url, name: name}}}} = conn, + %{private: %{open_api_spex: %{body_params: params}}} = conn, _ ) do - with :ok <- Pack.download_zip(name, %{url: url}) do - json(conn, "ok") - else - {:error, error} -> - conn - |> put_status(:bad_request) - |> json(%{error: error}) - end - end + name = Map.get(params, :name) - def download_zip( - %{private: %{open_api_spex: %{body_params: %{file: %Plug.Upload{} = file, name: name}}}} = - conn, - _ - ) do - with :ok <- Pack.download_zip(name, %{file: file}) do + with :ok <- Pack.download_zip(name, params) do json(conn, "ok") else - {:error, error} -> + {: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 From 4eeb9c1f2d2e53228db25c83de2eb1837585c56c Mon Sep 17 00:00:00 2001 From: Lain Soykaf Date: Fri, 8 Aug 2025 15:43:58 +0400 Subject: [PATCH 07/10] EmojiPackControllerDownloadZipTest: Add tests for empty pack name and failing creation. --- ...moji_pack_controller_download_zip_test.exs | 61 +++++++++++++++++++ 1 file changed, 61 insertions(+) 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 index ba72c8e27..50f6446dc 100644 --- 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 @@ -199,6 +199,67 @@ defmodule Pleroma.Web.PleromaAPI.EmojiPackControllerDownloadZipTest do } 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} do + # Make the emoji directory read-only to trigger mkdir_p failure + emoji_path = + Path.join( + Pleroma.Config.get!([:instance, :static_dir]), + "emoji" + ) + + # 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} do # Create ZIP with pack.json {:ok, zip_path} = create_test_emoji_zip_with_pack_json() From 80e0f072407728d06fd931ebebca8fd91cc80918 Mon Sep 17 00:00:00 2001 From: Lain Soykaf Date: Fri, 8 Aug 2025 15:44:30 +0400 Subject: [PATCH 08/10] Emoji.Pack: Implement empty name and directory creation failure handling --- lib/pleroma/emoji/pack.ex | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/lib/pleroma/emoji/pack.ex b/lib/pleroma/emoji/pack.ex index 1a4625db6..616af54ba 100644 --- a/lib/pleroma/emoji/pack.ex +++ b/lib/pleroma/emoji/pack.ex @@ -230,15 +230,23 @@ defmodule Pleroma.Emoji.Pack do :ok <- validate_new_pack(name), {:ok, archive_data} <- fetch_archive_data(opts), pack_path <- path_join_name_safe(emoji_path(), name), - :ok <- File.mkdir_p(pack_path), + :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 From 4ab96bbb9f950cfd90818ee7fc7ea863690d6eee Mon Sep 17 00:00:00 2001 From: Lain Soykaf Date: Sat, 9 Aug 2025 11:11:44 +0400 Subject: [PATCH 09/10] EmojiPackControllerDownloadZipTest: Use a unique folder for each test. --- ...moji_pack_controller_download_zip_test.exs | 89 +++++++++---------- 1 file changed, 44 insertions(+), 45 deletions(-) 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 index 50f6446dc..5150a75f0 100644 --- 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 @@ -8,12 +8,30 @@ defmodule Pleroma.Web.PleromaAPI.EmojiPackControllerDownloadZipTest do import Tesla.Mock import Pleroma.Factory - @emoji_path Path.join( - Pleroma.Config.get!([:instance, :static_dir]), - "emoji" - ) + 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) - setup do admin = insert(:user, is_admin: true) token = insert(:oauth_admin_token, user: admin) @@ -24,27 +42,7 @@ defmodule Pleroma.Web.PleromaAPI.EmojiPackControllerDownloadZipTest do Pleroma.Emoji.reload() - # Clean up any test packs from previous runs - on_exit(fn -> - test_packs = [ - "test_zip_pack", - "test_zip_pack_url", - "test_zip_pack_malicious", - "test_invalid_pack", - "test_bad_url_pack", - "test_no_source_pack" - ] - - Enum.each(test_packs, fn pack_name -> - pack_path = Path.join(@emoji_path, pack_name) - - if File.exists?(pack_path) do - File.rm_rf!(pack_path) - end - end) - end) - - {:ok, %{admin_conn: admin_conn}} + {:ok, %{admin_conn: admin_conn, emoji_path: emoji_dir}} end describe "POST /api/pleroma/emoji/packs/download_zip" do @@ -52,7 +50,7 @@ defmodule Pleroma.Web.PleromaAPI.EmojiPackControllerDownloadZipTest do clear_config([:instance, :admin_privileges], [:emoji_manage_emoji]) end - test "creates pack from uploaded ZIP file", %{admin_conn: admin_conn} do + 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() @@ -71,11 +69,11 @@ defmodule Pleroma.Web.PleromaAPI.EmojiPackControllerDownloadZipTest do |> 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") + 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") + {: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" @@ -85,7 +83,7 @@ defmodule Pleroma.Web.PleromaAPI.EmojiPackControllerDownloadZipTest do File.rm!(zip_path) end - test "creates pack from URL", %{admin_conn: admin_conn} do + 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) @@ -104,11 +102,11 @@ defmodule Pleroma.Web.PleromaAPI.EmojiPackControllerDownloadZipTest do |> 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") + 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") + {: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" @@ -118,9 +116,9 @@ defmodule Pleroma.Web.PleromaAPI.EmojiPackControllerDownloadZipTest do File.rm!(zip_path) end - test "refuses to overwrite existing pack", %{admin_conn: admin_conn} do + 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") + 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: %{}})) @@ -222,13 +220,11 @@ defmodule Pleroma.Web.PleromaAPI.EmojiPackControllerDownloadZipTest do File.rm!(zip_path) end - test "returns error when unable to create pack directory", %{admin_conn: admin_conn} do + 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 - emoji_path = - Path.join( - Pleroma.Config.get!([:instance, :static_dir]), - "emoji" - ) # Save original permissions {:ok, %{mode: original_mode}} = File.stat(emoji_path) @@ -260,7 +256,10 @@ defmodule Pleroma.Web.PleromaAPI.EmojiPackControllerDownloadZipTest do File.rm!(zip_path) end - test "preserves existing pack.json if present in ZIP", %{admin_conn: admin_conn} do + 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() @@ -273,13 +272,13 @@ defmodule Pleroma.Web.PleromaAPI.EmojiPackControllerDownloadZipTest do assert admin_conn |> put_req_header("content-type", "multipart/form-data") |> post("/api/pleroma/emoji/packs/download_zip", %{ - name: "test_zip_pack", + 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/pack.json") + {: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" From 20812151a7f4483c9d68bbd458d2bc2ac018cf21 Mon Sep 17 00:00:00 2001 From: Lain Soykaf Date: Sun, 10 Aug 2025 17:44:21 +0400 Subject: [PATCH 10/10] Gitlab CI: Don't run as root. --- .gitlab-ci.yml | 22 +++++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index bd36387c9..a3733eebe 100644 --- 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: