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

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

See merge request pleroma/pleroma!4314
This commit is contained in:
lain 2025-08-10 18:10:38 +00:00
commit 50a962ec6c
7 changed files with 538 additions and 3 deletions

View file

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

View file

@ -0,0 +1 @@
Added a way to upload new packs from a URL or ZIP file via Admin API

View file

@ -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()

View file

@ -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"],

View file

@ -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,
_

View file

@ -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)

View file

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