Ver código fonte

refactor emoji api with fixes

pleroma-fe-2020-05-01-c67e9daf
Alexander Strizhakov 4 anos atrás
pai
commit
342f55fb92
Nenhuma chave conhecida encontrada para esta assinatura no banco de dados ID da chave GPG: 22896A53AEF1381
9 arquivos alterados com 1285 adições e 737 exclusões
  1. +509
    -0
      lib/pleroma/emoji/pack.ex
  2. +166
    -446
      lib/pleroma/web/pleroma_api/controllers/emoji_api_controller.ex
  3. +2
    -1
      lib/pleroma/web/router.ex
  4. BIN
      test/instance_static/emoji/pack_bad_sha/blank.png
  5. +13
    -0
      test/instance_static/emoji/pack_bad_sha/pack.json
  6. BIN
      test/instance_static/emoji/pack_bad_sha/pack_bad_sha.zip
  7. +6
    -8
      test/instance_static/emoji/test_pack/pack.json
  8. +1
    -4
      test/instance_static/emoji/test_pack_nonshared/pack.json
  9. +588
    -278
      test/web/pleroma_api/controllers/emoji_api_controller_test.exs

+ 509
- 0
lib/pleroma/emoji/pack.ex Ver arquivo

@@ -0,0 +1,509 @@
defmodule Pleroma.Emoji.Pack do
@derive {Jason.Encoder, only: [:files, :pack]}
defstruct files: %{},
pack_file: nil,
path: nil,
pack: %{},
name: nil

@type t() :: %__MODULE__{
files: %{String.t() => Path.t()},
pack_file: Path.t(),
path: Path.t(),
pack: map(),
name: String.t()
}

alias Pleroma.Emoji

@spec emoji_path() :: Path.t()
def emoji_path do
static = Pleroma.Config.get!([:instance, :static_dir])
Path.join(static, "emoji")
end

@spec create(String.t()) :: :ok | {:error, File.posix()} | {:error, :empty_values}
def create(name) when byte_size(name) > 0 do
dir = Path.join(emoji_path(), name)

with :ok <- File.mkdir(dir) do
%__MODULE__{
pack_file: Path.join(dir, "pack.json")
}
|> save_pack()
end
end

def create(_), do: {:error, :empty_values}

@spec show(String.t()) :: {:ok, t()} | {:loaded, nil} | {:error, :empty_values}
def show(name) when byte_size(name) > 0 do
with {_, %__MODULE__{} = pack} <- {:loaded, load_pack(name)},
{_, pack} <- validate_pack(pack) do
{:ok, pack}
end
end

def show(_), do: {:error, :empty_values}

@spec delete(String.t()) ::
{:ok, [binary()]} | {:error, File.posix(), binary()} | {:error, :empty_values}
def delete(name) when byte_size(name) > 0 do
emoji_path()
|> Path.join(name)
|> File.rm_rf()
end

def delete(_), do: {:error, :empty_values}

@spec add_file(String.t(), String.t(), Path.t(), Plug.Upload.t() | String.t()) ::
{:ok, t()} | {:error, File.posix()} | {:error, :empty_values}
def add_file(name, shortcode, filename, file)
when byte_size(name) > 0 and byte_size(shortcode) > 0 and byte_size(filename) > 0 do
with {_, nil} <- {:exists, Emoji.get(shortcode)},
{_, %__MODULE__{} = pack} <- {:loaded, load_pack(name)} do
file_path = Path.join(pack.path, filename)

create_subdirs(file_path)

case file do
%Plug.Upload{path: upload_path} ->
# Copy the uploaded file from the temporary directory
File.copy!(upload_path, file_path)

url when is_binary(url) ->
# Download and write the file
file_contents = Tesla.get!(url).body
File.write!(file_path, file_contents)
end

files = Map.put(pack.files, shortcode, filename)

updated_pack = %{pack | files: files}

case save_pack(updated_pack) do
:ok ->
Emoji.reload()
{:ok, updated_pack}

e ->
e
end
end
end

def add_file(_, _, _, _), do: {:error, :empty_values}

defp create_subdirs(file_path) do
if String.contains?(file_path, "/") do
file_path
|> Path.dirname()
|> File.mkdir_p!()
end
end

@spec remove_file(String.t(), String.t()) ::
{:ok, t()} | {:error, File.posix()} | {:error, :empty_values}
def remove_file(name, shortcode) when byte_size(name) > 0 and byte_size(shortcode) > 0 do
with {_, %__MODULE__{} = pack} <- {:loaded, load_pack(name)},
{_, {filename, files}} when not is_nil(filename) <-
{:exists, Map.pop(pack.files, shortcode)},
emoji <- Path.join(pack.path, filename),
{_, true} <- {:exists, File.exists?(emoji)} do
emoji_dir = Path.dirname(emoji)

File.rm!(emoji)

if String.contains?(filename, "/") and File.ls!(emoji_dir) == [] do
File.rmdir!(emoji_dir)
end

updated_pack = %{pack | files: files}

case save_pack(updated_pack) do
:ok ->
Emoji.reload()
{:ok, updated_pack}

e ->
e
end
end
end

def remove_file(_, _), do: {:error, :empty_values}

@spec update_file(String.t(), String.t(), String.t(), String.t(), boolean()) ::
{:ok, t()} | {:error, File.posix()} | {:error, :empty_values}
def update_file(name, shortcode, new_shortcode, new_filename, force)
when byte_size(name) > 0 and byte_size(shortcode) > 0 and byte_size(new_shortcode) > 0 and
byte_size(new_filename) > 0 do
with {_, %__MODULE__{} = pack} <- {:loaded, load_pack(name)},
{_, {filename, files}} when not is_nil(filename) <-
{:exists, Map.pop(pack.files, shortcode)},
{_, true} <- {:not_used, force or is_nil(Emoji.get(new_shortcode))} do
old_path = Path.join(pack.path, filename)
old_dir = Path.dirname(old_path)
new_path = Path.join(pack.path, new_filename)

