From 2b02aa8e896c0cf832de6ec4e3fd0dba0f6102b4 Mon Sep 17 00:00:00 2001 From: chris Date: Mon, 5 Oct 2026 23:38:00 -0400 Subject: [PATCH] Profile pictures, display name default, coloured service names - Upload a profile picture on web and mobile. The API square-crops it to 512px WebP, strips metadata (phone photos carry GPS), stores it in Postgres and serves it with immutable caching. Avatar URLs are stored as paths, so they survive moving the API. Arbitrary avatar URLs are no longer accepted (they'd let a profile track its viewers). - New accounts get the username as typed as their display name; existing accounts are backfilled, and clearing it falls back to the username. - Service names in Settings get a brand-coloured badge and larger type. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MzFywujuH9TiuXbr9vjoX8 --- apps/api/drizzle/0010_avatars.sql | 9 + apps/api/drizzle/meta/0010_snapshot.json | 1251 ++++++++++++++++++++ apps/api/drizzle/meta/_journal.json | 7 + apps/api/package.json | 2 + apps/api/src/app.ts | 4 + apps/api/src/db/schema.ts | 14 + apps/api/src/lib/avatars.ts | 29 + apps/api/src/routes/auth.ts | 5 +- apps/api/src/routes/avatars.ts | 56 + apps/api/src/routes/me.ts | 5 +- apps/api/src/services/nowPlaying.ts | 3 +- apps/api/test/avatars.test.ts | 81 ++ apps/api/test/helpers.ts | 2 +- apps/mobile/app.json | 8 + apps/mobile/package.json | 1 + apps/mobile/src/app/(tabs)/settings.tsx | 69 +- apps/mobile/src/components/ServiceName.tsx | 31 + apps/web/src/app/globals.css | 4 + apps/web/src/app/settings/page.tsx | 63 +- apps/web/src/components/ServiceName.tsx | 14 + apps/web/src/lib/api.ts | 10 + package-lock.json | 71 +- packages/shared/src/client.ts | 14 + packages/shared/src/schemas.ts | 1 - packages/shared/src/services.ts | 14 + 25 files changed, 1746 insertions(+), 22 deletions(-) create mode 100644 apps/api/drizzle/0010_avatars.sql create mode 100644 apps/api/drizzle/meta/0010_snapshot.json create mode 100644 apps/api/src/lib/avatars.ts create mode 100644 apps/api/src/routes/avatars.ts create mode 100644 apps/api/test/avatars.test.ts create mode 100644 apps/mobile/src/components/ServiceName.tsx create mode 100644 apps/web/src/components/ServiceName.tsx diff --git a/apps/api/drizzle/0010_avatars.sql b/apps/api/drizzle/0010_avatars.sql new file mode 100644 index 0000000..40f167e --- /dev/null +++ b/apps/api/drizzle/0010_avatars.sql @@ -0,0 +1,9 @@ +CREATE TABLE "avatars" ( + "user_id" uuid PRIMARY KEY NOT NULL, + "image" "bytea" NOT NULL, + "content_type" text NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +ALTER TABLE "avatars" ADD CONSTRAINT "avatars_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +UPDATE "users" SET "display_name" = "username" WHERE "display_name" IS NULL OR "display_name" = ''; diff --git a/apps/api/drizzle/meta/0010_snapshot.json b/apps/api/drizzle/meta/0010_snapshot.json new file mode 100644 index 0000000..005f579 --- /dev/null +++ b/apps/api/drizzle/meta/0010_snapshot.json @@ -0,0 +1,1251 @@ +{ + "id": "89f8acc3-35bc-4587-8fb0-4e98abbb730b", + "prevId": "0ec3157f-efbd-4469-a98c-24e2a0e49549", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.avatars": { + "name": "avatars", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "image": { + "name": "image", + "type": "bytea", + "primaryKey": false, + "notNull": true + }, + "content_type": { + "name": "content_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "avatars_user_id_users_id_fk": { + "name": "avatars_user_id_users_id_fk", + "tableFrom": "avatars", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.blocks": { + "name": "blocks", + "schema": "", + "columns": { + "blocker_id": { + "name": "blocker_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "blocked_id": { + "name": "blocked_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "blocks_blocked_idx": { + "name": "blocks_blocked_idx", + "columns": [ + { + "expression": "blocked_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "blocks_blocker_id_users_id_fk": { + "name": "blocks_blocker_id_users_id_fk", + "tableFrom": "blocks", + "tableTo": "users", + "columnsFrom": [ + "blocker_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "blocks_blocked_id_users_id_fk": { + "name": "blocks_blocked_id_users_id_fk", + "tableFrom": "blocks", + "tableTo": "users", + "columnsFrom": [ + "blocked_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "blocks_blocker_id_blocked_id_pk": { + "name": "blocks_blocker_id_blocked_id_pk", + "columns": [ + "blocker_id", + "blocked_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.connections": { + "name": "connections", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "external_user_id": { + "name": "external_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_enc": { + "name": "access_token_enc", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token_enc": { + "name": "refresh_token_enc", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "token_expires_at": { + "name": "token_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_polled_at": { + "name": "last_polled_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_activity_at": { + "name": "last_activity_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "meta": { + "name": "meta", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "webhook_secret": { + "name": "webhook_secret", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "connections_user_provider_idx": { + "name": "connections_user_provider_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "connections_user_id_users_id_fk": { + "name": "connections_user_id_users_id_fk", + "tableFrom": "connections", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "connections_webhook_secret_unique": { + "name": "connections_webhook_secret_unique", + "nullsNotDistinct": false, + "columns": [ + "webhook_secret" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.devices": { + "name": "devices", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "platform": { + "name": "platform", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "devices_user_idx": { + "name": "devices_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "devices_user_id_users_id_fk": { + "name": "devices_user_id_users_id_fk", + "tableFrom": "devices", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "devices_token_hash_unique": { + "name": "devices_token_hash_unique", + "nullsNotDistinct": false, + "columns": [ + "token_hash" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.featured_songs": { + "name": "featured_songs", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "track_id": { + "name": "track_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "comment": { + "name": "comment", + "type": "varchar(280)", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "featured_songs_user_id_users_id_fk": { + "name": "featured_songs_user_id_users_id_fk", + "tableFrom": "featured_songs", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "featured_songs_track_id_tracks_id_fk": { + "name": "featured_songs_track_id_tracks_id_fk", + "tableFrom": "featured_songs", + "tableTo": "tracks", + "columnsFrom": [ + "track_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.follows": { + "name": "follows", + "schema": "", + "columns": { + "follower_id": { + "name": "follower_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "followee_id": { + "name": "followee_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "pinned_at": { + "name": "pinned_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "follows_followee_idx": { + "name": "follows_followee_idx", + "columns": [ + { + "expression": "followee_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "follows_follower_id_users_id_fk": { + "name": "follows_follower_id_users_id_fk", + "tableFrom": "follows", + "tableTo": "users", + "columnsFrom": [ + "follower_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "follows_followee_id_users_id_fk": { + "name": "follows_followee_id_users_id_fk", + "tableFrom": "follows", + "tableTo": "users", + "columnsFrom": [ + "followee_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "follows_follower_id_followee_id_pk": { + "name": "follows_follower_id_followee_id_pk", + "columns": [ + "follower_id", + "followee_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.listens": { + "name": "listens", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "track_id": { + "name": "track_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "listens_user_started_idx": { + "name": "listens_user_started_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "listens_user_id_users_id_fk": { + "name": "listens_user_id_users_id_fk", + "tableFrom": "listens", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "listens_track_id_tracks_id_fk": { + "name": "listens_track_id_tracks_id_fk", + "tableFrom": "listens", + "tableTo": "tracks", + "columnsFrom": [ + "track_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.now_playing": { + "name": "now_playing", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "track_id": { + "name": "track_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_app": { + "name": "source_app", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_url": { + "name": "source_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_playing": { + "name": "is_playing", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "progress_ms": { + "name": "progress_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "now_playing_track_idx": { + "name": "now_playing_track_idx", + "columns": [ + { + "expression": "track_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "now_playing_user_id_users_id_fk": { + "name": "now_playing_user_id_users_id_fk", + "tableFrom": "now_playing", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "now_playing_track_id_tracks_id_fk": { + "name": "now_playing_track_id_tracks_id_fk", + "tableFrom": "now_playing", + "tableTo": "tracks", + "columnsFrom": [ + "track_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.reports": { + "name": "reports", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "reporter_id": { + "name": "reporter_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "reported_id": { + "name": "reported_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "note": { + "name": "note", + "type": "varchar(500)", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'open'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "reports_status_idx": { + "name": "reports_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "reports_reporter_id_users_id_fk": { + "name": "reports_reporter_id_users_id_fk", + "tableFrom": "reports", + "tableTo": "users", + "columnsFrom": [ + "reporter_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "reports_reported_id_users_id_fk": { + "name": "reports_reported_id_users_id_fk", + "tableFrom": "reports", + "tableTo": "users", + "columnsFrom": [ + "reported_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sessions": { + "name": "sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "sessions_user_idx": { + "name": "sessions_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sessions_user_id_users_id_fk": { + "name": "sessions_user_id_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.track_links": { + "name": "track_links", + "schema": "", + "columns": { + "track_id": { + "name": "track_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "service": { + "name": "service", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "match_type": { + "name": "match_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "track_links_external_idx": { + "name": "track_links_external_idx", + "columns": [ + { + "expression": "service", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "track_links_track_id_tracks_id_fk": { + "name": "track_links_track_id_tracks_id_fk", + "tableFrom": "track_links", + "tableTo": "tracks", + "columnsFrom": [ + "track_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "track_links_track_id_service_pk": { + "name": "track_links_track_id_service_pk", + "columns": [ + "track_id", + "service" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tracks": { + "name": "tracks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "isrc": { + "name": "isrc", + "type": "varchar(12)", + "primaryKey": false, + "notNull": false + }, + "match_key": { + "name": "match_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "artist": { + "name": "artist", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "album": { + "name": "album", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "artwork_url": { + "name": "artwork_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "links_resolved_at": { + "name": "links_resolved_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "tracks_isrc_idx": { + "name": "tracks_isrc_idx", + "columns": [ + { + "expression": "isrc", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tracks_match_key_idx": { + "name": "tracks_match_key_idx", + "columns": [ + { + "expression": "match_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "username": { + "name": "username", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "varchar(320)", + "primaryKey": false, + "notNull": true + }, + "password_hash": { + "name": "password_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "bio": { + "name": "bio", + "type": "varchar(280)", + "primaryKey": false, + "notNull": false + }, + "avatar_url": { + "name": "avatar_url", + "type": "varchar(500)", + "primaryKey": false, + "notNull": false + }, + "preferred_service": { + "name": "preferred_service", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'user'" + }, + "suspended_at": { + "name": "suspended_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "onboarded_at": { + "name": "onboarded_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "sharing_paused_until": { + "name": "sharing_paused_until", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "plan": { + "name": "plan", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'free'" + }, + "plan_expires_at": { + "name": "plan_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_username_unique": { + "name": "users_username_unique", + "nullsNotDistinct": false, + "columns": [ + "username" + ] + }, + "users_email_unique": { + "name": "users_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/apps/api/drizzle/meta/_journal.json b/apps/api/drizzle/meta/_journal.json index fe3697d..a9ca649 100644 --- a/apps/api/drizzle/meta/_journal.json +++ b/apps/api/drizzle/meta/_journal.json @@ -71,6 +71,13 @@ "when": 1791240840729, "tag": "0009_black_wild_pack", "breakpoints": true + }, + { + "idx": 10, + "version": "7", + "when": 1791257078479, + "tag": "0010_avatars", + "breakpoints": true } ] } \ No newline at end of file diff --git a/apps/api/package.json b/apps/api/package.json index e85c217..de5f76e 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -21,6 +21,7 @@ "dependencies": { "@fastify/cookie": "^11.1.2", "@fastify/cors": "^11.3.0", + "@fastify/multipart": "^10.1.2", "@fastify/rate-limit": "^11.2.0", "@fastify/websocket": "^11.3.2", "@musicshare/shared": "*", @@ -31,6 +32,7 @@ "fastify": "^5.12.5", "ioredis": "^6.0.0", "postgres": "^3.4.9", + "sharp": "^0.35.5", "undici": "^6.29.0", "zod": "^4.6.5" }, diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index 3cbd094..6314f18 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -1,6 +1,7 @@ import cookie from '@fastify/cookie'; import cors from '@fastify/cors'; import rateLimit from '@fastify/rate-limit'; +import multipart from '@fastify/multipart'; import websocket from '@fastify/websocket'; import Fastify from 'fastify'; import { ZodError } from 'zod'; @@ -11,6 +12,7 @@ import { HttpError } from './lib/http'; import { redis } from './lib/redis'; import { adminRoutes } from './routes/admin'; import { authRoutes } from './routes/auth'; +import { avatarRoutes } from './routes/avatars'; import { connectionRoutes } from './routes/connections'; import { deviceRoutes } from './routes/devices'; import { meRoutes } from './routes/me'; @@ -41,6 +43,7 @@ export async function buildApp({ logger = true, rateLimits = true }: { logger?: // Rate limits are per-route (see `config.rateLimit`); the Redis store makes them global across instances. if (rateLimits) await app.register(rateLimit, { global: false, redis }); await app.register(websocket); + await app.register(multipart); app.decorateRequest('user', null); app.decorateRequest('sessionId', null); @@ -64,6 +67,7 @@ export async function buildApp({ logger = true, rateLimits = true }: { logger?: await app.register(authRoutes); await app.register(meRoutes); + await app.register(avatarRoutes); await app.register(userRoutes); await app.register(safetyRoutes); await app.register(adminRoutes); diff --git a/apps/api/src/db/schema.ts b/apps/api/src/db/schema.ts index 4d2705a..ada9922 100644 --- a/apps/api/src/db/schema.ts +++ b/apps/api/src/db/schema.ts @@ -1,6 +1,7 @@ import { sql } from 'drizzle-orm'; import { boolean, + customType, index, integer, jsonb, @@ -23,6 +24,7 @@ export const users = pgTable('users', { passwordHash: text('password_hash').notNull(), displayName: varchar('display_name', { length: 64 }), bio: varchar('bio', { length: 280 }), + /** Path of the uploaded picture (`/avatars/?v=`), resolved against API_PUBLIC_URL when sent. */ avatarUrl: varchar('avatar_url', { length: 500 }), preferredService: text('preferred_service').$type(), /** Billing plan. Kept in sync by the billing webhook (RevenueCat/Stripe) — see lib/entitlements.ts */ @@ -236,6 +238,18 @@ export const featuredSongs = pgTable('featured_songs', { .default(sql`now()`), }); +const bytea = customType<{ data: Buffer }>({ dataType: () => 'bytea' }); + +/** Profile pictures, already resized and re-encoded (WebP, metadata stripped). */ +export const avatars = pgTable('avatars', { + userId: uuid('user_id') + .primaryKey() + .references(() => users.id, { onDelete: 'cascade' }), + image: bytea('image').notNull(), + contentType: text('content_type').notNull(), + updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(), +}); + export type User = typeof users.$inferSelect; export type Connection = typeof connections.$inferSelect; export type Track = typeof tracks.$inferSelect; diff --git a/apps/api/src/lib/avatars.ts b/apps/api/src/lib/avatars.ts new file mode 100644 index 0000000..95c4095 --- /dev/null +++ b/apps/api/src/lib/avatars.ts @@ -0,0 +1,29 @@ +import sharp from 'sharp'; +import { env } from '../env'; +import { HttpError } from './http'; + +export const AVATAR_SIZE = 512; +export const AVATAR_MAX_UPLOAD_BYTES = 10 * 1024 * 1024; + +/** + * Uploaded avatars are stored as a path (`/avatars/?v=`) so they keep + * working if the API moves; older rows may hold a full external URL. + */ +export const publicAvatarUrl = (stored: string | null): string | null => + stored?.startsWith('/') ? env.API_PUBLIC_URL.replace(/\/$/, '') + stored : stored; + +/** + * Square-crops, resizes and re-encodes to WebP. Re-encoding also drops EXIF + * (phone photos carry GPS location) and anything that isn't image data. + */ +export async function processAvatar(input: Buffer): Promise { + try { + return await sharp(input, { limitInputPixels: 50_000_000, animated: false }) + .rotate() // apply the EXIF orientation before it's stripped + .resize(AVATAR_SIZE, AVATAR_SIZE, { fit: 'cover', position: 'attention' }) + .webp({ quality: 82 }) + .toBuffer(); + } catch { + throw new HttpError(400, "That file isn't an image we can read. Try a JPEG, PNG, WebP or HEIC."); + } +} diff --git a/apps/api/src/routes/auth.ts b/apps/api/src/routes/auth.ts index 7582a0a..1e4917d 100644 --- a/apps/api/src/routes/auth.ts +++ b/apps/api/src/routes/auth.ts @@ -12,6 +12,9 @@ const authRateLimit = { config: { rateLimit: { max: 10, timeWindow: '1 minute' } export async function authRoutes(app: FastifyInstance) { app.post('/auth/register', authRateLimit, async (req, reply) => { const input = registerSchema.parse(req.body); + // Usernames are stored lowercase; keep the capitalization they typed as the display name. + const typed = String((req.body as { username?: unknown }).username ?? '').trim(); + const displayName = typed.toLowerCase() === input.username ? typed : input.username; const [taken] = await db .select({ username: schema.users.username, email: schema.users.email }) .from(schema.users) @@ -25,7 +28,7 @@ export async function authRoutes(app: FastifyInstance) { const [{ n }] = (await tx.select({ n: count() }).from(schema.users)) as [{ n: number }]; const [created] = await tx .insert(schema.users) - .values({ username: input.username, email: input.email, passwordHash, role: n === 0 ? 'admin' : 'user' }) + .values({ username: input.username, displayName, email: input.email, passwordHash, role: n === 0 ? 'admin' : 'user' }) .returning(); return created; }); diff --git a/apps/api/src/routes/avatars.ts b/apps/api/src/routes/avatars.ts new file mode 100644 index 0000000..828a5a8 --- /dev/null +++ b/apps/api/src/routes/avatars.ts @@ -0,0 +1,56 @@ +import { eq } from 'drizzle-orm'; +import type { FastifyInstance } from 'fastify'; +import { z } from 'zod'; +import { db, schema } from '../db'; +import { requireUser } from '../lib/auth'; +import { AVATAR_MAX_UPLOAD_BYTES, processAvatar } from '../lib/avatars'; +import { HttpError } from '../lib/http'; +import { toMeDTO } from './me'; + +export async function avatarRoutes(app: FastifyInstance) { + /** Multipart upload with one file field. Returns the updated profile. */ + app.post('/me/avatar', { config: { rateLimit: { max: 20, timeWindow: '1 hour' } } }, async (req) => { + const user = requireUser(req); + const file = await req.file({ limits: { fileSize: AVATAR_MAX_UPLOAD_BYTES, files: 1 } }).catch(() => undefined); + if (!file) throw new HttpError(400, 'Choose a picture to upload'); + const input = await file.toBuffer().catch(() => { + throw new HttpError(413, 'That picture is too big (10 MB max)'); + }); + const image = await processAvatar(input); + + const values = { image, contentType: 'image/webp', updatedAt: new Date() }; + await db + .insert(schema.avatars) + .values({ userId: user.id, ...values }) + .onConflictDoUpdate({ target: schema.avatars.userId, set: values }); + // The version busts caches, since the image itself is served as immutable. + const [updated] = await db + .update(schema.users) + .set({ avatarUrl: `/avatars/${user.id}?v=${values.updatedAt.getTime()}` }) + .where(eq(schema.users.id, user.id)) + .returning(); + return toMeDTO(updated!); + }); + + app.delete('/me/avatar', async (req) => { + const user = requireUser(req); + await db.delete(schema.avatars).where(eq(schema.avatars.userId, user.id)); + const [updated] = await db.update(schema.users).set({ avatarUrl: null }).where(eq(schema.users.id, user.id)).returning(); + return toMeDTO(updated!); + }); + + app.get('/avatars/:userId', async (req, reply) => { + const { userId } = z.object({ userId: z.uuid() }).parse(req.params); + const [row] = await db + .select({ image: schema.avatars.image, contentType: schema.avatars.contentType }) + .from(schema.avatars) + .where(eq(schema.avatars.userId, userId)); + if (!row) throw new HttpError(404, 'No picture'); + return reply + .header('Content-Type', row.contentType) + .header('Cache-Control', 'public, max-age=31536000, immutable') + .header('Content-Security-Policy', "default-src 'none'") + .header('Cross-Origin-Resource-Policy', 'cross-origin') + .send(row.image); + }); +} diff --git a/apps/api/src/routes/me.ts b/apps/api/src/routes/me.ts index 469353c..b09aa5d 100644 --- a/apps/api/src/routes/me.ts +++ b/apps/api/src/routes/me.ts @@ -4,6 +4,7 @@ import { setFeaturedSchema, setSharingSchema, updateMeSchema, type MeDTO } from import { db, schema } from '../db'; import type { User } from '../db/schema'; import { SESSION_COOKIE, requireUser } from '../lib/auth'; +import { publicAvatarUrl } from '../lib/avatars'; import { effectivePlan, entitlementsFor } from '../lib/entitlements'; import { HttpError } from '../lib/http'; import { deezerTrack } from '../providers/catalog'; @@ -17,7 +18,7 @@ export const toMeDTO = (u: User): MeDTO => ({ id: u.id, username: u.username, displayName: u.displayName, - avatarUrl: u.avatarUrl, + avatarUrl: publicAvatarUrl(u.avatarUrl), email: u.email, bio: u.bio, preferredService: u.preferredService, @@ -34,6 +35,8 @@ export async function meRoutes(app: FastifyInstance) { app.patch('/me', async (req) => { const user = requireUser(req); const { onboarded, ...input } = updateMeSchema.parse(req.body); + // Clearing the display name falls back to the username. + if ('displayName' in input && !input.displayName) input.displayName = user.username; const changes = { ...input, ...(onboarded && !user.onboardedAt ? { onboardedAt: new Date() } : {}) }; if (Object.keys(changes).length === 0) return toMeDTO(user); // nothing to update const [updated] = await db.update(schema.users).set(changes).where(eq(schema.users.id, user.id)).returning(); diff --git a/apps/api/src/services/nowPlaying.ts b/apps/api/src/services/nowPlaying.ts index 17bcb6c..5732231 100644 --- a/apps/api/src/services/nowPlaying.ts +++ b/apps/api/src/services/nowPlaying.ts @@ -2,6 +2,7 @@ import { and, desc, eq, inArray, lt } from 'drizzle-orm'; import type { NowPlayingDTO, NowPlayingProvider, RecentPlayDTO, UserSummaryDTO } from '@musicshare/shared'; import { db, schema } from '../db'; import type { User } from '../db/schema'; +import { publicAvatarUrl } from '../lib/avatars'; import { nowPlayingChannel, redis } from '../lib/redis'; import type { PollResult } from '../providers/types'; import { enqueueResolve } from './queues'; @@ -11,7 +12,7 @@ export const toUserSummary = (u: Pick + sharp({ create: { width: 1200, height: 800, channels: 3, background: '#7b2ff7' } }) + .jpeg() + .withExif({ IFD0: { Make: 'TestPhone', Model: 'GPS-Leaker' } }) + .toBuffer(); + +describe('avatars', () => { + it('resizes, re-encodes and serves an uploaded picture', async () => { + const { token, user } = await createUser(app(), 'alice'); + const { payload, headers } = multipart(await photo()); + const res = await app().inject({ method: 'POST', url: '/me/avatar', payload, headers: { ...headers, ...bearer(token) } }); + expect(res.statusCode).toBe(200); + const url: string = res.json().avatarUrl; + expect(url).toMatch(new RegExp(`^https://api\\.test/avatars/${user.id}\\?v=\\d+$`)); + + const img = await app().inject({ url: url.replace('https://api.test', '') }); + expect(img.statusCode).toBe(200); + expect(img.headers['content-type']).toBe('image/webp'); + expect(img.headers['cache-control']).toContain('immutable'); + const meta = await sharp(img.rawPayload).metadata(); + expect(meta).toMatchObject({ format: 'webp', width: 512, height: 512 }); + expect(meta.exif).toBeUndefined(); // camera/GPS metadata stripped + + // Shows up wherever the user does, e.g. their profile. + const profile = await app().inject({ url: '/users/alice', headers: bearer(token) }); + expect(profile.json().user.avatarUrl).toBe(url); + }); + + it('rejects files that are not images', async () => { + const { token } = await createUser(app(), 'alice'); + const { payload, headers } = multipart(Buffer.from(''), 'image/png', 'x.png'); + const res = await app().inject({ method: 'POST', url: '/me/avatar', payload, headers: { ...headers, ...bearer(token) } }); + expect(res.statusCode).toBe(400); + expect(res.json().error).toMatch(/isn't an image/); + }); + + it('removes the picture', async () => { + const { token, user } = await createUser(app(), 'alice'); + const { payload, headers } = multipart(await photo()); + await app().inject({ method: 'POST', url: '/me/avatar', payload, headers: { ...headers, ...bearer(token) } }); + const res = await app().inject({ method: 'DELETE', url: '/me/avatar', headers: bearer(token) }); + expect(res.json().avatarUrl).toBeNull(); + expect((await app().inject({ url: `/avatars/${user.id}` })).statusCode).toBe(404); + }); + + it('requires sign-in to upload', async () => { + const { payload, headers } = multipart(await photo()); + expect((await app().inject({ method: 'POST', url: '/me/avatar', payload, headers })).statusCode).toBe(401); + }); +}); + +describe('display name', () => { + it('defaults to the username as typed, and falls back to it when cleared', async () => { + const res = await app().inject({ + method: 'POST', + url: '/auth/register', + payload: { username: 'BinaryGnome', email: 'g@example.com', password: 'password123' }, + }); + expect(res.json().user).toMatchObject({ username: 'binarygnome', displayName: 'BinaryGnome' }); + + const cleared = await app().inject({ method: 'PATCH', url: '/me', headers: bearer(res.json().token), payload: { displayName: '' } }); + expect(cleared.json().displayName).toBe('binarygnome'); + }); +}); diff --git a/apps/api/test/helpers.ts b/apps/api/test/helpers.ts index cda90f8..a998e4d 100644 --- a/apps/api/test/helpers.ts +++ b/apps/api/test/helpers.ts @@ -17,7 +17,7 @@ function assertTestTargets() { export async function resetState() { assertTestTargets(); - await sql`truncate users, tracks, track_links, sessions, follows, connections, now_playing, featured_songs, listens, devices, blocks, reports restart identity cascade`; + await sql`truncate users, tracks, track_links, sessions, follows, connections, now_playing, featured_songs, listens, devices, blocks, reports, avatars restart identity cascade`; await redis.flushdb(); } diff --git a/apps/mobile/app.json b/apps/mobile/app.json index d16207d..0816c53 100644 --- a/apps/mobile/app.json +++ b/apps/mobile/app.json @@ -42,6 +42,14 @@ "microphonePermission": false, "recordAudioAndroid": false } + ], + [ + "expo-image-picker", + { + "photosPermission": "musicshare lets you choose a profile picture from your photos.", + "cameraPermission": "musicshare uses the camera to scan friends' QR codes.", + "microphonePermission": false + } ] ], "experiments": { diff --git a/apps/mobile/package.json b/apps/mobile/package.json index be490fd..e358b5d 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -15,6 +15,7 @@ "expo-glass-effect": "~57.0.4", "expo-haptics": "~57.0.3", "expo-image": "~57.0.5", + "expo-image-picker": "~57.0.20", "expo-linear-gradient": "~57.0.2", "expo-linking": "~57.0.11", "expo-router": "~57.0.24", diff --git a/apps/mobile/src/app/(tabs)/settings.tsx b/apps/mobile/src/app/(tabs)/settings.tsx index d95b4f6..265c50f 100644 --- a/apps/mobile/src/app/(tabs)/settings.tsx +++ b/apps/mobile/src/app/(tabs)/settings.tsx @@ -1,17 +1,21 @@ +import * as ImagePicker from 'expo-image-picker'; import { router } from 'expo-router'; import { useState } from 'react'; -import { Alert, Platform, StyleSheet, Text, View } from 'react-native'; +import { Alert, Platform, StyleSheet, View } from 'react-native'; import useSWR from 'swr'; import { SERVICE_LABELS, STREAMING_SERVICES, timeAgo, type ConnectionDTO, + type MeDTO, type NowPlayingProvider, } from '@musicshare/shared'; +import { Avatar } from '@/components/Avatar'; import { RelinkReminder } from '@/components/RelinkReminder'; import { FeaturedPicker } from '@/components/FeaturedPicker'; import { Screen } from '@/components/Screen'; +import { ServiceName } from '@/components/ServiceName'; import { SharingControls } from '@/components/SharingControls'; import { Body, Button, Card, Chip, Field, Heading, Row, Title } from '@/components/ui'; import { api } from '@/lib/api'; @@ -57,6 +61,7 @@ function ProfileSection() { return ( Profile + + {me.avatarUrl && ( + + )} + + {error &&

{error}

} + + + + ); +} + function Connections() { const params = useSearchParams(); const { data: connections = [], mutate } = useSWR('/connections', fetcher); @@ -154,7 +203,7 @@ function Connections() { {/* Spotify caps new apps at a handful of accounts; Spotify's own Last.fm/ListenBrainz link works for everyone. */}
- Spotify +
Spotify sends everything you play to Last.fm or ListenBrainz, and we share it from there. Connect one of them below, then link Spotify on their site: @@ -181,7 +230,7 @@ function Connections() {
- Plex +
{status('plex') ?? Music you play from your Plex servers}
@@ -198,7 +247,7 @@ function Connections() {
- Jellyfin +
{status('jellyfin') ?? Sign in to your Jellyfin server. Your password isn't stored.}
{byProvider.get('jellyfin')?.status !== 'active' && ( @@ -230,7 +279,7 @@ function Connections() { {(['lastfm', 'listenbrainz'] as const).map((p) => (
- {SERVICE_LABELS[p]} +
{status(p) ?? ( @@ -267,7 +316,7 @@ function Connections() {
- Apple Music +
{byProvider.has('apple_music') ? status('apple_music') : 'Connect from the musicshare iPhone app (one tap)'}
diff --git a/apps/web/src/components/ServiceName.tsx b/apps/web/src/components/ServiceName.tsx new file mode 100644 index 0000000..b8a61f1 --- /dev/null +++ b/apps/web/src/components/ServiceName.tsx @@ -0,0 +1,14 @@ +import { SERVICE_COLORS, SERVICE_LABELS, type NowPlayingProvider, type StreamingService } from '@musicshare/shared'; + +/** A service's name with a badge in its brand colour, so it's easy to spot in a list. */ +export function ServiceName({ service }: { service: StreamingService | NowPlayingProvider }) { + const label = SERVICE_LABELS[service]; + return ( + + + {label.slice(0, 1).toUpperCase()} + + {label} + + ); +} diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts index 91e4c80..074c9d1 100644 --- a/apps/web/src/lib/api.ts +++ b/apps/web/src/lib/api.ts @@ -26,6 +26,16 @@ export async function api(path: string, init: { method?: string; bo return data as T; } +/** Uploads one file as multipart form data (e.g. the profile picture). */ +export async function uploadFile(path: string, file: File): Promise { + const form = new FormData(); + form.append('file', file); + const res = await fetch(API_URL + path, { method: 'POST', credentials: 'include', body: form }); + const data = await res.json().catch(() => ({})); + if (!res.ok) throw new ApiError(res.status, (data as { error?: string }).error ?? res.statusText); + return data as T; +} + export const fetcher = (path: string) => api(path); export function useMe() { diff --git a/package-lock.json b/package-lock.json index 8c5f695..5ec1514 100644 --- a/package-lock.json +++ b/package-lock.json @@ -23,6 +23,7 @@ "dependencies": { "@fastify/cookie": "^11.1.2", "@fastify/cors": "^11.3.0", + "@fastify/multipart": "^10.1.2", "@fastify/rate-limit": "^11.2.0", "@fastify/websocket": "^11.3.2", "@musicshare/shared": "*", @@ -33,6 +34,7 @@ "fastify": "^5.12.5", "ioredis": "^6.0.0", "postgres": "^3.4.9", + "sharp": "^0.35.5", "undici": "^6.29.0", "zod": "^4.6.5" }, @@ -815,6 +817,7 @@ "expo-glass-effect": "~57.0.4", "expo-haptics": "~57.0.3", "expo-image": "~57.0.5", + "expo-image-picker": "~57.0.20", "expo-linear-gradient": "~57.0.2", "expo-linking": "~57.0.11", "expo-router": "~57.0.24", @@ -4313,6 +4316,12 @@ "fast-uri": "^4.0.0" } }, + "node_modules/@fastify/busboy": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@fastify/busboy/-/busboy-3.2.2.tgz", + "integrity": "sha512-yXSS27qPExaXeuLvMRMXOLtpipzfQYNjG3FkunDWKGfMYjKuhFXko9CVzqxm8jcF+lmtS9Fd89QNdh9XDjnbNg==", + "license": "MIT" + }, "node_modules/@fastify/cookie": { "version": "11.1.2", "resolved": "https://registry.npmjs.org/@fastify/cookie/-/cookie-11.1.2.tgz", @@ -4353,6 +4362,22 @@ "toad-cache": "^3.7.0" } }, + "node_modules/@fastify/deepmerge": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@fastify/deepmerge/-/deepmerge-3.2.2.tgz", + "integrity": "sha512-155eqA/WMQdN6gUD4RrhS2K0Pcz2Ou9pQ86L0sipOpP9ONbTA6oYw/hW4VdBjR4egcDHqmWUK9x80oUX7S1mDQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, "node_modules/@fastify/error": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/@fastify/error/-/error-4.2.0.tgz", @@ -4423,6 +4448,29 @@ "dequal": "^2.0.3" } }, + "node_modules/@fastify/multipart": { + "version": "10.1.2", + "resolved": "https://registry.npmjs.org/@fastify/multipart/-/multipart-10.1.2.tgz", + "integrity": "sha512-WHsqW5ffDXCbXXtdBQDryxu58fQvFPVGh4E4MlCO6hwCLNm0lYTCS6GKXUw2P64REDoECUNpMVvtMMstdIyBjQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/busboy": "^3.0.0", + "@fastify/deepmerge": "^3.0.0", + "@fastify/error": "^4.0.0", + "fastify-plugin": "^6.0.0", + "secure-json-parse": "^4.0.0" + } + }, "node_modules/@fastify/proxy-addr": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/@fastify/proxy-addr/-/proxy-addr-5.1.1.tgz", @@ -4557,7 +4605,6 @@ "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", "license": "MIT", - "optional": true, "engines": { "node": ">=18" } @@ -11169,6 +11216,27 @@ } } }, + "node_modules/expo-image-loader": { + "version": "57.0.1", + "resolved": "https://registry.npmjs.org/expo-image-loader/-/expo-image-loader-57.0.1.tgz", + "integrity": "sha512-uhrZKLT/cTl2mXyR28kPpVkS5O+PK9N1QA/07IFM4f5T4g0lTW1JHT3NEWwEEsGFldPmVX4j7LwUVVZxE+woug==", + "license": "MIT", + "peerDependencies": { + "expo": "*" + } + }, + "node_modules/expo-image-picker": { + "version": "57.0.20", + "resolved": "https://registry.npmjs.org/expo-image-picker/-/expo-image-picker-57.0.20.tgz", + "integrity": "sha512-plgcGuMZMWqDchjXQW6S/WepYLB5KqfbZOzwC6xe0+j5IhY4ipvVuRZcXeP/0NgKgU0Jiu/XoTK4Bi8crtZ+sQ==", + "license": "MIT", + "dependencies": { + "expo-image-loader": "~57.0.1" + }, + "peerDependencies": { + "expo": "*" + } + }, "node_modules/expo-keep-awake": { "version": "57.0.2", "resolved": "https://registry.npmjs.org/expo-keep-awake/-/expo-keep-awake-57.0.2.tgz", @@ -17416,7 +17484,6 @@ "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.5.tgz", "integrity": "sha512-Ywn4OnzGukp7CDMrp08RQ50YKmuwG47brZgIVPTvBaaAfQlRlygrRqSrxdCiL9M+LlzLBiJ68IR1QqvzHyjC7g==", "license": "Apache-2.0", - "optional": true, "dependencies": { "@img/colour": "^1.1.0", "detect-libc": "^2.1.2", diff --git a/packages/shared/src/client.ts b/packages/shared/src/client.ts index 077a0bd..ebc08bb 100644 --- a/packages/shared/src/client.ts +++ b/packages/shared/src/client.ts @@ -46,6 +46,17 @@ export function createApiClient({ baseUrl, getToken, credentials }: ApiClientOpt return data as T; } + /** Multipart upload (the browser/React Native sets the boundary itself). */ + async function upload(path: string, form: FormData): Promise { + const headers: Record = {}; + const token = getToken?.(); + if (token) headers.Authorization = `Bearer ${token}`; + const res = await fetch(baseUrl + path, { method: 'POST', headers, credentials, body: form }); + const data = await res.json().catch(() => ({})); + if (!res.ok) throw new ApiError(res.status, (data as { error?: string }).error ?? `Upload failed (${res.status})`); + return data as T; + } + const q = encodeURIComponent; return { @@ -60,6 +71,9 @@ export function createApiClient({ baseUrl, getToken, credentials }: ApiClientOpt me: () => request('/me'), updateMe: (input: UpdateMeInput) => request('/me', { method: 'PATCH', body: input }), deleteAccount: () => request('/me', { method: 'DELETE' }), + /** `form` holds one file field (any name): a File on web, `{ uri, name, type }` on React Native. */ + uploadAvatar: (form: FormData) => upload('/me/avatar', form), + removeAvatar: () => request('/me/avatar', { method: 'DELETE' }), setSharing: (pause: '1h' | '8h' | 'indefinitely' | null) => request('/me/sharing', { method: 'PUT', body: { pause } }), setFeatured: (input: SetFeaturedInput) => request('/me/featured', { method: 'PUT', body: input }), clearFeatured: () => request('/me/featured', { method: 'DELETE' }), diff --git a/packages/shared/src/schemas.ts b/packages/shared/src/schemas.ts index f23cbd9..bf8b29c 100644 --- a/packages/shared/src/schemas.ts +++ b/packages/shared/src/schemas.ts @@ -24,7 +24,6 @@ export const loginSchema = z.object({ export const updateMeSchema = z.object({ displayName: z.string().trim().max(64).nullable().optional(), bio: z.string().trim().max(280).nullable().optional(), - avatarUrl: z.url().max(500).nullable().optional(), preferredService: z.enum(STREAMING_SERVICES).nullable().optional(), /** Mark onboarding as finished. */ onboarded: z.literal(true).optional(), diff --git a/packages/shared/src/services.ts b/packages/shared/src/services.ts index 7b28a74..cb4356e 100644 --- a/packages/shared/src/services.ts +++ b/packages/shared/src/services.ts @@ -33,6 +33,20 @@ export const SERVICE_LABELS: Record = { + spotify: '#1DB954', + apple_music: '#FA2D48', + youtube_music: '#FF0033', + deezer: '#A238FF', + tidal: '#000000', + lastfm: '#D51007', + listenbrainz: '#EB743B', + plex: '#E5A00D', + jellyfin: '#AA5CC3', + android_media_session: '#5b4cf0', +}; + /** Fallback when we have no direct link: a search on that service. */ export function searchUrl(service: StreamingService, title: string, artist: string): string { const q = encodeURIComponent(`${artist} ${title}`);