修复个人资料

This commit is contained in:
浪子
2026-05-14 13:37:13 +08:00
parent 5b01f18719
commit 5a9acd60c5
7 changed files with 208 additions and 17 deletions
+4
View File
@@ -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;
+33 -3
View File
@@ -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<Response> {
export async function actorDocument(env: Env, user: User): Promise<Json> {
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<Json> {
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<Json> {
};
}
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<Response> {
const user = await getUserByUsername(env, username);
if (!user) return json({ error: "not_found" }, 404);
+26
View File
@@ -274,3 +274,29 @@ export async function touchOAuthToken(env: Env, token: string): Promise<void> {
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<ProfileField[]> {
const rows = await env.DB.prepare("SELECT name, value FROM user_profile_fields WHERE user_id = ? ORDER BY position ASC").bind(userId).all<ProfileField>();
return rows.results;
}
export async function replaceProfileFields(env: Env, userId: string, fields: ProfileField[]): Promise<void> {
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<void> {
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<void> {
await env.DB.prepare("UPDATE users SET header_r2_key = ? WHERE id = ?").bind(key, userId).run();
}
+2
View File
@@ -38,6 +38,7 @@ import {
homeTimeline,
instance,
instanceV2,
lookupAccount,
markersList,
notificationClear,
notificationDismiss,
@@ -108,6 +109,7 @@ async function route(request: Request, env: Env): Promise<Response> {
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;
+139 -12
View File
@@ -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<Response> {
export async function verifyCredentials(request: Request, env: Env): Promise<Response> {
const user = await requireUser(request, env);
const account = await accountJson(env, user) as Record<string, unknown>;
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<Response> {
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<Res
return json(await accountJson(env, refreshed));
}
async function storeProfileAsset(env: Env, userId: string, kind: "avatar" | "header", file: File): Promise<string> {
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<Response> {
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<Response> {
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<Response> {
@@ -1147,12 +1271,15 @@ async function buildStatusSerializationContext(
}
async function accountJson(env: Env, user: User): Promise<Record<string, unknown>> {
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<Record<string, unknown
created_at: user.created_at,
note: user.note,
url: actorUrl(env, user),
avatar: `${baseUrl(env)}/avatar.png`,
avatar_static: `${baseUrl(env)}/avatar.png`,
header: `${baseUrl(env)}/header.png`,
header_static: `${baseUrl(env)}/header.png`,
avatar,
avatar_static: avatar,
header,
header_static: header,
followers_count: followersCount,
following_count: followingCount,
statuses_count: statusesCount,
last_status_at: null,
emojis: [],
fields: []
fields: fields.map((field) => ({ name: field.name, value: field.value, verified_at: null }))
};
}
+2
View File
@@ -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;
};
+2 -2
View File
@@ -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": [
{