create_subdirs(new_path)

:ok = File.rename(old_path, new_path)

if String.contains?(filename, "/") and File.ls!(old_dir) == [] do
File.rmdir!(old_dir)
end

files = Map.put(files, new_shortcode, new_filename)

updated_pack = %{pack | files: files}

case save_pack(updated_pack) do
:ok ->
Emoji.reload()
{:ok, updated_pack}

e ->
e
end
end
end

def update_file(_, _, _, _, _), do: {:error, :empty_values}

@spec import_from_filesystem() :: {:ok, [String.t()]} | {:error, atom()}
def import_from_filesystem do
emoji_path = emoji_path()

with {:ok, %{access: :read_write}} <- File.stat(emoji_path),
{:ok, results} <- File.ls(emoji_path) do
names =
results
|> Enum.map(&Path.join(emoji_path, &1))
|> Enum.reject(fn path ->
File.dir?(path) and File.exists?(Path.join(path, "pack.json"))
end)
|> Enum.map(&write_pack_contents/1)
|> Enum.filter(& &1)

{:ok, names}
else
{:ok, %{access: _}} -> {:error, :not_writable}
e -> e
end
end

defp write_pack_contents(path) do
pack = %__MODULE__{
files: files_from_path(path),
path: path,
pack_file: Path.join(path, "pack.json")
}

case save_pack(pack) do
:ok -> Path.basename(path)
_ -> nil
end
end

defp files_from_path(path) do
txt_path = Path.join(path, "emoji.txt")

if File.exists?(txt_path) do
# There's an emoji.txt file, it's likely from a pack installed by the pack manager.
# Make a pack.json file from the contents of that emoji.txt file

# FIXME: Copy-pasted from Pleroma.Emoji/load_from_file_stream/2

# Create a map of shortcodes to filenames from emoji.txt
File.read!(txt_path)
|> String.split("\n")
|> Enum.map(&String.trim/1)
|> Enum.map(fn line ->
case String.split(line, ~r/,\s*/) do
# This matches both strings with and without tags
# and we don't care about tags here
[name, file | _] ->
file_dir_name = Path.dirname(file)

file =
if String.ends_with?(path, file_dir_name) do
Path.basename(file)
else
file
end

{name, file}

_ ->
nil
end
end)
|> Enum.filter(& &1)
|> Enum.into(%{})
else
# If there's no emoji.txt, assume all files
# that are of certain extensions from the config are emojis and import them all
pack_extensions = Pleroma.Config.get!([:emoji, :pack_extensions])
Emoji.Loader.make_shortcode_to_file_map(path, pack_extensions)
end
end

@spec list_remote_packs(String.t()) :: {:ok, map()}
def list_remote_packs(url) do
uri =
url
|> String.trim()
|> URI.parse()

with {_, true} <- {:shareable, shareable_packs_available?(uri)} do
packs =
uri
|> URI.merge("/api/pleroma/emoji/packs")
|> to_string()
|> Tesla.get!()
|> Map.get(:body)
|> Jason.decode!()

{:ok, packs}
end
end

@spec list_local_packs() :: {:ok, map()}
def list_local_packs do
emoji_path = emoji_path()

# Create the directory first if it does not exist. This is probably the first request made
# with the API so it should be sufficient
with {:create_dir, :ok} <- {:create_dir, File.mkdir_p(emoji_path)},
{:ls, {:ok, results}} <- {:ls, File.ls(emoji_path)} do
packs =
results
|> Enum.map(&load_pack/1)
|> Enum.filter(& &1)
|> Enum.map(&validate_pack/1)
|> Map.new()

{:ok, packs}
end
end

defp validate_pack(pack) do
if downloadable?(pack) do
archive = fetch_archive(pack)
archive_sha = :crypto.hash(:sha256, archive) |> Base.encode16()

info =
pack.pack
|> Map.put("can-download", true)
|> Map.put("download-sha256", archive_sha)

{pack.name, Map.put(pack, :pack, info)}
else
info = Map.put(pack.pack, "can-download", false)
{pack.name, Map.put(pack, :pack, info)}
end
end

defp downloadable?(pack) do
# If the pack is set as shared, check if it can be downloaded
# That means that when asked, the pack can be packed and sent to the remote
# Otherwise, they'd have to download it from external-src
pack.pack["share-files"] &&
Enum.all?(pack.files, fn {_, file} ->
File.exists?(Path.join(pack.path, file))
end)
end

@spec download(String.t()) :: {:ok, binary()}
def download(name) do
with {_, %__MODULE__{} = pack} <- {:exists?, load_pack(name)},
{_, true} <- {:can_download?, downloadable?(pack)} do
{:ok, fetch_archive(pack)}
end
end

defp fetch_archive(pack) do
hash = :crypto.hash(:md5, File.read!(pack.pack_file))

case Cachex.get!(:emoji_packs_cache, pack.name) do
%{hash: ^hash, pack_data: archive} ->
archive

_ ->
create_archive_and_cache(pack, hash)
end
end

defp create_archive_and_cache(pack, hash) do
files = ['pack.json' | Enum.map(pack.files, fn {_, file} -> to_charlist(file) end)]

{:ok, {_, result}} =
:zip.zip('#{pack.name}.zip', files, [:memory, cwd: to_charlist(pack.path)])

