diff --git a/assets/js/ash_rpc.ts b/assets/js/ash_rpc.ts index 34fb399..78478a7 100644 --- a/assets/js/ash_rpc.ts +++ b/assets/js/ash_rpc.ts @@ -2,7 +2,7 @@ // Do not edit this file manually -import type { AshRpcError, ConditionalPaginatedResultMixed, InferResult, SortString, UUID, UnifiedFieldSelection, ValidationResult, tweetsFilterInput, tweetsResourceSchema, tweetsSortField } from "./ash_types"; +import type { AshRpcError, ConditionalPaginatedResultMixed, InferResult, SortString, UUID, UnifiedFieldSelection, ValidationResult, mediaFilterInput, mediaResourceSchema, mediaSortField, tweetsFilterInput, tweetsResourceSchema, tweetsSortField } from "./ash_types"; export type * from "./ash_types"; // Helper Functions @@ -201,8 +201,110 @@ export async function executeValidationRpcRequest( +export type ReadMediaFields = UnifiedFieldSelection[]; + + +export type InferReadMediaResult< + Fields extends ReadMediaFields | undefined, + Page extends ReadMediaConfig["page"] = undefined +> = ConditionalPaginatedResultMixed>, { + results: Array>; + hasMore: boolean; + limit: number; + offset: number; + count?: number | null; + type: "offset"; +}, { + results: Array>; + hasMore: boolean; + limit: number; + after: string | null; + before: string | null; + previousPage: string; + nextPage: string; + count?: number | null; + type: "keyset"; +}>; + +export type ReadMediaConfig = { + tenant?: string; + fields: ReadMediaFields; + filter?: mediaFilterInput; + sort?: SortString | SortString[]; + page?: ( + { + limit?: number; + offset?: number; + count?: boolean; + } | { + limit?: number; + after?: string; + before?: string; + } + ); + headers?: Record; + fetchOptions?: RequestInit; + customFetch?: (input: RequestInfo | URL, init?: RequestInit) => Promise; +}; + +export type ReadMediaResult = | { success: true; data: InferReadMediaResult; } +| { success: false; errors: AshRpcError[]; } + +; + +/** + * Read Media records + * + * @ashActionType :read + */ +export async function readMedia( + config: Config & { fields: Fields } +): Promise> { + const payload = { + action: "read_media", + ...(config.tenant !== undefined && { tenant: config.tenant }), + ...(config.fields !== undefined && { fields: config.fields }), + ...(config.filter && { filter: config.filter }), + ...(config.sort && { sort: Array.isArray(config.sort) ? config.sort.join(",") : config.sort }), + ...(config.page && { page: config.page }) + }; + + return executeActionRpcRequest>( + payload, + config + ); +} + + +/** + * Validate: Read Media records + * + * @ashActionType :read + * @validation true + */ +export async function validateReadMedia( + config: { + tenant?: string; + headers?: Record; + fetchOptions?: RequestInit; + customFetch?: (input: RequestInfo | URL, init?: RequestInit) => Promise; +} +): Promise { + const payload = { + action: "read_media", + ...(config.tenant !== undefined && { tenant: config.tenant }) + }; + + return executeValidationRpcRequest( + payload, + config + ); +} + + export type CreateTweetInput = { content: string; + mediaId?: UUID; }; export type CreateTweetFields = UnifiedFieldSelection[]; diff --git a/assets/js/ash_types.ts b/assets/js/ash_types.ts index c461a0e..6d326fb 100644 --- a/assets/js/ash_types.ts +++ b/assets/js/ash_types.ts @@ -5,6 +5,29 @@ export type UUID = string; +// media Schema +export type mediaResourceSchema = { + __type: "Resource"; + __primitiveFields: "id" | "s3Key" | "userId" | "tweetId"; + id: UUID; + s3Key: string; + userId: UUID; + tweetId: UUID | null; + tweet: { __type: "Relationship"; __resource: tweetsResourceSchema | null; }; +}; + + + +export type mediaAttributesOnlySchema = { + __type: "Resource"; + __primitiveFields: "id" | "s3Key" | "userId" | "tweetId"; + id: UUID; + s3Key: string; + userId: UUID; + tweetId: UUID | null; +}; + + // tweets Schema export type tweetsResourceSchema = { __type: "Resource"; @@ -14,6 +37,7 @@ export type tweetsResourceSchema = { likes: number; userId: UUID; state: "posted" | "drafted"; + media: { __type: "Relationship"; __array: true; __resource: mediaResourceSchema; }; }; @@ -29,6 +53,40 @@ export type tweetsAttributesOnlySchema = { }; +export type mediaFilterInput = { + and?: Array; + or?: Array; + not?: Array; + + id?: { + eq?: UUID; + notEq?: UUID; + in?: Array; + }; + + s3Key?: { + eq?: string; + notEq?: string; + in?: Array; + }; + + userId?: { + eq?: UUID; + notEq?: UUID; + in?: Array; + }; + + tweetId?: { + eq?: UUID; + notEq?: UUID; + in?: Array; + isNil?: boolean; + }; + + + tweet?: tweetsFilterInput; + +}; export type tweetsFilterInput = { and?: Array; or?: Array; @@ -69,14 +127,21 @@ export type tweetsFilterInput = { }; + media?: mediaFilterInput; }; -export const tweetsFilterFields = ["id", "content", "likes", "userId", "state", "user", "s3Key"] as const; +export const mediaFilterFields = ["id", "s3Key", "userId", "tweetId", "user", "tweet"] as const; +export type mediaFilterField = (typeof mediaFilterFields)[number]; + +export const tweetsFilterFields = ["id", "content", "likes", "userId", "state", "user", "media"] as const; export type tweetsFilterField = (typeof tweetsFilterFields)[number]; +export const mediaSortFields = ["id", "s3Key", "userId", "tweetId"] as const; +export type mediaSortField = (typeof mediaSortFields)[number]; + export const tweetsSortFields = ["id", "content", "likes", "userId", "state"] as const; export type tweetsSortField = (typeof tweetsSortFields)[number]; diff --git a/assets/js/index.tsx b/assets/js/index.tsx index 2b0382f..e3812f6 100644 --- a/assets/js/index.tsx +++ b/assets/js/index.tsx @@ -14,6 +14,7 @@ import { updateTweet, buildCSRFHeaders, } from "./ash_rpc"; +import { uploadFile } from "./upload"; const queryClient = new QueryClient({ defaultOptions: { queries: { staleTime: 10_000 } }, @@ -21,7 +22,8 @@ const queryClient = new QueryClient({ // ── Types ────────────────────────────────────────────────────────────────────── -type Tweet = { id: string; content: string; userId: string; state: string }; +type MediaItem = { id: string; s3Key: string }; +type Tweet = { id: string; content: string; userId: string; state: string; media?: MediaItem[] }; // ── Auth context ─────────────────────────────────────────────────────────────── @@ -33,6 +35,11 @@ function timeAgo(): string { return "just now"; } +function getAssetHost(): string { + const appEl = document.getElementById("app"); + return appEl?.dataset.assetHost ?? "http://localhost:3901"; +} + // ── Components ───────────────────────────────────────────────────────────────── function Spinner() { @@ -67,14 +74,19 @@ function CharCount({ current, max }: { current: number; max: number }) { function ComposeTweet({ onSuccess }: { onSuccess?: () => void }) { const [text, setText] = useState(""); const [error, setError] = useState(null); + const [pendingFile, setPendingFile] = useState(null); + const [mediaId, setMediaId] = useState(null); + const [uploading, setUploading] = useState(false); + const [uploadError, setUploadError] = useState(null); const textareaRef = useRef(null); + const fileInputRef = useRef(null); const qc = useQueryClient(); const MAX = 280; const mutation = useMutation({ mutationFn: async (content: string) => { const res = await createTweet({ - input: { content }, + input: { content, mediaId: mediaId ?? undefined }, fields: ["id", "content", "userId", "state"], headers: buildCSRFHeaders(), }); @@ -85,11 +97,40 @@ function ComposeTweet({ onSuccess }: { onSuccess?: () => void }) { qc.invalidateQueries({ queryKey: ["tweets"] }); setText(""); setError(null); + setMediaId(null); + setPendingFile(null); + setUploadError(null); onSuccess?.(); }, onError: (e: Error) => setError(e.message), }); + async function handleFileChange(e: React.ChangeEvent) { + const file = e.target.files?.[0]; + if (!file) return; + // Reset the input so the same file can be re-selected after removal + e.target.value = ""; + setPendingFile(file); + setMediaId(null); + setUploadError(null); + setUploading(true); + const csrfToken = buildCSRFHeaders()["X-CSRF-Token"] as string; + const result = await uploadFile(file, csrfToken); + setUploading(false); + if ("error" in result) { + setUploadError(result.error); + setPendingFile(null); + } else { + setMediaId(result.mediaId); + } + } + + function removeAttachment() { + setPendingFile(null); + setMediaId(null); + setUploadError(null); + } + function handleKeyDown(e: React.KeyboardEvent) { if ((e.metaKey || e.ctrlKey) && e.key === "Enter") { e.preventDefault(); @@ -134,13 +175,51 @@ function ComposeTweet({ onSuccess }: { onSuccess?: () => void }) { /> {error &&

