修复个人资料

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
+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 }))
};
}