ttl_per_file = Pleroma.Config.get!([:emoji, :shared_pack_cache_seconds_per_file])
overall_ttl = :timer.seconds(ttl_per_file * Enum.count(files))

Cachex.put!(
:emoji_packs_cache,
pack.name,
# if pack.json MD5 changes, the cache is not valid anymore
%{hash: hash, pack_data: result},
# Add a minute to cache time for every file in the pack
ttl: overall_ttl
)

result
end

@spec download_from_source(String.t(), String.t(), String.t()) :: :ok
def download_from_source(name, url, as) do
uri =
url
|> String.trim()
|> URI.parse()

with {_, true} <- {:shareable, shareable_packs_available?(uri)} do
# TODO: why do we load all packs, if we know the name of pack we need
remote_pack =
uri
|> URI.merge("/api/pleroma/emoji/packs/#{name}")
|> to_string()
|> Tesla.get!()
|> Map.get(:body)
|> Jason.decode!()

result =
case remote_pack["pack"] do
%{"share-files" => true, "can-download" => true, "download-sha256" => sha} ->
{:ok,
%{
sha: sha,
url:
URI.merge(uri, "/api/pleroma/emoji/packs/#{name}/download_shared") |> to_string()
}}

%{"fallback-src" => src, "fallback-src-sha256" => sha} when is_binary(src) ->
{:ok,
%{
sha: sha,
url: src,
fallback: true
}}

_ ->
{:error,
"The pack was not set as shared and there is no fallback src to download from"}
end

with {:ok, %{sha: sha, url: url} = pinfo} <- result,
%{body: archive} <- Tesla.get!(url),
{_, true} <- {:checksum, Base.decode16!(sha) == :crypto.hash(:sha256, archive)} do
local_name = as || name

path = Path.join(emoji_path(), local_name)

pack = %__MODULE__{
name: local_name,
path: path,
files: remote_pack["files"],
pack_file: Path.join(path, "pack.json")
}

File.mkdir_p!(pack.path)

files = Enum.map(remote_pack["files"], fn {_, path} -> to_charlist(path) end)
# Fallback cannot contain a pack.json file
files = if pinfo[:fallback], do: files, else: ['pack.json' | files]

{:ok, _} = :zip.unzip(archive, cwd: to_charlist(pack.path), file_list: files)

# Fallback can't contain a pack.json file, since that would cause the fallback-src-sha256
# in it to depend on itself
if pinfo[:fallback] do
save_pack(pack)
end

:ok
end
end
end

defp save_pack(pack), do: File.write(pack.pack_file, Jason.encode!(pack, pretty: true))

@spec save_metadata(map(), t()) :: {:ok, t()} | {:error, File.posix()}
def save_metadata(metadata, %__MODULE__{} = pack) do
pack = Map.put(pack, :pack, metadata)

with :ok <- save_pack(pack) do
{:ok, pack}
end
end

@spec update_metadata(String.t(), map()) :: {:ok, t()} | {:error, File.posix()}
def update_metadata(name, data) do
pack = load_pack(name)

fb_sha_changed? =
not is_nil(data["fallback-src"]) and data["fallback-src"] != pack.pack["fallback-src"]

with {_, true} <- {:update?, fb_sha_changed?},
{:ok, %{body: zip}} <- Tesla.get(data["fallback-src"]),
{:ok, f_list} <- :zip.unzip(zip, [:memory]),
{_, true} <- {:has_all_files?, has_all_files?(pack.files, f_list)} do
fallback_sha = :crypto.hash(:sha256, zip) |> Base.encode16()

data
|> Map.put("fallback-src-sha256", fallback_sha)
|> save_metadata(pack)
else
{:update?, _} -> save_metadata(data, pack)
e -> e
end
end

# Check if all files from the pack.json are in the archive
defp has_all_files?(files, f_list) do
Enum.all?(files, fn {_, from_manifest} ->
List.keyfind(f_list, to_charlist(from_manifest), 0)
end)
end

@spec load_pack(String.t()) :: t() | nil
def load_pack(name) do
pack_file = Path.join([emoji_path(), name, "pack.json"])

if File.exists?(pack_file) do
pack_file
|> File.read!()
|> from_json()
|> Map.put(:pack_file, pack_file)
|> Map.put(:path, Path.dirname(pack_file))
|> Map.put(:name, name)
end
end

defp from_json(json) do
map = Jason.decode!(json)

struct(__MODULE__, %{files: map["files"], pack: map["pack"]})
end

defp shareable_packs_available?(uri) do
uri
|> URI.merge("/.well-known/nodeinfo")
|> to_string()
|> Tesla.get!()
|> Map.get(:body)
|> Jason.decode!()
|> Map.get("links")
|> List.last()
|> Map.get("href")
# Get the actual nodeinfo address and fetch it
|> Tesla.get!()
|> Map.get(:body)
|> Jason.decode!()
|> get_in(["metadata", "features"])
|> Enum.member?("shareable_emoji_packs")
end
end

+ 166
- 446
lib/pleroma/web/pleroma_api/controllers/emoji_api_controller.ex Ver arquivo

@@ -1,18 +1,15 @@
defmodule Pleroma.Web.PleromaAPI.EmojiAPIController do
use Pleroma.Web, :controller

alias Pleroma.Plugs.ExpectPublicOrAuthenticatedCheckPlug
alias Pleroma.Plugs.OAuthScopesPlug

require Logger
alias Pleroma.Emoji.Pack

