diff --git a/migrations/0004_user_assets.sql b/migrations/0004_user_assets.sql new file mode 100644 index 0000000..7662216 --- /dev/null +++ b/migrations/0004_user_assets.sql @@ -0,0 +1,4 @@ +-- User avatar/header storage. + +ALTER TABLE users ADD COLUMN avatar_r2_key TEXT; +ALTER TABLE users ADD COLUMN header_r2_key TEXT; diff --git a/src/activitypub.ts b/src/activitypub.ts index dbaaa73..1a6880d 100644 --- a/src/activitypub.ts +++ b/src/activitypub.ts @@ -8,6 +8,7 @@ import { getStatus, getStatusByObjectId, getUserByUsername, + listProfileFields, recordNotification, upsertActorCache, upsertCachedStatus @@ -42,6 +43,7 @@ import { clampLimit, hostFromBaseUrl, id, + mediaUrl, objectUrl } from "./util"; @@ -97,8 +99,22 @@ export async function actor(env: Env, username: string): Promise { export async function actorDocument(env: Env, user: User): Promise { const url = actorUrl(env, user); + const fields = await listProfileFields(env, user.id); + const avatarUrl = user.avatar_r2_key ? mediaUrl(env, user.avatar_r2_key) : `${baseUrl(env)}/avatar.png`; + const headerUrl = user.header_r2_key ? mediaUrl(env, user.header_r2_key) : `${baseUrl(env)}/header.png`; + const avatarMime = guessImageMime(user.avatar_r2_key); + const headerMime = guessImageMime(user.header_r2_key); return { - "@context": [ACTIVITY_CONTEXT, SECURITY_CONTEXT, { manuallyApprovesFollowers: "as:manuallyApprovesFollowers" }], + "@context": [ + ACTIVITY_CONTEXT, + SECURITY_CONTEXT, + { + manuallyApprovesFollowers: "as:manuallyApprovesFollowers", + schema: "http://schema.org#", + PropertyValue: "schema:PropertyValue", + value: "schema:value" + } + ], id: url, type: "Person", preferredUsername: user.username, @@ -110,10 +126,11 @@ export async function actorDocument(env: Env, user: User): Promise { followers: `${url}/followers`, following: `${url}/following`, endpoints: { sharedInbox: `${baseUrl(env)}/inbox` }, - icon: { type: "Image", mediaType: "image/svg+xml", url: `${baseUrl(env)}/avatar.png` }, - image: { type: "Image", mediaType: "image/svg+xml", url: `${baseUrl(env)}/header.png` }, + icon: { type: "Image", mediaType: avatarMime, url: avatarUrl }, + image: { type: "Image", mediaType: headerMime, url: headerUrl }, manuallyApprovesFollowers: false, discoverable: true, + attachment: fields.map((field) => ({ type: "PropertyValue", name: field.name, value: field.value })), publicKey: { id: `${url}#main-key`, owner: url, @@ -122,6 +139,19 @@ export async function actorDocument(env: Env, user: User): Promise { }; } +function guessImageMime(r2Key: string | null): string { + if (!r2Key) return "image/svg+xml"; + const ext = r2Key.split(".").pop()?.toLowerCase(); + switch (ext) { + case "jpg": case "jpeg": return "image/jpeg"; + case "png": return "image/png"; + case "gif": return "image/gif"; + case "webp": return "image/webp"; + case "avif": return "image/avif"; + default: return "image/jpeg"; + } +} + export async function outbox(request: Request, env: Env, username: string): Promise { const user = await getUserByUsername(env, username); if (!user) return json({ error: "not_found" }, 404); diff --git a/src/db.ts b/src/db.ts index b2eee93..bd78403 100644 --- a/src/db.ts +++ b/src/db.ts @@ -274,3 +274,29 @@ export async function touchOAuthToken(env: Env, token: string): Promise { await env.DB.prepare("UPDATE oauth_tokens SET last_used_at = ? WHERE token = ?") .bind(new Date().toISOString(), token).run(); } + +export type ProfileField = { name: string; value: string }; + +export async function listProfileFields(env: Env, userId: string): Promise { + const rows = await env.DB.prepare("SELECT name, value FROM user_profile_fields WHERE user_id = ? ORDER BY position ASC").bind(userId).all(); + return rows.results; +} + +export async function replaceProfileFields(env: Env, userId: string, fields: ProfileField[]): Promise { + await env.DB.prepare("DELETE FROM user_profile_fields WHERE user_id = ?").bind(userId).run(); + for (let i = 0; i < fields.length; i++) { + const field = fields[i]; + if (!field.name && !field.value) continue; + await env.DB.prepare( + "INSERT INTO user_profile_fields (user_id, position, name, value) VALUES (?, ?, ?, ?)" + ).bind(userId, i, field.name.slice(0, 255), field.value.slice(0, 2000)).run(); + } +} + +export async function setUserAvatarKey(env: Env, userId: string, key: string | null): Promise { + await env.DB.prepare("UPDATE users SET avatar_r2_key = ? WHERE id = ?").bind(key, userId).run(); +} + +export async function setUserHeaderKey(env: Env, userId: string, key: string | null): Promise { + await env.DB.prepare("UPDATE users SET header_r2_key = ? WHERE id = ?").bind(key, userId).run(); +} diff --git a/src/index.ts b/src/index.ts index 7cc9d3f..655d80b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -38,6 +38,7 @@ import { homeTimeline, instance, instanceV2, + lookupAccount, markersList, notificationClear, notificationDismiss, @@ -108,6 +109,7 @@ async function route(request: Request, env: Env): Promise { if ((method === "PATCH" || method === "POST") && path === "/api/v1/accounts/update_credentials") return updateCredentials(request, env); if (method === "GET" && path === "/api/v1/accounts/relationships") return getRelationships(request, env); if (method === "GET" && path === "/api/v1/accounts/search") return search(request, env); + if (method === "GET" && path === "/api/v1/accounts/lookup") return lookupAccount(request, env); let m: RegExpMatchArray | null; diff --git a/src/mastodon.ts b/src/mastodon.ts index b86f420..8974d95 100644 --- a/src/mastodon.ts +++ b/src/mastodon.ts @@ -29,9 +29,13 @@ import { getUserByUsername, insertOAuthToken, listCachedStatusAttachments, + listProfileFields, recordNotification, removeBookmark, removePin, + replaceProfileFields, + setUserAvatarKey, + setUserHeaderKey, takeOAuthCode } from "./db"; import { @@ -49,6 +53,7 @@ import { json, readBody } from "./http"; +import type { ParsedBody } from "./http"; import type { CachedStatus, Follow, @@ -280,27 +285,59 @@ export async function revoke(request: Request, env: Env): Promise { export async function verifyCredentials(request: Request, env: Env): Promise { const user = await requireUser(request, env); const account = await accountJson(env, user) as Record; + const fields = await listProfileFields(env, user.id); account.source = { privacy: "public", sensitive: false, language: "en", note: user.note, - fields: [] + fields: fields.map((field) => ({ name: field.name, value: field.value })) }; return json(account); } export async function updateCredentials(request: Request, env: Env): Promise { const user = await requireUser(request, env); - const body = await readBody(request); + const contentType = (request.headers.get("content-type") ?? "").toLowerCase(); + let form: FormData | null = null; + let body: ParsedBody = {}; + if (contentType.includes("multipart/form-data") || contentType.startsWith("application/x-www-form-urlencoded")) { + form = await request.formData(); + body = parsedBodyFromForm(form); + } else { + body = await readBody(request); + } + const displayName = bodyString(body, "display_name", user.display_name); const note = bodyString(body, "note", user.note); await env.DB.prepare("UPDATE users SET display_name = ?, note = ? WHERE id = ?").bind(displayName, note, user.id).run(); + const password = bodyString(body, "password"); if (password) { const hash = await hashPassword(password); await env.DB.prepare("UPDATE users SET password_hash = ? WHERE id = ?").bind(hash, user.id).run(); } + + const fields = extractFieldsAttributes(body); + if (fields !== null) { + await replaceProfileFields(env, user.id, fields); + } + + if (form) { + const avatar = form.get("avatar"); + if (avatar instanceof File && avatar.size > 0) { + if (avatar.size > MAX_MEDIA_BYTES) return json({ error: "avatar too large" }, 413); + const key = await storeProfileAsset(env, user.id, "avatar", avatar); + await setUserAvatarKey(env, user.id, key); + } + const header = form.get("header"); + if (header instanceof File && header.size > 0) { + if (header.size > MAX_MEDIA_BYTES) return json({ error: "header too large" }, 413); + const key = await storeProfileAsset(env, user.id, "header", header); + await setUserHeaderKey(env, user.id, key); + } + } + const refreshed = await getUserById(env, user.id); if (!refreshed) throw new HttpError(500, "user_missing"); const followerInboxes = await gatherFollowerInboxes(env, user.id); @@ -310,10 +347,97 @@ export async function updateCredentials(request: Request, env: Env): Promise { + const ext = mimeExtension(file.type) ?? safeFileName(file.name).split(".").pop() ?? "bin"; + const key = `${userId}/${kind}-${id()}.${ext}`; + await env.MEDIA.put(key, file.stream(), { httpMetadata: { contentType: file.type || "application/octet-stream" } }); + return key; +} + +function mimeExtension(mime: string): string | null { + switch (mime) { + case "image/jpeg": case "image/jpg": return "jpg"; + case "image/png": return "png"; + case "image/gif": return "gif"; + case "image/webp": return "webp"; + case "image/avif": return "avif"; + default: return null; + } +} + +function parsedBodyFromForm(form: FormData): ParsedBody { + const data: ParsedBody = {}; + for (const [key, value] of form) { + const cleanKey = key.endsWith("[]") ? key.slice(0, -2) : key; + const normalized = value instanceof File ? value : String(value); + const existing = data[cleanKey]; + if (existing === undefined) { + data[cleanKey] = key.endsWith("[]") ? [normalized as string] : normalized; + } else if (Array.isArray(existing)) { + existing.push(normalized as string); + } else { + data[cleanKey] = [existing as string, normalized as string]; + } + } + return data; +} + +function extractFieldsAttributes(body: ParsedBody): { name: string; value: string }[] | null { + const flat = body["fields_attributes"]; + if (Array.isArray(flat)) { + return flat.map((entry) => { + if (typeof entry === "string") { + try { + const parsed = JSON.parse(entry) as { name?: string; value?: string }; + return { name: String(parsed.name ?? ""), value: String(parsed.value ?? "") }; + } catch { + return { name: entry, value: "" }; + } + } + const obj = entry as unknown as { name?: string; value?: string }; + return { name: String(obj.name ?? ""), value: String(obj.value ?? "") }; + }); + } + const indexed: { name: string; value: string }[] = []; + let touched = false; + for (const key of Object.keys(body)) { + const match = key.match(/^fields_attributes\[(\d+)\]\[(name|value)\]$/); + if (!match) continue; + touched = true; + const idx = Number(match[1]); + indexed[idx] = indexed[idx] ?? { name: "", value: "" }; + const v = body[key]; + indexed[idx][match[2] as "name" | "value"] = typeof v === "string" ? v : Array.isArray(v) ? String(v[0] ?? "") : ""; + } + if (!touched) return null; + return indexed.filter(Boolean); +} + export async function getAccount(env: Env, accountId: string): Promise { - const user = await getUserByIdOrUsername(env, accountId); - if (!user) return json({ error: "Record not found" }, 404); - return json(await accountJson(env, user)); + const local = await getUserByIdOrUsername(env, accountId); + if (local) return json(await accountJson(env, local)); + if (accountId.startsWith("http://") || accountId.startsWith("https://")) { + const cache = await resolveRemoteActor(env, accountId); + if (cache) return json(remoteAccountJson(cache)); + } + return json({ error: "Record not found" }, 404); +} + +export async function lookupAccount(request: Request, env: Env): Promise { + const acct = (new URL(request.url).searchParams.get("acct") ?? "").trim(); + if (!acct) return json({ error: "acct parameter is required" }, 422); + const resolved = await resolveAcct(env, acct); + if (!resolved) return json({ error: "Record not found" }, 404); + + if (resolved.actorId.startsWith(baseUrl(env))) { + const match = resolved.actorId.match(/\/users\/([^/?#]+)$/); + const user = match ? await getUserByUsername(env, match[1]) : null; + if (!user) return json({ error: "Record not found" }, 404); + return json(await accountJson(env, user)); + } + const cache = await resolveRemoteActor(env, resolved.actorId); + if (!cache) return json({ error: "Record not found" }, 404); + return json(remoteAccountJson(cache)); } export async function accountStatuses(request: Request, env: Env, accountId: string): Promise { @@ -1147,12 +1271,15 @@ async function buildStatusSerializationContext( } async function accountJson(env: Env, user: User): Promise> { - const [followersCount, followingCount, statusesCount] = await Promise.all([ + const [followersCount, followingCount, statusesCount, fields] = await Promise.all([ countFollowers(env, user.id), countFollowing(env, user.id), - countStatuses(env, user.id) + countStatuses(env, user.id), + listProfileFields(env, user.id) ]); const acct = `${user.username}`; + const avatar = user.avatar_r2_key ? mediaUrl(env, user.avatar_r2_key) : `${baseUrl(env)}/avatar.png`; + const header = user.header_r2_key ? mediaUrl(env, user.header_r2_key) : `${baseUrl(env)}/header.png`; return { id: user.id, username: user.username, @@ -1165,16 +1292,16 @@ async function accountJson(env: Env, user: User): Promise ({ name: field.name, value: field.value, verified_at: null })) }; } diff --git a/src/types.ts b/src/types.ts index 337123b..8a15a5d 100644 --- a/src/types.ts +++ b/src/types.ts @@ -8,6 +8,8 @@ export type User = { password_hash: string; private_key_jwk: string; public_key_jwk: string; + avatar_r2_key: string | null; + header_r2_key: string | null; created_at: string; }; diff --git a/wrangler.jsonc b/wrangler.jsonc index e0785af..b4ab932 100644 --- a/wrangler.jsonc +++ b/wrangler.jsonc @@ -6,8 +6,8 @@ "PUBLIC_BASE_URL": "https://zxd.im", "MEDIA_BASE_URL": "https://toot-media.zxd.im", "INSTANCE_NAME": "Toot Worker", - "ADMIN_USERNAME": "sun", - "ADMIN_PASSWORD": "change-me-before-deploy" + "ADMIN_USERNAME": "sun" + //"ADMIN_PASSWORD": "change-me-before-deploy" }, "d1_databases": [ {