完善账号html
This commit is contained in:
+6
-4
@@ -43,7 +43,8 @@ import {
|
||||
hostFromBaseUrl,
|
||||
id,
|
||||
mediaUrl,
|
||||
objectUrl
|
||||
objectUrl,
|
||||
profileUrl
|
||||
} from "./util";
|
||||
|
||||
export async function webFinger(request: Request, env: Env): Promise<Response> {
|
||||
@@ -56,10 +57,10 @@ export async function webFinger(request: Request, env: Env): Promise<Response> {
|
||||
|
||||
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<Response> {
|
||||
|
||||
export async function actorDocument(env: Env, user: User): Promise<Json> {
|
||||
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<Json> {
|
||||
preferredUsername: user.username,
|
||||
name: user.display_name,
|
||||
summary: user.note,
|
||||
url,
|
||||
url: profile,
|
||||
inbox: `${url}/inbox`,
|
||||
outbox: `${url}/outbox`,
|
||||
followers: `${url}/followers`,
|
||||
|
||||
+15
-1
@@ -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<Response> {
|
||||
@@ -212,7 +213,15 @@ async function route(request: Request, env: Env): Promise<Response> {
|
||||
|
||||
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<Response> {
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
+2
-1
@@ -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<Record<string, unknown
|
||||
group: false,
|
||||
created_at: user.created_at,
|
||||
note: user.note,
|
||||
url: actorUrl(env, user),
|
||||
url: profileUrl(env, user),
|
||||
avatar,
|
||||
avatar_static: avatar,
|
||||
header,
|
||||
|
||||
+433
@@ -0,0 +1,433 @@
|
||||
import {
|
||||
countFollowers,
|
||||
countFollowing,
|
||||
countStatuses,
|
||||
getUserByIdOrUsername,
|
||||
listMediaForStatus,
|
||||
listProfileFields
|
||||
} from "./db";
|
||||
import { html } from "./http";
|
||||
import type { Media, Status, User } from "./types";
|
||||
import { actorUrl, baseUrl, escapeHtml, hostFromBaseUrl, mediaUrl, objectUrl, profileUrl } from "./util";
|
||||
|
||||
type ProfileStatus = {
|
||||
status: Status;
|
||||
media: Media[];
|
||||
};
|
||||
|
||||
export async function profilePage(env: Env, key: string): Promise<Response> {
|
||||
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<Status>()
|
||||
]);
|
||||
|
||||
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 `<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>${escapeHtml(displayName)} (${escapeHtml(handle)})</title>
|
||||
<meta name="description" content="${escapeHtml(description || handle)}">
|
||||
<meta property="og:type" content="profile">
|
||||
<meta property="og:title" content="${escapeHtml(displayName)}">
|
||||
<meta property="og:description" content="${escapeHtml(description || handle)}">
|
||||
<meta property="og:image" content="${escapeHtml(avatar)}">
|
||||
<link rel="canonical" href="${escapeHtml(pageUrl)}">
|
||||
<link rel="alternate" type="application/activity+json" href="${escapeHtml(actor)}">
|
||||
<style>${profileCss()}</style>
|
||||
</head>
|
||||
<body>
|
||||
<main class="shell">
|
||||
<section class="profile" aria-labelledby="profile-name">
|
||||
<div class="cover"><img src="${escapeHtml(header)}" alt=""></div>
|
||||
<div class="identity">
|
||||
<img class="avatar" src="${escapeHtml(avatar)}" alt="">
|
||||
<div class="name-block">
|
||||
<h1 id="profile-name">${escapeHtml(displayName)}</h1>
|
||||
<p class="handle">${escapeHtml(handle)}</p>
|
||||
</div>
|
||||
</div>
|
||||
${renderNote(user.note)}
|
||||
${renderFields(data.fields)}
|
||||
<dl class="stats" aria-label="Profile stats">
|
||||
${renderStat(data.statusesCount, "Posts")}
|
||||
${renderStat(data.followingCount, "Following")}
|
||||
${renderStat(data.followersCount, "Followers")}
|
||||
</dl>
|
||||
</section>
|
||||
${renderStatuses(env, data.statuses)}
|
||||
</main>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
function renderNotFoundPage(env: Env, key: string): string {
|
||||
const title = "Profile not found";
|
||||
return `<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>${title}</title>
|
||||
<style>${profileCss()}</style>
|
||||
</head>
|
||||
<body>
|
||||
<main class="shell">
|
||||
<section class="profile not-found">
|
||||
<h1>${title}</h1>
|
||||
<p class="handle">${escapeHtml(key)} on ${escapeHtml(hostFromBaseUrl(env))}</p>
|
||||
</section>
|
||||
</main>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
function renderNote(note: string): string {
|
||||
const text = plainText(note);
|
||||
if (!text) return "";
|
||||
return `<div class="note">${escapeHtml(text).replace(/\n/g, "<br>")}</div>`;
|
||||
}
|
||||
|
||||
function renderFields(fields: { name: string; value: string }[]): string {
|
||||
const items = fields.filter((field) => field.name || field.value);
|
||||
if (items.length === 0) return "";
|
||||
return `<dl class="fields">${items.map((field) => `
|
||||
<div>
|
||||
<dt>${escapeHtml(field.name)}</dt>
|
||||
<dd>${escapeHtml(plainText(field.value))}</dd>
|
||||
</div>`).join("")}
|
||||
</dl>`;
|
||||
}
|
||||
|
||||
function renderStat(value: number, label: string): string {
|
||||
return `<div><dt>${formatCount(value)}</dt><dd>${label}</dd></div>`;
|
||||
}
|
||||
|
||||
function renderStatuses(env: Env, items: ProfileStatus[]): string {
|
||||
if (items.length === 0) {
|
||||
return `<section class="timeline" aria-label="Posts">
|
||||
<p class="empty">No public posts yet.</p>
|
||||
</section>`;
|
||||
}
|
||||
|
||||
return `<section class="timeline" aria-label="Posts">
|
||||
${items.map((item) => renderStatus(env, item)).join("")}
|
||||
</section>`;
|
||||
}
|
||||
|
||||
function renderStatus(env: Env, item: ProfileStatus): string {
|
||||
const { status, media } = item;
|
||||
const statusUrl = status.url || objectUrl(env, status.id);
|
||||
return `<article class="status">
|
||||
<header class="status-meta">
|
||||
<a href="${escapeHtml(statusUrl)}"><time datetime="${escapeHtml(status.created_at)}">${escapeHtml(formatDate(status.created_at))}</time></a>
|
||||
${status.visibility === "unlisted" ? `<span>Unlisted</span>` : ""}
|
||||
</header>
|
||||
${status.summary ? `<p class="summary">${escapeHtml(status.summary)}</p>` : ""}
|
||||
<div class="content">${status.content}</div>
|
||||
${renderMedia(env, media)}
|
||||
</article>`;
|
||||
}
|
||||
|
||||
function renderMedia(env: Env, media: Media[]): string {
|
||||
if (media.length === 0) return "";
|
||||
return `<div class="media-grid">${media.map((item) => {
|
||||
const url = mediaUrl(env, item.r2_key);
|
||||
const alt = item.description ?? "";
|
||||
if (item.mime_type.startsWith("image/")) {
|
||||
return `<a href="${escapeHtml(url)}"><img src="${escapeHtml(url)}" alt="${escapeHtml(alt)}" loading="lazy"></a>`;
|
||||
}
|
||||
return `<a class="attachment" href="${escapeHtml(url)}">${escapeHtml(item.description || item.mime_type)}</a>`;
|
||||
}).join("")}</div>`;
|
||||
}
|
||||
|
||||
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; }
|
||||
}
|
||||
`;
|
||||
}
|
||||
@@ -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}`;
|
||||
}
|
||||
|
||||
+6
-6
@@ -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"]
|
||||
|
||||
Reference in New Issue
Block a user