plug(
OAuthScopesPlug,
Pleroma.Plugs.OAuthScopesPlug,
%{scopes: ["write"], admin: true}
when action in [
:create,
:delete,
:save_from,
:download_from,
:import_from_fs,
:update_file,
:update_metadata
@@ -21,17 +18,10 @@ defmodule Pleroma.Web.PleromaAPI.EmojiAPIController do

plug(
:skip_plug,
[OAuthScopesPlug, ExpectPublicOrAuthenticatedCheckPlug]
[Pleroma.Plugs.OAuthScopesPlug, Pleroma.Plugs.ExpectPublicOrAuthenticatedCheckPlug]
when action in [:download_shared, :list_packs, :list_from]
)

defp emoji_dir_path do
Path.join(
Pleroma.Config.get!([:instance, :static_dir]),
"emoji"
)
end

@doc """
Lists packs from the remote instance.

@@ -39,17 +29,13 @@ defmodule Pleroma.Web.PleromaAPI.EmojiAPIController do
be done by the server
"""
def list_from(conn, %{"instance_address" => address}) do
address = String.trim(address)

if shareable_packs_available(address) do
list_resp =
"#{address}/api/pleroma/emoji/packs" |> Tesla.get!() |> Map.get(:body) |> Jason.decode!()

json(conn, list_resp)
with {:ok, packs} <- Pack.list_remote_packs(address) do
json(conn, packs)
else
conn
|> put_status(:internal_server_error)
|> json(%{error: "The requested instance does not support sharing emoji packs"})
{:shareable, _} ->
conn
|> put_status(:internal_server_error)
|> json(%{error: "The requested instance does not support sharing emoji packs"})
end
end

@@ -60,113 +46,44 @@ defmodule Pleroma.Web.PleromaAPI.EmojiAPIController do
a map of "pack directory name" to pack.json contents.
"""
def list_packs(conn, _params) do
# Create the directory first if it does not exist. This is probably the first request made
# with the API so it should be sufficient
with {:create_dir, :ok} <- {:create_dir, File.mkdir_p(emoji_dir_path())},
{:ls, {:ok, results}} <- {:ls, File.ls(emoji_dir_path())} do
pack_infos =
results
|> Enum.filter(&has_pack_json?/1)
|> Enum.map(&load_pack/1)
# Check if all the files are in place and can be sent
|> Enum.map(&validate_pack/1)
# Transform into a map of pack-name => pack-data
|> Enum.into(%{})

json(conn, pack_infos)
emoji_path =
Path.join(
Pleroma.Config.get!([:instance, :static_dir]),
"emoji"
)

with {:ok, packs} <- Pack.list_local_packs() do
json(conn, packs)
else
{:create_dir, {:error, e}} ->
conn
|> put_status(:internal_server_error)
|> json(%{error: "Failed to create the emoji pack directory at #{emoji_dir_path()}: #{e}"})
|> json(%{error: "Failed to create the emoji pack directory at #{emoji_path}: #{e}"})

{:ls, {:error, e}} ->
conn
|> put_status(:internal_server_error)
|> json(%{
error:
"Failed to get the contents of the emoji pack directory at #{emoji_dir_path()}: #{e}"
error: "Failed to get the contents of the emoji pack directory at #{emoji_path}: #{e}"
})
end
end

defp has_pack_json?(file) do
dir_path = Path.join(emoji_dir_path(), file)
# Filter to only use the pack.json packs
File.dir?(dir_path) and File.exists?(Path.join(dir_path, "pack.json"))
end

defp load_pack(pack_name) do
pack_path = Path.join(emoji_dir_path(), pack_name)
pack_file = Path.join(pack_path, "pack.json")

{pack_name, Jason.decode!(File.read!(pack_file))}
end

defp validate_pack({name, pack}) do
pack_path = Path.join(emoji_dir_path(), name)

if can_download?(pack, pack_path) do
archive_for_sha = make_archive(name, pack, pack_path)
archive_sha = :crypto.hash(:sha256, archive_for_sha) |> Base.encode16()
def show(conn, %{"name" => name}) do
name = String.trim(name)

pack =
pack
|> put_in(["pack", "can-download"], true)
|> put_in(["pack", "download-sha256"], archive_sha)

{name, pack}
with {:ok, pack} <- Pack.show(name) do
json(conn, pack)
else
{name, put_in(pack, ["pack", "can-download"], false)}
end
end

defp can_download?(pack, pack_path) do
# If the pack is set as shared, check if it can be downloaded
# That means that when asked, the pack can be packed and sent to the remote
# Otherwise, they'd have to download it from external-src
pack["pack"]["share-files"] &&
Enum.all?(pack["files"], fn {_, path} ->
File.exists?(Path.join(pack_path, path))
end)
end

defp create_archive_and_cache(name, pack, pack_dir, md5) do
files =
['pack.json'] ++
(pack["files"] |> Enum.map(fn {_, path} -> to_charlist(path) end))

{:ok, {_, zip_result}} = :zip.zip('#{name}.zip', files, [:memory, cwd: to_charlist(pack_dir)])

cache_seconds_per_file = Pleroma.Config.get!([:emoji, :shared_pack_cache_seconds_per_file])
cache_ms = :timer.seconds(cache_seconds_per_file * Enum.count(files))

Cachex.put!(
:emoji_packs_cache,
name,
# if pack.json MD5 changes, the cache is not valid anymore
%{pack_json_md5: md5, pack_data: zip_result},
# Add a minute to cache time for every file in the pack
ttl: cache_ms
)

Logger.debug("Created an archive for the '#{name}' emoji pack, \
keeping it in cache for #{div(cache_ms, 1000)}s")

zip_result
end

defp make_archive(name, pack, pack_dir) do
# Having a different pack.json md5 invalidates cache
pack_file_md5 = :crypto.hash(:md5, File.read!(Path.join(pack_dir, "pack.json")))

case Cachex.get!(:emoji_packs_cache, name) do
%{pack_file_md5: ^pack_file_md5, pack_data: zip_result} ->
Logger.debug("Using cache for the '#{name}' shared emoji pack")
zip_result
{:loaded, _} ->
conn
|> put_status(:not_found)
|> json(%{error: "Pack #{name} does not exist"})

_ ->
create_archive_and_cache(name, pack, pack_dir, pack_file_md5)
{:error, :empty_values} ->
conn
|> put_status(:bad_request)
|> json(%{error: "pack name cannot be empty"})
end
end

@@ -175,21 +92,15 @@ keeping it in cache for #{div(cache_ms, 1000)}s")
to download packs that the instance shares.
"""
def download_shared(conn, %{"name" => name}) do
pack_dir = Path.join(emoji_dir_path(), name)
pack_file = Path.join(pack_dir, "pack.json")

with {_, true} <- {:exists?, File.exists?(pack_file)},
pack = Jason.decode!(File.read!(pack_file)),
{_, true} <- {:can_download?, can_download?(pack, pack_dir)} do
zip_result = make_archive(name, pack, pack_dir)
send_download(conn, {:binary, zip_result}, filename: "#{name}.zip")
with {:ok, archive} <- Pack.download(name) do
send_download(conn, {:binary, archive}, filename: "#{name}.zip")
else
{:can_download?, _} ->
conn
|> put_status(:forbidden)
|> json(%{
error: "Pack #{name} cannot be downloaded from this instance, either pack sharing\
was disabled for this pack or some files are missing"
error:
"Pack #{name} cannot be downloaded from this instance, either pack sharing was disabled for this pack or some files are missing"
})

{:exists?, _} ->
@@ -199,22 +110,6 @@ keeping it in cache for #{div(cache_ms, 1000)}s")
end
end

defp shareable_packs_available(address) do
"#{address}/.well-known/nodeinfo"
|> Tesla.get!()
|> Map.get(:body)
|> Jason.decode!()
|> Map.get("links")
|> List.last()
|> Map.get("href")
# Get the actual nodeinfo address and fetch it
|> Tesla.get!()
|> Map.get(:body)
|> Jason.decode!()
|> get_in(["metadata", "features"])
|> Enum.member?("shareable_emoji_packs")
end

@doc """
An admin endpoint to request downloading and storing a pack named `pack_name` from the instance
`instance_address`.
@@ -222,74 +117,24 @@ keeping it in cache for #{div(cache_ms, 1000)}s")
If the requested instance's admin chose to share the pack, it will be downloaded
from that instance, otherwise it will be downloaded from the fallback source, if there is one.
"""
def save_from(conn, %{"instance_address" => address, "pack_name" => name} = data) do
address = String.trim(address)

if shareable_packs_available(address) do
full_pack =
"#{address}/api/pleroma/emoji/packs/list"
|> Tesla.get!()
|> Map.get(:body)
|> Jason.decode!()
|> Map.get(name)

pack_info_res =
case full_pack["pack"] do
%{"share-files" => true, "can-download" => true, "download-sha256" => sha} ->
{:ok,
%{
sha: sha,
uri: "#{address}/api/pleroma/emoji/packs/download_shared/#{name}"
}}

%{"fallback-src" => src, "fallback-src-sha256" => sha} when is_binary(src) ->
{:ok,
%{
sha: sha,
uri: src,
fallback: true
}}

_ ->
{:error,
"The pack was not set as shared and there is no fallback src to download from"}
end

with {:ok, %{sha: sha, uri: uri} = pinfo} <- pack_info_res,
%{body: emoji_archive} <- Tesla.get!(uri),
{_, true} <- {:checksum, Base.decode16!(sha) == :crypto.hash(:sha256, emoji_archive)} do
local_name = data["as"] || name
pack_dir = Path.join(emoji_dir_path(), local_name)
File.mkdir_p!(pack_dir)

files = Enum.map(full_pack["files"], fn {_, path} -> to_charlist(path) end)
# Fallback cannot contain a pack.json file
files = if pinfo[:fallback], do: files, else: ['pack.json'] ++ files

{:ok, _} = :zip.unzip(emoji_archive, cwd: to_charlist(pack_dir), file_list: files)

# Fallback can't contain a pack.json file, since that would cause the fallback-src-sha256
# in it to depend on itself
if pinfo[:fallback] do
pack_file_path = Path.join(pack_dir, "pack.json")

File.write!(pack_file_path, Jason.encode!(full_pack, pretty: true))
end

json(conn, "ok")
else
{:error, e} ->
conn |> put_status(:internal_server_error) |> json(%{error: e})

{:checksum, _} ->
conn
|> put_status(:internal_server_error)
|> json(%{error: "SHA256 for the pack doesn't match the one sent by the server"})
end
def download_from(conn, %{"instance_address" => address, "pack_name" => name} = params) do
with :ok <- Pack.download_from_source(name, address, params["as"]) do
json(conn, "ok")
else
conn
|> put_status(:internal_server_error)
|> json(%{error: "The requested instance does not support sharing emoji packs"})
{:shareable, _} ->
conn
|> put_status(:internal_server_error)
|> json(%{error: "The requested instance does not support sharing emoji packs"})

{:checksum, _} ->
conn
|> put_status(:internal_server_error)
|> json(%{error: "SHA256 for the pack doesn't match the one sent by the server"})

{:error, e} ->
conn
|> put_status(:internal_server_error)
|> json(%{error: e})
end
end

@@ -297,23 +142,27 @@ keeping it in cache for #{div(cache_ms, 1000)}s")
Creates an empty pack named `name` which then can be updated via the admin UI.
"""
def create(conn, %{"name" => name}) do
pack_dir = Path.join(emoji_dir_path(), name)
name = String.trim(name)

if not File.exists?(pack_dir) do
File.mkdir_p!(pack_dir)

pack_file_p = Path.join(pack_dir, "pack.json")
with :ok <- Pack.create(name) do
json(conn, "ok")
else
{:error, :eexist} ->
conn
|> put_status(:conflict)
|> json(%{error: "A pack named \"#{name}\" already exists"})

File.write!(
pack_file_p,
Jason.encode!(%{pack: %{}, files: %{}}, pretty: true)
)
{:error, :empty_values} ->
conn
|> put_status(:bad_request)
|> json(%{error: "pack name cannot be empty"})

conn |> json("ok")
else
conn
|> put_status(:conflict)
|> json(%{error: "A pack named \"#{name}\" already exists"})
{:error, _} ->
render_error(
conn,
:internal_server_error,
"Unexpected error occurred while creating pack."
)
end
end

@@ -321,11 +170,20 @@ keeping it in cache for #{div(cache_ms, 1000)}s")
Deletes the pack `name` and all it's files.
"""
def delete(conn, %{"name" => name}) do
pack_dir = Path.join(emoji_dir_path(), name)
name = String.trim(name)

with {:ok, deleted} when deleted != [] <- Pack.delete(name) do
json(conn, "ok")
else
{:ok, []} ->
conn
|> put_status(:not_found)
|> json(%{error: "Pack #{name} does not exist"})

case File.rm_rf(pack_dir) do
{:ok, _} ->
conn |> json("ok")
{:error, :empty_values} ->
conn
|> put_status(:bad_request)
|> json(%{error: "pack name cannot be empty"})

{:error, _, _} ->
conn
@@ -340,82 +198,23 @@ keeping it in cache for #{div(cache_ms, 1000)}s")
`new_data` is the new metadata for the pack, that will replace the old metadata.
"""
def update_metadata(conn, %{"pack_name" => name, "new_data" => new_data}) do
pack_file_p = Path.join([emoji_dir_path(), name, "pack.json"])

full_pack = Jason.decode!(File.read!(pack_file_p))

# The new fallback-src is in the new data and it's not the same as it was in the old data
should_update_fb_sha =
not is_nil(new_data["fallback-src"]) and
new_data["fallback-src"] != full_pack["pack"]["fallback-src"]

with {_, true} <- {:should_update?, should_update_fb_sha},
%{body: pack_arch} <- Tesla.get!(new_data["fallback-src"]),
{:ok, flist} <- :zip.unzip(pack_arch, [:memory]),
{_, true} <- {:has_all_files?, has_all_files?(full_pack, flist)} do
fallback_sha = :crypto.hash(:sha256, pack_arch) |> Base.encode16()

new_data = Map.put(new_data, "fallback-src-sha256", fallback_sha)
update_metadata_and_send(conn, full_pack, new_data, pack_file_p)
with {:ok, pack} <- Pack.update_metadata(name, new_data) do
json(conn, pack.pack)
else
{:should_update?, _} ->
update_metadata_and_send(conn, full_pack, new_data, pack_file_p)

{:has_all_files?, _} ->
conn
|> put_status(:bad_request)
|> json(%{error: "The fallback archive does not have all files specified in pack.json"})
end
end

# Check if all files from the pack.json are in the archive
defp has_all_files?(%{"files" => files}, flist) do
Enum.all?(files, fn {_, from_manifest} ->
Enum.find(flist, fn {from_archive, _} ->
to_string(from_archive) == from_manifest
end)
end)
end

defp update_metadata_and_send(conn, full_pack, new_data, pack_file_p) do
full_pack = Map.put(full_pack, "pack", new_data)
File.write!(pack_file_p, Jason.encode!(full_pack, pretty: true))

# Send new data back with fallback sha filled
json(conn, new_data)
end

defp get_filename(%Plug.Upload{filename: filename}), do: filename
defp get_filename(url) when is_binary(url), do: Path.basename(url)

defp empty?(str), do: String.trim(str) == ""

defp update_pack_file(updated_full_pack, pack_file_p) do
content = Jason.encode!(updated_full_pack, pretty: true)

File.write!(pack_file_p, content)
end

defp create_subdirs(file_path) do
if String.contains?(file_path, "/") do
file_path
|> Path.dirname()
|> File.mkdir_p!()
{:error, _} ->
render_error(
conn,
:internal_server_error,
"Unexpected error occurred while updating pack metadata."
)
end
end

defp pack_info(pack_name) do
dir = Path.join(emoji_dir_path(), pack_name)
json_path = Path.join(dir, "pack.json")

json =
json_path
|> File.read!()
|> Jason.decode!()

{dir, json_path, json}
end

@doc """
Updates a file in a pack.

@@ -436,50 +235,33 @@ keeping it in cache for #{div(cache_ms, 1000)}s")
conn,
%{"pack_name" => pack_name, "action" => "add"} = params
) do
shortcode =
if params["shortcode"] do
params["shortcode"]
else
filename = get_filename(params["file"])
Path.basename(filename, Path.extname(filename))
end

{pack_dir, pack_file_p, full_pack} = pack_info(pack_name)

with {_, false} <- {:has_shortcode, Map.has_key?(full_pack["files"], shortcode)},
filename <- params["filename"] || get_filename(params["file"]),
false <- empty?(shortcode),
false <- empty?(filename),
file_path <- Path.join(pack_dir, filename) do
# If the name contains directories, create them
create_subdirs(file_path)

case params["file"] do
%Plug.Upload{path: upload_path} ->
# Copy the uploaded file from the temporary directory
File.copy!(upload_path, file_path)

url when is_binary(url) ->
# Download and write the file
file_contents = Tesla.get!(url).body
File.write!(file_path, file_contents)
end

full_pack
|> put_in(["files", shortcode], filename)
|> update_pack_file(pack_file_p)

json(conn, %{shortcode => filename})
filename = params["filename"] || get_filename(params["file"])
shortcode = params["shortcode"] || Path.basename(filename, Path.extname(filename))

with {:ok, pack} <- Pack.add_file(pack_name, shortcode, filename, params["file"]) do
json(conn, pack.files)
else
{:has_shortcode, _} ->
{:exists, _} ->
conn
|> put_status(:conflict)
|> json(%{error: "An emoji with the \"#{shortcode}\" shortcode already exists"})

true ->
{:loaded, _} ->
conn
|> put_status(:bad_request)
|> json(%{error: "pack \"#{pack_name}\" is not found"})

{:error, :empty_values} ->
conn
|> put_status(:bad_request)
|> json(%{error: "shortcode or filename cannot be empty"})
|> json(%{error: "pack name, shortcode or filename cannot be empty"})

{:error, _} ->
render_error(
conn,
:internal_server_error,
"Unexpected error occurred while adding file to pack."
)
end
end

@@ -489,87 +271,74 @@ keeping it in cache for #{div(cache_ms, 1000)}s")
"action" => "remove",
"shortcode" => shortcode
}) do
{pack_dir, pack_file_p, full_pack} = pack_info(pack_name)

