Working s3 compatible file uplaods!

This commit is contained in:
2026-03-30 14:28:44 -04:00
parent 6152dcdeea
commit 830ee36f84
16 changed files with 664 additions and 15 deletions

View File

@@ -19,5 +19,9 @@ defmodule Mixer.Posts do
rpc_action :update_tweet, :update
rpc_action :destroy_tweet, :destroy
end
resource Mixer.Posts.Media do
rpc_action :read_media, :read
end
end
end

View File

@@ -5,7 +5,6 @@ defmodule Mixer.Posts.Media do
data_layer: AshPostgres.DataLayer,
authorizers: [Ash.Policy.Authorizer],
extensions: [
#AshStateMachine,
AshTypescript.Resource
]
@@ -19,7 +18,20 @@ defmodule Mixer.Posts.Media do
end
actions do
defaults [:read, :destroy, create: :*, update: :*]
defaults [:read]
create :upload do
accept [:s3_key]
change relate_actor(:user)
end
update :link_to_tweet do
accept [:tweet_id]
end
destroy :destroy do
primary? true
end
end
attributes do
@@ -29,12 +41,41 @@ defmodule Mixer.Posts.Media do
allow_nil? false
public? true
end
end
relationships do
belongs_to :tweet, Mixer.Posts.Tweet do
attribute :user_id, :uuid do
allow_nil? false
public? true
end
end
relationships do
belongs_to :user, Mixer.Accounts.User do
attribute_writable? true
allow_nil? false
public? true
end
belongs_to :tweet, Mixer.Posts.Tweet do
allow_nil? true
public? true
end
end
policies do
policy action_type(:read) do
authorize_if always()
end
policy action(:upload) do
authorize_if actor_present()
end
policy action(:link_to_tweet) do
authorize_if relates_to_actor_via(:user)
end
policy action_type(:destroy) do
authorize_if relates_to_actor_via(:user)
end
end
end

View File

@@ -0,0 +1,24 @@
defmodule Mixer.Posts.MediaUploader do
use Waffle.Definition
@async false
@versions [:original]
@extensions ~w(.jpg .jpeg .png .gif .webp .mp4 .mov)
def validate({file, _scope}) do
ext = file.file_name |> Path.extname() |> String.downcase()
if ext in @extensions, do: :ok, else: {:error, "unsupported file type #{ext}"}
end
def storage_dir(_version, {_file, scope}), do: "uploads/media/#{scope.id}"
def filename(_version, {file, _scope}) do
Path.basename(file.file_name, Path.extname(file.file_name))
end
def s3_object_headers(_version, {file, _scope}) do
[content_type: MIME.from_path(file.file_name)]
end
def acl(_version, _), do: :public_read
end

View File

@@ -30,8 +30,25 @@ defmodule Mixer.Posts.Tweet do
create :create do
upsert? true
accept [:content]
argument :media_id, :uuid, allow_nil?: true
change relate_actor(:user)
change transition_state(:posted)
change fn changeset, context ->
case Ash.Changeset.get_argument(changeset, :media_id) do
nil ->
changeset
media_id ->
Ash.Changeset.after_action(changeset, fn _changeset, tweet ->
Mixer.Posts.Media
|> Ash.get!(media_id, authorize?: false)
|> Ash.Changeset.for_update(:link_to_tweet, %{tweet_id: tweet.id})
|> Ash.update!(actor: context.actor)
{:ok, tweet}
end)
end
end
end
end
@@ -63,7 +80,7 @@ defmodule Mixer.Posts.Tweet do
public? true
end
has_many :s3_key, Mixer.Posts.Media do
has_many :media, Mixer.Posts.Media do
public? true
end
end

View File

@@ -0,0 +1,47 @@
defmodule MixerWeb.UploadController do
use MixerWeb, :controller
alias Mixer.Posts.MediaUploader
def create(conn, %{"file" => %Plug.Upload{} = upload}) do
actor = conn.assigns[:current_user]
unless actor do
conn
|> put_status(:unauthorized)
|> json(%{error: "authentication required"})
else
scope = %{id: Ash.UUID.generate()}
case MediaUploader.store({upload, scope}) do
{:ok, file_name} ->
s3_key = "uploads/media/#{scope.id}/#{file_name}"
url = MediaUploader.url({file_name, scope})
Mixer.Posts.Media
|> Ash.Changeset.for_create(:upload, %{s3_key: s3_key}, actor: actor)
|> Ash.create()
|> case do
{:ok, media} ->
json(conn, %{success: true, mediaId: media.id, url: url})
{:error, error} ->
conn
|> put_status(:unprocessable_entity)
|> json(%{success: false, error: inspect(error)})
end
{:error, reason} ->
conn
|> put_status(:unprocessable_entity)
|> json(%{success: false, error: reason})
end
end
end
def create(conn, _params) do
conn
|> put_status(:bad_request)
|> json(%{error: "no file provided"})
end
end

View File

@@ -56,7 +56,8 @@ defmodule MixerWeb.Endpoint do
plug Plug.Parsers,
parsers: [:urlencoded, :multipart, :json, Absinthe.Plug.Parser, AshJsonApi.Plug.Parser],
pass: ["*/*"],
json_decoder: Phoenix.json_library()
json_decoder: Phoenix.json_library(),
length: 10_000_000
plug Plug.MethodOverride
plug Plug.Head

View File

@@ -41,6 +41,7 @@ defmodule MixerWeb.Router do
get "/feed", PageController, :index
post "/rpc/run", AshTypescriptRpcController, :run
post "/rpc/validate", AshTypescriptRpcController, :validate
post "/upload", UploadController, :create
auth_routes AuthController, Mixer.Accounts.User, path: "/auth"
sign_out_route AuthController

View File

@@ -0,0 +1,59 @@
defmodule Mixer.Media do
use Waffle.Definition
# Include ecto support (requires package waffle_ecto installed):
# use Waffle.Ecto.Definition
@versions [:original]
# To add a thumbnail version:
# @versions [:original, :thumb]
# Override the bucket on a per definition basis:
# def bucket do
# :custom_bucket_name
# end
# def bucket({_file, scope}) do
# scope.bucket || bucket()
# end
# Whitelist file extensions:
# def validate({file, _}) do
# file_extension = file.file_name |> Path.extname() |> String.downcase()
#
# case Enum.member?(~w(.jpg .jpeg .gif .png), file_extension) do
# true -> :ok
# false -> {:error, "invalid file type"}
# end
# end
# Define a thumbnail transformation:
# def transform(:thumb, _) do
# {:convert, "-strip -thumbnail 250x250^ -gravity center -extent 250x250 -format png", :png}
# end
# Override the persisted filenames:
# def filename(version, _) do
# version
# end
# Override the storage directory:
# def storage_dir(version, {file, scope}) do
# "uploads/user/avatars/#{scope.id}"
# end
# Provide a default URL if there hasn't been a file uploaded
# def default_url(version, scope) do
# "/images/avatars/default_#{version}.png"
# end
# Specify custom headers for s3 objects
# Available options are [:cache_control, :content_disposition,
# :content_encoding, :content_length, :content_type,
# :expect, :expires, :storage_class, :website_redirect_location]
#
# def s3_object_headers(version, {file, scope}) do
# [content_type: MIME.from_path(file.file_name)]
# end
end