{error}

}
- ⌘↵ to post +
+ + + {uploading && ( + Uploading… + )} + {pendingFile && !uploading && ( + + {pendingFile.name} + + + )} + {uploadError && ( + {uploadError} + )} +
@@ -151,6 +230,31 @@ function ComposeTweet({ onSuccess }: { onSuccess?: () => void }) { ); } +function TweetMedia({ media }: { media: MediaItem[] }) { + const assetHost = getAssetHost(); + return ( +
+ {media.map((m) => + /\.(mp4|mov)$/i.test(m.s3Key) ? ( +
+ ); +} + function TweetCard({ tweet }: { tweet: Tweet }) { const { userId: currentUserId } = useContext(AuthCtx); const canModify = !!currentUserId && tweet.userId === currentUserId; @@ -283,6 +387,10 @@ function TweetCard({ tweet }: { tweet: Tweet }) {

{tweet.content}

)} + {tweet.media && tweet.media.length > 0 && ( + + )} + {error && !editing &&

{error}

}
@@ -294,7 +402,7 @@ function Feed() { queryKey: ["tweets"], queryFn: async () => { const res = await readTweet({ - fields: ["id", "content", "userId", "state"], + fields: ["id", "content", "userId", "state", { media: ["id", "s3Key"] }], sort: "-id", headers: buildCSRFHeaders(), }); diff --git a/assets/js/upload.ts b/assets/js/upload.ts new file mode 100644 index 0000000..882d7b4 --- /dev/null +++ b/assets/js/upload.ts @@ -0,0 +1,29 @@ +export interface UploadResult { + success: true; + mediaId: string; + url: string; +} + +export interface UploadError { + success?: false; + error: string; +} + +export async function uploadFile( + file: File, + csrfToken: string +): Promise { + const formData = new FormData(); + formData.append("file", file); + // Do NOT set Content-Type — browser sets the multipart boundary automatically + const res = await fetch("/upload", { + method: "POST", + headers: { "X-CSRF-Token": csrfToken }, + body: formData, + }); + const json = await res.json(); + if (!res.ok || !json.success) { + return { error: json.error ?? "Upload failed" }; + } + return json as UploadResult; +} diff --git a/config/config.exs b/config/config.exs index e7b53ae..bfffe79 100644 --- a/config/config.exs +++ b/config/config.exs @@ -10,7 +10,7 @@ import Config config :waffle, storage: Waffle.Storage.S3, bucket: "mixer-bucket", - asset_host: "http://localhost:3901" + asset_host: "http://localhost:3900" config :ex_aws, json_codec: Jason diff --git a/config/dev.exs b/config/dev.exs index 330daa5..1490490 100644 --- a/config/dev.exs +++ b/config/dev.exs @@ -91,3 +91,16 @@ config :phoenix_live_view, # Disable swoosh api client as it is only required for production adapters. config :swoosh, :api_client, false + +# Local S3-compatible storage (MinIO or similar at localhost:3901) +# Adjust access_key_id / secret_access_key to match your local server's credentials +config :ex_aws, + access_key_id: "GKdea8f62997a90ffa664135d2", + secret_access_key: "dd2f1757661a9e68cae6928a2fc950a2b2fd03b229d71038c98d4713b40ebba2", + region: "garage" + +config :ex_aws, :s3, + scheme: "http://", + host: "localhost", + port: 3900, + region: "garage" diff --git a/lib/mixer/posts.ex b/lib/mixer/posts.ex index 7f26faa..f038dda 100644 --- a/lib/mixer/posts.ex +++ b/lib/mixer/posts.ex @@ -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 diff --git a/lib/mixer/posts/media.ex b/lib/mixer/posts/media.ex index d01fbb1..439be42 100644 --- a/lib/mixer/posts/media.ex +++ b/lib/mixer/posts/media.ex @@ -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 diff --git a/lib/mixer/posts/media_uploader.ex b/lib/mixer/posts/media_uploader.ex new file mode 100644 index 0000000..2318c39 --- /dev/null +++ b/lib/mixer/posts/media_uploader.ex @@ -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 diff --git a/lib/mixer/posts/tweet.ex b/lib/mixer/posts/tweet.ex index 12fd34a..351a0be 100644 --- a/lib/mixer/posts/tweet.ex +++ b/lib/mixer/posts/tweet.ex @@ -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 diff --git a/lib/mixer_web/controllers/upload_controller.ex b/lib/mixer_web/controllers/upload_controller.ex new file mode 100644 index 0000000..28fa410 --- /dev/null +++ b/lib/mixer_web/controllers/upload_controller.ex @@ -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 diff --git a/lib/mixer_web/endpoint.ex b/lib/mixer_web/endpoint.ex index f65ee49..5bdd6a5 100644 --- a/lib/mixer_web/endpoint.ex +++ b/lib/mixer_web/endpoint.ex @@ -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 diff --git a/lib/mixer_web/router.ex b/lib/mixer_web/router.ex index b7b250f..4f92009 100644 --- a/lib/mixer_web/router.ex +++ b/lib/mixer_web/router.ex @@ -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 diff --git a/lib/mixer_web/uploaders/media.ex b/lib/mixer_web/uploaders/media.ex new file mode 100644 index 0000000..c203f2f --- /dev/null +++ b/lib/mixer_web/uploaders/media.ex @@ -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 diff --git a/priv/repo/migrations/20260330174801_add_user_id_to_media_and_allow_null_tweet_id.exs b/priv/repo/migrations/20260330174801_add_user_id_to_media_and_allow_null_tweet_id.exs new file mode 100644 index 0000000..7b526df --- /dev/null +++ b/priv/repo/migrations/20260330174801_add_user_id_to_media_and_allow_null_tweet_id.exs @@ -0,0 +1,32 @@ +defmodule Mixer.Repo.Migrations.AddUserIdToMediaAndAllowNullTweetId do + @moduledoc """ + Updates resources based on their most recent snapshots. + + This file was autogenerated with `mix ash_postgres.generate_migrations` + """ + + use Ecto.Migration + + def up do + alter table(:media) do + modify :tweet_id, :uuid, null: true + + add :user_id, + references(:users, + column: :id, + name: "media_user_id_fkey", + type: :uuid, + prefix: "public" + ), null: false + end + end + + def down do + drop constraint(:media, "media_user_id_fkey") + + alter table(:media) do + remove :user_id + modify :tweet_id, :uuid, null: false + end + end +end diff --git a/priv/resource_snapshots/repo/media/20260330174802.json b/priv/resource_snapshots/repo/media/20260330174802.json new file mode 100644 index 0000000..3213494 --- /dev/null +++ b/priv/resource_snapshots/repo/media/20260330174802.json @@ -0,0 +1,106 @@ +{ + "attributes": [ + { + "allow_nil?": false, + "default": "fragment(\"gen_random_uuid()\")", + "generated?": false, + "precision": null, + "primary_key?": true, + "references": null, + "scale": null, + "size": null, + "source": "id", + "type": "uuid" + }, + { + "allow_nil?": false, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "s3_key", + "type": "text" + }, + { + "allow_nil?": false, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": { + "deferrable": false, + "destination_attribute": "id", + "destination_attribute_default": null, + "destination_attribute_generated": null, + "index?": false, + "match_type": null, + "match_with": null, + "multitenancy": { + "attribute": null, + "global": null, + "strategy": null + }, + "name": "media_user_id_fkey", + "on_delete": null, + "on_update": null, + "primary_key?": true, + "schema": "public", + "table": "users" + }, + "scale": null, + "size": null, + "source": "user_id", + "type": "uuid" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": { + "deferrable": false, + "destination_attribute": "id", + "destination_attribute_default": null, + "destination_attribute_generated": null, + "index?": false, + "match_type": null, + "match_with": null, + "multitenancy": { + "attribute": null, + "global": null, + "strategy": null + }, + "name": "media_tweet_id_fkey", + "on_delete": null, + "on_update": null, + "primary_key?": true, + "schema": "public", + "table": "tweets" + }, + "scale": null, + "size": null, + "source": "tweet_id", + "type": "uuid" + } + ], + "base_filter": null, + "check_constraints": [], + "create_table_options": null, + "custom_indexes": [], + "custom_statements": [], + "has_create_action": true, + "hash": "8E26F7D27DBFD094DC6AA9EDA77C4D40767CF9F3B738CF6F31839A68AD62A886", + "identities": [], + "multitenancy": { + "attribute": null, + "global": null, + "strategy": null + }, + "repo": "Elixir.Mixer.Repo", + "schema": null, + "table": "media" +} \ No newline at end of file