if Map.has_key?(full_pack["files"], shortcode) do
{emoji_file_path, updated_full_pack} = pop_in(full_pack, ["files", shortcode])

emoji_file_path = Path.join(pack_dir, emoji_file_path)

# Delete the emoji file
File.rm!(emoji_file_path)
with {:ok, pack} <- Pack.remove_file(pack_name, shortcode) do
json(conn, pack.files)
else
{:exists, _} ->
conn
|> put_status(:bad_request)
|> json(%{error: "Emoji \"#{shortcode}\" does not exist"})

# If the old directory has no more files, remove it
if String.contains?(emoji_file_path, "/") do
dir = Path.dirname(emoji_file_path)
{:loaded, _} ->
conn
|> put_status(:bad_request)
|> json(%{error: "pack \"#{pack_name}\" is not found"})

if Enum.empty?(File.ls!(dir)) do
File.rmdir!(dir)
end
end
{:error, :empty_values} ->
conn
|> put_status(:bad_request)
|> json(%{error: "pack name or shortcode cannot be empty"})

update_pack_file(updated_full_pack, pack_file_p)
json(conn, %{shortcode => full_pack["files"][shortcode]})
else
conn
|> put_status(:bad_request)
|> json(%{error: "Emoji \"#{shortcode}\" does not exist"})
{:error, _} ->
render_error(
conn,
:internal_server_error,
"Unexpected error occurred while removing file from pack."
)
end
end

