From 6c104b9db31fbcd5edd28a7f14c54eb34ef124a0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B5=AA=E5=AD=90?= Date: Sat, 16 May 2026 09:20:00 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=8C=E5=96=84=E8=B4=A6=E5=8F=B7html?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/activitypub.ts | 10 +- src/index.ts | 16 +- src/mastodon.ts | 3 +- src/profile.ts | 433 +++++++++++++++++++++++++++++++++++++++++++++ src/util.ts | 4 + wrangler.jsonc | 12 +- 6 files changed, 466 insertions(+), 12 deletions(-) create mode 100644 src/profile.ts diff --git a/src/activitypub.ts b/src/activitypub.ts index 9667986..7d73906 100644 --- a/src/activitypub.ts +++ b/src/activitypub.ts @@ -43,7 +43,8 @@ import { hostFromBaseUrl, id, mediaUrl, - objectUrl + objectUrl, + profileUrl } from "./util"; export async function webFinger(request: Request, env: Env): Promise { @@ -56,10 +57,10 @@ export async function webFinger(request: Request, env: Env): Promise { return json({ subject: `acct:${user.username}@${hostFromBaseUrl(env)}`, - aliases: [actorUrl(env, user)], + aliases: [actorUrl(env, user), profileUrl(env, user)], links: [ { rel: "self", type: "application/activity+json", href: actorUrl(env, user) }, - { rel: "http://webfinger.net/rel/profile-page", type: "text/html", href: actorUrl(env, user) } + { rel: "http://webfinger.net/rel/profile-page", type: "text/html", href: profileUrl(env, user) } ] }, 200, { "content-type": "application/jrd+json; charset=utf-8" }); } @@ -98,6 +99,7 @@ export async function actor(env: Env, username: string): Promise { export async function actorDocument(env: Env, user: User): Promise { const url = actorUrl(env, user); + const profile = profileUrl(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`; @@ -119,7 +121,7 @@ export async function actorDocument(env: Env, user: User): Promise { preferredUsername: user.username, name: user.display_name, summary: user.note, - url, + url: profile, inbox: `${url}/inbox`, outbox: `${url}/outbox`, followers: `${url}/followers`, diff --git a/src/index.ts b/src/index.ts index 0a8bcb5..7213b59 100644 --- a/src/index.ts +++ b/src/index.ts @@ -88,6 +88,7 @@ import { verifyCredentials } from "./mastodon"; import { processOutgoingDeliveries } from "./federation"; +import { profilePage } from "./profile"; export default { async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise { @@ -212,7 +213,15 @@ async function route(request: Request, env: Env): Promise { if (method === "GET" && (m = path.match(/^\/media\/(.+)$/))) return serveMedia(env, m[1]); - if (method === "GET" && (m = path.match(/^\/users\/([^/]+)$/))) return actor(env, decodeURIComponent(m[1])); + if (method === "GET" && (m = path.match(/^\/@([^/]+)$/))) return profilePage(env, decodeURIComponent(m[1])); + if (method === "GET" && (m = path.match(/^\/web\/@([^/]+)$/))) return profilePage(env, decodeURIComponent(m[1])); + if (method === "GET" && (m = path.match(/^\/web\/accounts\/([^/]+)$/))) return profilePage(env, decodeURIComponent(m[1])); + + if (method === "GET" && (m = path.match(/^\/users\/([^/]+)$/))) { + const username = decodeURIComponent(m[1]); + if (wantsProfileHtml(request)) return profilePage(env, username); + return actor(env, username); + } if (method === "GET" && (m = path.match(/^\/users\/([^/]+)\/outbox$/))) return outbox(request, env, decodeURIComponent(m[1])); if (method === "POST" && (m = path.match(/^\/users\/([^/]+)\/inbox$/))) return inboxHandler(request, env, decodeURIComponent(m[1])); if (method === "POST" && path === "/inbox") return inboxHandler(request, env, null); @@ -222,3 +231,8 @@ async function route(request: Request, env: Env): Promise { return json({ error: "not_found" }, 404); } + +function wantsProfileHtml(request: Request): boolean { + const accept = request.headers.get("accept") ?? ""; + return /\btext\/html\b/i.test(accept) && !/(application\/activity\+json|application\/ld\+json)/i.test(accept); +} diff --git a/src/mastodon.ts b/src/mastodon.ts index e73b0e0..545a39b 100644 --- a/src/mastodon.ts +++ b/src/mastodon.ts @@ -94,6 +94,7 @@ import { mediaUrl, normalizeArray, objectUrl, + profileUrl, safeFileName, tokenString } from "./util"; @@ -2074,7 +2075,7 @@ async function accountJson(env: Env, user: User): Promise { + const user = await getUserByIdOrUsername(env, key); + if (!user) return html(renderNotFoundPage(env, key), 404); + + const [followersCount, followingCount, statusesCount, fields, rows] = await Promise.all([ + countFollowers(env, user.id), + countFollowing(env, user.id), + countStatuses(env, user.id), + listProfileFields(env, user.id), + env.DB.prepare( + "SELECT * FROM statuses WHERE user_id = ? AND visibility IN ('public', 'unlisted') ORDER BY created_at DESC LIMIT 20" + ).bind(user.id).all() + ]); + + const statuses = await Promise.all(rows.results.map(async (status) => ({ + status, + media: await listMediaForStatus(env, status.id) + }))); + + return html(renderProfilePage(env, user, { + followersCount, + followingCount, + statusesCount, + fields, + statuses + })); +} + +function renderProfilePage( + env: Env, + user: User, + data: { + followersCount: number; + followingCount: number; + statusesCount: number; + fields: { name: string; value: string }[]; + statuses: ProfileStatus[]; + } +): string { + const displayName = user.display_name || user.username; + const handle = `@${user.username}@${hostFromBaseUrl(env)}`; + 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`; + const pageUrl = profileUrl(env, user); + const actor = actorUrl(env, user); + const description = plainText(user.note).slice(0, 180); + + return ` + + + + +${escapeHtml(displayName)} (${escapeHtml(handle)}) + + + + + + + + + + +
+
+
+
+ +
+

${escapeHtml(displayName)}

+

${escapeHtml(handle)}

+
+
+ ${renderNote(user.note)} + ${renderFields(data.fields)} +
+ ${renderStat(data.statusesCount, "Posts")} + ${renderStat(data.followingCount, "Following")} + ${renderStat(data.followersCount, "Followers")} +
+
+ ${renderStatuses(env, data.statuses)} +
+ +`; +} + +function renderNotFoundPage(env: Env, key: string): string { + const title = "Profile not found"; + return ` + + + + +${title} + + + +
+
+

${title}

+

${escapeHtml(key)} on ${escapeHtml(hostFromBaseUrl(env))}

+
+
+ +`; +} + +function renderNote(note: string): string { + const text = plainText(note); + if (!text) return ""; + return `
${escapeHtml(text).replace(/\n/g, "
")}
`; +} + +function renderFields(fields: { name: string; value: string }[]): string { + const items = fields.filter((field) => field.name || field.value); + if (items.length === 0) return ""; + return `
${items.map((field) => ` +
+
${escapeHtml(field.name)}
+
${escapeHtml(plainText(field.value))}
+
`).join("")} +
`; +} + +function renderStat(value: number, label: string): string { + return `
${formatCount(value)}
${label}
`; +} + +function renderStatuses(env: Env, items: ProfileStatus[]): string { + if (items.length === 0) { + return `
+

No public posts yet.

+
`; + } + + return `
+ ${items.map((item) => renderStatus(env, item)).join("")} +
`; +} + +function renderStatus(env: Env, item: ProfileStatus): string { + const { status, media } = item; + const statusUrl = status.url || objectUrl(env, status.id); + return ``; +} + +function renderMedia(env: Env, media: Media[]): string { + if (media.length === 0) return ""; + return `
${media.map((item) => { + const url = mediaUrl(env, item.r2_key); + const alt = item.description ?? ""; + if (item.mime_type.startsWith("image/")) { + return `${escapeHtml(alt)}`; + } + return `${escapeHtml(item.description || item.mime_type)}`; + }).join("")}
`; +} + +function plainText(value: string): string { + return value.replace(/<[^>]*>/g, " ").replace(/\s+/g, " ").trim(); +} + +function formatDate(value: string): string { + const date = new Date(value); + if (Number.isNaN(date.getTime())) return value; + return new Intl.DateTimeFormat("en", { + year: "numeric", + month: "short", + day: "2-digit", + hour: "2-digit", + minute: "2-digit" + }).format(date); +} + +function formatCount(value: number): string { + return new Intl.NumberFormat("en", { notation: "compact", maximumFractionDigits: 1 }).format(value); +} + +function profileCss(): string { + return ` +:root { + color-scheme: light; + --bg: #f6f7f9; + --surface: #ffffff; + --ink: #202428; + --muted: #66717c; + --line: #dce1e6; + --accent: #0f766e; + --accent-strong: #0b5d56; + --link: #1d4ed8; + --warn: #9a3412; + --shadow: 0 18px 45px rgba(32, 36, 40, .08); +} +* { box-sizing: border-box; } +body { + margin: 0; + min-height: 100vh; + background: var(--bg); + color: var(--ink); + font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + line-height: 1.5; +} +a { color: var(--link); text-decoration-thickness: .08em; text-underline-offset: .18em; } +a:focus-visible { outline: 3px solid rgba(15, 118, 110, .35); outline-offset: 3px; border-radius: 4px; } +.shell { + width: min(100%, 760px); + margin: 0 auto; + padding: 24px 16px 48px; +} +.profile, +.status { + background: var(--surface); + border: 1px solid var(--line); + border-radius: 8px; + box-shadow: var(--shadow); + overflow: hidden; +} +.cover { + aspect-ratio: 3 / 1; + background: #d9e2e8; + border-bottom: 1px solid var(--line); +} +.cover img { + display: block; + width: 100%; + height: 100%; + object-fit: cover; +} +.identity { + display: flex; + align-items: end; + gap: 18px; + padding: 0 28px; + transform: translateY(-34px); + margin-bottom: -18px; +} +.avatar { + width: 112px; + height: 112px; + border-radius: 8px; + border: 5px solid var(--surface); + background: var(--surface); + object-fit: cover; + box-shadow: 0 8px 24px rgba(32, 36, 40, .16); +} +.name-block { min-width: 0; padding-bottom: 10px; } +h1 { + margin: 0; + font-size: 2.35rem; + line-height: 1.02; + letter-spacing: 0; +} +.handle { + margin: 8px 0 0; + color: var(--muted); + font-size: 1rem; + overflow-wrap: anywhere; +} +.note { + padding: 0 28px 20px; + font-size: 1rem; + overflow-wrap: anywhere; +} +.fields { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 1px; + margin: 0 28px 24px; + border: 1px solid var(--line); + border-radius: 8px; + overflow: hidden; + background: var(--line); +} +.fields div { + min-width: 0; + padding: 12px 14px; + background: var(--surface); +} +.fields dt { + color: var(--muted); + font-size: .78rem; + font-weight: 700; + text-transform: uppercase; +} +.fields dd { + margin: 4px 0 0; + overflow-wrap: anywhere; +} +.stats { + display: grid; + grid-template-columns: repeat(3, 1fr); + margin: 0; + border-top: 1px solid var(--line); +} +.stats div { + padding: 18px 12px; + text-align: center; +} +.stats div + div { border-left: 1px solid var(--line); } +.stats dt { + font-size: 1.3rem; + font-weight: 800; + color: var(--accent-strong); +} +.stats dd { + margin: 2px 0 0; + color: var(--muted); + font-size: .88rem; +} +.timeline { + display: grid; + gap: 14px; + margin-top: 16px; +} +.status { padding: 20px 22px; } +.status-meta { + display: flex; + align-items: center; + gap: 10px; + color: var(--muted); + font-size: .88rem; +} +.status-meta span { + color: var(--warn); + font-weight: 700; +} +.summary { + margin: 14px 0 0; + padding: 10px 12px; + border-left: 3px solid var(--accent); + background: #eef7f5; + border-radius: 0 6px 6px 0; + font-weight: 700; +} +.content { + margin-top: 12px; + overflow-wrap: anywhere; +} +.content p { margin: 0 0 12px; } +.content p:last-child { margin-bottom: 0; } +.content .mention, +.content .hashtag { font-weight: 650; } +.media-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 8px; + margin-top: 16px; +} +.media-grid a { + display: block; + min-width: 0; + border: 1px solid var(--line); + border-radius: 8px; + overflow: hidden; + background: #eef1f4; +} +.media-grid img { + display: block; + width: 100%; + aspect-ratio: 4 / 3; + object-fit: cover; +} +.attachment { + padding: 14px; + overflow-wrap: anywhere; +} +.empty { + margin: 0; + padding: 28px; + color: var(--muted); + text-align: center; + background: var(--surface); + border: 1px dashed var(--line); + border-radius: 8px; +} +.not-found { padding: 28px; } +@media (max-width: 560px) { + .shell { padding: 12px 10px 32px; } + .identity { + align-items: center; + gap: 12px; + padding: 0 16px; + transform: translateY(-24px); + margin-bottom: -8px; + } + .avatar { + width: 82px; + height: 82px; + border-width: 4px; + } + h1 { font-size: 1.7rem; } + .note { padding: 0 16px 18px; } + .fields { + grid-template-columns: 1fr; + margin: 0 16px 18px; + } + .fields div, + .fields div:nth-child(odd) { + border-right: 0; + } + .stats dt { font-size: 1.05rem; } + .stats dd { font-size: .78rem; } + .status { padding: 17px 16px; } + .media-grid { grid-template-columns: 1fr; } +} +`; +} diff --git a/src/util.ts b/src/util.ts index df59b6b..a9c0434 100644 --- a/src/util.ts +++ b/src/util.ts @@ -95,6 +95,10 @@ export function actorUrl(env: Env, user: User): string { return `${baseUrl(env)}/users/${user.username}`; } +export function profileUrl(env: Env, user: User): string { + return `${baseUrl(env)}/@${encodeURIComponent(user.username)}`; +} + export function objectUrl(env: Env, statusId: string): string { return `${baseUrl(env)}/objects/${statusId}`; } diff --git a/wrangler.jsonc b/wrangler.jsonc index 01943f4..ae9ffad 100644 --- a/wrangler.jsonc +++ b/wrangler.jsonc @@ -1,13 +1,13 @@ { - "name": "toot-worker", + "name": "toot-worker",//单用户联邦应用 "main": "src/index.ts", "compatibility_date": "2026-05-13", "vars": { - "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" + "PUBLIC_BASE_URL": "https://zxd.im",//实例域名 + "MEDIA_BASE_URL": "https://toot-media.zxd.im",//R2域名 + "INSTANCE_NAME": "Toot Worker",//实例名 + "ADMIN_USERNAME": "sun"//管理员用户名 + //管理员密码使用"ADMIN_PASSWORD"添加到系统变量 }, "secrets": { "required": ["ADMIN_PASSWORD"]