修复个人资料
This commit is contained in:
+139
-12
@@ -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 }))
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user