# Update
def update_file(
conn,
%{"pack_name" => pack_name, "action" => "update", "shortcode" => shortcode} = params
%{"pack_name" => name, "action" => "update", "shortcode" => shortcode} = params
) do
{pack_dir, pack_file_p, full_pack} = pack_info(pack_name)

with {_, true} <- {:has_shortcode, Map.has_key?(full_pack["files"], shortcode)},
%{"new_shortcode" => new_shortcode, "new_filename" => new_filename} <- params,
false <- empty?(new_shortcode),
false <- empty?(new_filename) do
# First, remove the old shortcode, saving the old path
{old_emoji_file_path, updated_full_pack} = pop_in(full_pack, ["files", shortcode])
old_emoji_file_path = Path.join(pack_dir, old_emoji_file_path)
new_emoji_file_path = Path.join(pack_dir, new_filename)

# If the name contains directories, create them
create_subdirs(new_emoji_file_path)

# Move/Rename the old filename to a new filename
# These are probably on the same filesystem, so just rename should work
:ok = File.rename(old_emoji_file_path, new_emoji_file_path)

# If the old directory has no more files, remove it
if String.contains?(old_emoji_file_path, "/") do
dir = Path.dirname(old_emoji_file_path)

if Enum.empty?(File.ls!(dir)) do
File.rmdir!(dir)
end
end

