修复个人资料
This commit is contained in:
@@ -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
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
+137
-10
@@ -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,11 +347,98 @@ 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);
|
||||
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> {
|
||||
const user = await getUserByIdOrUsername(env, accountId);
|
||||
@@ -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 }))
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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": [
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user