# Then, put in the new shortcode with the new path
updated_full_pack
|> put_in(["files", new_shortcode], new_filename)
|> update_pack_file(pack_file_p)

json(conn, %{new_shortcode => new_filename})
new_shortcode = params["new_shortcode"]
new_filename = params["new_filename"]
force = params["force"] == true

with {:ok, pack} <- Pack.update_file(name, shortcode, new_shortcode, new_filename, force) do
json(conn, pack.files)
else
{:has_shortcode, _} ->
{:exists, _} ->
conn
|> put_status(:bad_request)
|> json(%{error: "Emoji \"#{shortcode}\" does not exist"})

true ->
{:not_used, _} ->
conn
|> put_status(:conflict)
|> json(%{
error:
"New shortcode \"#{new_shortcode}\" is already used. If you want to override emoji use 'force' option"
})

{:loaded, _} ->
conn
|> put_status(:bad_request)
|> json(%{error: "new_shortcode or new_filename cannot be empty"})
|> json(%{error: "pack \"#{name}\" is not found"})

_ ->
{:error, :empty_values} ->
conn
|> put_status(:bad_request)
|> json(%{error: "new_shortcode or new_file were not specified"})
|> json(%{error: "new_shortcode or new_filename cannot be empty"})

{:error, _} ->
render_error(
conn,
:internal_server_error,
"Unexpected error occurred while updating file in pack."
)
end
end

@@ -589,23 +358,12 @@ keeping it in cache for #{div(cache_ms, 1000)}s")
neither, all the files with specific configured extenstions will be
assumed to be emojis and stored in the new `pack.json` file.
"""

def import_from_fs(conn, _params) do
emoji_path = emoji_dir_path()

with {:ok, %{access: :read_write}} <- File.stat(emoji_path),
{:ok, results} <- File.ls(emoji_path) do
imported_pack_names =
results
|> Enum.filter(fn file ->
dir_path = Path.join(emoji_path, file)
# Find the directories that do NOT have pack.json
File.dir?(dir_path) and not File.exists?(Path.join(dir_path, "pack.json"))
end)
|> Enum.map(&write_pack_json_contents/1)

json(conn, imported_pack_names)
with {:ok, names} <- Pack.import_from_filesystem() do
json(conn, names)
else
{:ok, %{access: _}} ->
{:error, :not_writable} ->
conn
|> put_status(:internal_server_error)
|> json(%{error: "Error: emoji pack directory must be writable"})
@@ -617,44 +375,6 @@ keeping it in cache for #{div(cache_ms, 1000)}s")
end
end

defp write_pack_json_contents(dir) do
dir_path = Path.join(emoji_dir_path(), dir)
emoji_txt_path = Path.join(dir_path, "emoji.txt")

files_for_pack = files_for_pack(emoji_txt_path, dir_path)
pack_json_contents = Jason.encode!(%{pack: %{}, files: files_for_pack})

File.write!(Path.join(dir_path, "pack.json"), pack_json_contents)

dir
end

defp files_for_pack(emoji_txt_path, dir_path) do
if File.exists?(emoji_txt_path) do
# There's an emoji.txt file, it's likely from a pack installed by the pack manager.
# Make a pack.json file from the contents of that emoji.txt fileh

# FIXME: Copy-pasted from Pleroma.Emoji/load_from_file_stream/2

# Create a map of shortcodes to filenames from emoji.txt
File.read!(emoji_txt_path)
|> String.split("\n")
|> Enum.map(&String.trim/1)
|> Enum.map(fn line ->
case String.split(line, ~r/,\s*/) do
# This matches both strings with and without tags
# and we don't care about tags here
[name, file | _] -> {name, file}
_ -> nil
end
end)
|> Enum.filter(fn x -> not is_nil(x) end)
|> Enum.into(%{})
else
# If there's no emoji.txt, assume all files
# that are of certain extensions from the config are emojis and import them all
pack_extensions = Pleroma.Config.get!([:emoji, :pack_extensions])
Pleroma.Emoji.Loader.make_shortcode_to_file_map(dir_path, pack_extensions)
end
end
defp get_filename(%Plug.Upload{filename: filename}), do: filename
defp get_filename(url) when is_binary(url), do: Path.basename(url)
end

+ 2
- 1
lib/pleroma/web/router.ex Ver arquivo

@@ -221,12 +221,13 @@ defmodule Pleroma.Web.Router do
delete("/:name", EmojiAPIController, :delete)

# Note: /download_from downloads and saves to instance, not to requester
post("/download_from", EmojiAPIController, :save_from)
post("/download_from", EmojiAPIController, :download_from)
end

# Pack info / downloading
scope "/packs" do
get("/", EmojiAPIController, :list_packs)
get("/:name", EmojiAPIController, :show)
get("/:name/download_shared/", EmojiAPIController, :download_shared)
get("/list_from", EmojiAPIController, :list_from)



BIN
test/instance_static/emoji/pack_bad_sha/blank.png Ver arquivo

Antes Depois
Largura: 128  |  Altura: 128  |  Tamanho: 95B

+ 13
- 0
test/instance_static/emoji/pack_bad_sha/pack.json Ver arquivo

@@ -0,0 +1,13 @@
{
"pack": {
"license": "Test license",
"homepage": "https://pleroma.social",
"description": "Test description",
"can-download": true,
"share-files": true,
"download-sha256": "57482F30674FD3DE821FF48C81C00DA4D4AF1F300209253684ABA7075E5FC238"
},
"files": {
"blank": "blank.png"
}
}

BIN
test/instance_static/emoji/pack_bad_sha/pack_bad_sha.zip Ver arquivo


+ 6
- 8
test/instance_static/emoji/test_pack/pack.json Ver arquivo

@@ -1,13 +1,11 @@
{
"files": {
"blank": "blank.png"
},
"pack": {
"license": "Test license",
"homepage": "https://pleroma.social",
"description": "Test description",

"homepage": "https://pleroma.social",
"license": "Test license",
"share-files": true
},

"files": {
"blank": "blank.png"
}
}
}

+ 1
- 4
test/instance_static/emoji/test_pack_nonshared/pack.json Ver arquivo

@@ -3,14 +3,11 @@
"license": "Test license",
"homepage": "https://pleroma.social",
"description": "Test description",

"fallback-src": "https://nonshared-pack",
"fallback-src-sha256": "74409E2674DAA06C072729C6C8426C4CB3B7E0B85ED77792DB7A436E11D76DAF",

"share-files": false
},

"files": {
"blank": "blank.png"
}
}
}

+ 588
- 278
test/web/pleroma_api/controllers/emoji_api_controller_test.exs
Diferenças do arquivo suprimidas por serem muito extensas
Ver arquivo


Carregando…
Cancelar
Salvar