fix 编辑嘟文
This commit is contained in:
@@ -0,0 +1,4 @@
|
|||||||
|
-- Store editable source text and edit timestamps for local statuses.
|
||||||
|
|
||||||
|
ALTER TABLE statuses ADD COLUMN source_text TEXT NOT NULL DEFAULT '';
|
||||||
|
ALTER TABLE statuses ADD COLUMN edited_at TEXT;
|
||||||
@@ -94,7 +94,7 @@ npm run deploy
|
|||||||
嘟文:
|
嘟文:
|
||||||
|
|
||||||
- `POST /api/v1/statuses`(支持 `media_ids`、`spoiler_text`、`sensitive`、`in_reply_to_id`、`visibility`、`language`、`poll[...]`、`scheduled_at`,自动解析 `@user`/`@user@host` 提及和 `#hashtag`,投递 Create 给 followers 与 mention)
|
- `POST /api/v1/statuses`(支持 `media_ids`、`spoiler_text`、`sensitive`、`in_reply_to_id`、`visibility`、`language`、`poll[...]`、`scheduled_at`,自动解析 `@user`/`@user@host` 提及和 `#hashtag`,投递 Create 给 followers 与 mention)
|
||||||
- `GET /api/v1/statuses/:id`、`DELETE /api/v1/statuses/:id`(联邦 Delete 出站)
|
- `GET /api/v1/statuses/:id`、`GET /api/v1/statuses/:id/source`、`PUT / PATCH /api/v1/statuses/:id`(联邦 Update Note 出站)、`DELETE /api/v1/statuses/:id`(联邦 Delete 出站)
|
||||||
- `GET /api/v1/statuses/:id/context`
|
- `GET /api/v1/statuses/:id/context`
|
||||||
- `POST /api/v1/statuses/:id/favourite`、`/unfavourite`(联邦 Like / Undo Like)
|
- `POST /api/v1/statuses/:id/favourite`、`/unfavourite`(联邦 Like / Undo Like)
|
||||||
- `POST /api/v1/statuses/:id/reblog`、`/unreblog`(联邦 Announce / Undo Announce)
|
- `POST /api/v1/statuses/:id/reblog`、`/unreblog`(联邦 Announce / Undo Announce)
|
||||||
@@ -154,6 +154,7 @@ npm run deploy
|
|||||||
- `migrations/0007_polls_lists_push_scheduled.sql` — poll / list / push subscription / scheduled statuses
|
- `migrations/0007_polls_lists_push_scheduled.sql` — poll / list / push subscription / scheduled statuses
|
||||||
- `migrations/0008_outgoing_deliveries.sql` — 出站 ActivityPub 投递队列 / 重试状态
|
- `migrations/0008_outgoing_deliveries.sql` — 出站 ActivityPub 投递队列 / 重试状态
|
||||||
- `migrations/0009_markers.sql` — Mastodon 读位 markers(home / notifications)
|
- `migrations/0009_markers.sql` — Mastodon 读位 markers(home / notifications)
|
||||||
|
- `migrations/0010_status_edits.sql` — 本地嘟文原文 source_text 与 edited_at,支持客户端编辑
|
||||||
|
|
||||||
## 重要限制
|
## 重要限制
|
||||||
|
|
||||||
|
|||||||
@@ -624,6 +624,26 @@ export async function createActivity(env: Env, user: User, status: Status, extra
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function updateNoteActivity(env: Env, user: User, status: Status, extra: { to?: string[]; cc?: string[] } = {}): Promise<Json> {
|
||||||
|
const audience = statusAudience(env, user, status);
|
||||||
|
const to = extra.to ?? audience.to;
|
||||||
|
const cc = extra.cc ?? audience.cc;
|
||||||
|
const [attachments, tag] = await Promise.all([
|
||||||
|
loadStatusAttachments(env, status.id),
|
||||||
|
loadStatusTags(env, status.id)
|
||||||
|
]);
|
||||||
|
return {
|
||||||
|
"@context": [ACTIVITY_CONTEXT, SECURITY_CONTEXT],
|
||||||
|
id: activityUrl(env, id()),
|
||||||
|
type: "Update",
|
||||||
|
actor: actorUrl(env, user),
|
||||||
|
published: status.edited_at ?? new Date().toISOString(),
|
||||||
|
to,
|
||||||
|
cc,
|
||||||
|
object: noteObject(env, user, status, { to, cc, attachments, tag })
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export function attachmentObject(env: Env, media: Media): Json {
|
export function attachmentObject(env: Env, media: Media): Json {
|
||||||
return {
|
return {
|
||||||
type: media.mime_type.startsWith("image/") ? "Image" : "Document",
|
type: media.mime_type.startsWith("image/") ? "Image" : "Document",
|
||||||
@@ -747,6 +767,7 @@ export function noteObject(env: Env, user: User, status: Status, opts: { to?: st
|
|||||||
attributedTo: actorUrl(env, user),
|
attributedTo: actorUrl(env, user),
|
||||||
content: status.content,
|
content: status.content,
|
||||||
published: status.created_at,
|
published: status.created_at,
|
||||||
|
updated: status.edited_at ?? null,
|
||||||
url: statusUrl(env, user, status.id),
|
url: statusUrl(env, user, status.id),
|
||||||
to: opts.to ?? audience.to,
|
to: opts.to ?? audience.to,
|
||||||
cc: opts.cc ?? audience.cc,
|
cc: opts.cc ?? audience.cc,
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ import {
|
|||||||
getPushSubscription,
|
getPushSubscription,
|
||||||
getRelationships,
|
getRelationships,
|
||||||
getScheduledStatus,
|
getScheduledStatus,
|
||||||
|
getStatusSource,
|
||||||
getStatusEndpoint,
|
getStatusEndpoint,
|
||||||
hashtagInfo,
|
hashtagInfo,
|
||||||
hashtagTimeline,
|
hashtagTimeline,
|
||||||
@@ -72,6 +73,7 @@ import {
|
|||||||
token,
|
token,
|
||||||
trendsTags,
|
trendsTags,
|
||||||
updateMarkers,
|
updateMarkers,
|
||||||
|
updateStatusEndpoint,
|
||||||
unbookmarkStatus,
|
unbookmarkStatus,
|
||||||
unfavouriteStatus,
|
unfavouriteStatus,
|
||||||
unfollowAccount,
|
unfollowAccount,
|
||||||
@@ -159,7 +161,9 @@ async function route(request: Request, env: Env): Promise<Response> {
|
|||||||
if (method === "POST" && (m = path.match(/^\/api\/v1\/follow_requests\/([^/]+)\/reject$/))) return rejectFollowRequest(request, env, decodeURIComponent(m[1]));
|
if (method === "POST" && (m = path.match(/^\/api\/v1\/follow_requests\/([^/]+)\/reject$/))) return rejectFollowRequest(request, env, decodeURIComponent(m[1]));
|
||||||
|
|
||||||
if (method === "POST" && path === "/api/v1/statuses") return createStatus(request, env);
|
if (method === "POST" && path === "/api/v1/statuses") return createStatus(request, env);
|
||||||
|
if (method === "GET" && (m = path.match(/^\/api\/v1\/statuses\/([^/]+)\/source$/))) return getStatusSource(request, env, decodeURIComponent(m[1]));
|
||||||
if (method === "GET" && (m = path.match(/^\/api\/v1\/statuses\/([^/]+)$/))) return getStatusEndpoint(request, env, decodeURIComponent(m[1]));
|
if (method === "GET" && (m = path.match(/^\/api\/v1\/statuses\/([^/]+)$/))) return getStatusEndpoint(request, env, decodeURIComponent(m[1]));
|
||||||
|
if ((method === "PUT" || method === "PATCH") && (m = path.match(/^\/api\/v1\/statuses\/([^/]+)$/))) return updateStatusEndpoint(request, env, decodeURIComponent(m[1]));
|
||||||
if (method === "DELETE" && (m = path.match(/^\/api\/v1\/statuses\/([^/]+)$/))) return deleteStatusEndpoint(request, env, decodeURIComponent(m[1]));
|
if (method === "DELETE" && (m = path.match(/^\/api\/v1\/statuses\/([^/]+)$/))) return deleteStatusEndpoint(request, env, decodeURIComponent(m[1]));
|
||||||
if (method === "GET" && (m = path.match(/^\/api\/v1\/statuses\/([^/]+)\/context$/))) return statusContext(env, decodeURIComponent(m[1]), request);
|
if (method === "GET" && (m = path.match(/^\/api\/v1\/statuses\/([^/]+)\/context$/))) return statusContext(env, decodeURIComponent(m[1]), request);
|
||||||
if (method === "POST" && (m = path.match(/^\/api\/v1\/statuses\/([^/]+)\/favourite$/))) return favouriteStatus(request, env, decodeURIComponent(m[1]));
|
if (method === "POST" && (m = path.match(/^\/api\/v1\/statuses\/([^/]+)\/favourite$/))) return favouriteStatus(request, env, decodeURIComponent(m[1]));
|
||||||
|
|||||||
+159
-3
@@ -7,6 +7,7 @@ import {
|
|||||||
followActivity,
|
followActivity,
|
||||||
likeActivity,
|
likeActivity,
|
||||||
undoActivity,
|
undoActivity,
|
||||||
|
updateNoteActivity,
|
||||||
updatePersonActivity
|
updatePersonActivity
|
||||||
} from "./activitypub";
|
} from "./activitypub";
|
||||||
import { hashPassword, verifyPassword } from "./crypto";
|
import { hashPassword, verifyPassword } from "./crypto";
|
||||||
@@ -136,6 +137,15 @@ type StatusCreateInput = {
|
|||||||
pollHideTotals: boolean;
|
pollHideTotals: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type StatusEditInput = {
|
||||||
|
statusText: string;
|
||||||
|
summary: string;
|
||||||
|
sensitive: boolean;
|
||||||
|
visibility: StatusVisibility;
|
||||||
|
language: string;
|
||||||
|
mediaIds: string[] | null;
|
||||||
|
};
|
||||||
|
|
||||||
function parseRedirectUris(value: string): string[] {
|
function parseRedirectUris(value: string): string[] {
|
||||||
return value.split(/\s+/).map((item) => item.trim()).filter(Boolean);
|
return value.split(/\s+/).map((item) => item.trim()).filter(Boolean);
|
||||||
}
|
}
|
||||||
@@ -613,7 +623,7 @@ async function publishStatus(env: Env, user: User, input: StatusCreateInput): Pr
|
|||||||
const renderedContent = htmlContent(input.statusText, resolvedMentions.map(({ acct, url }) => ({ acct, url })), hashtags);
|
const renderedContent = htmlContent(input.statusText, resolvedMentions.map(({ acct, url }) => ({ acct, url })), hashtags);
|
||||||
|
|
||||||
await env.DB.prepare(
|
await env.DB.prepare(
|
||||||
"INSERT INTO statuses (id, user_id, content, summary, sensitive, language, visibility, in_reply_to_id, activity_id, object_id, created_at, url) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"
|
"INSERT INTO statuses (id, user_id, content, summary, sensitive, language, visibility, in_reply_to_id, activity_id, object_id, created_at, url, source_text, edited_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL)"
|
||||||
)
|
)
|
||||||
.bind(
|
.bind(
|
||||||
statusId,
|
statusId,
|
||||||
@@ -627,7 +637,8 @@ async function publishStatus(env: Env, user: User, input: StatusCreateInput): Pr
|
|||||||
activityId,
|
activityId,
|
||||||
objectId,
|
objectId,
|
||||||
now,
|
now,
|
||||||
statusUrl(env, user, statusId)
|
statusUrl(env, user, statusId),
|
||||||
|
input.statusText
|
||||||
)
|
)
|
||||||
.run();
|
.run();
|
||||||
|
|
||||||
@@ -745,6 +756,29 @@ function parseStatusCreateInput(body: ParsedBody): StatusCreateInput {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function parseStatusEditInput(body: ParsedBody, existing: Status): StatusEditInput {
|
||||||
|
const existingText = statusSourceText(existing);
|
||||||
|
const statusText = Object.prototype.hasOwnProperty.call(body, "status")
|
||||||
|
? bodyString(body, "status").trim()
|
||||||
|
: existingText;
|
||||||
|
if (!statusText) throw new HttpError(422, "status can't be blank");
|
||||||
|
if (statusText.length > MAX_STATUS_CHARS) throw new HttpError(422, "status too long");
|
||||||
|
|
||||||
|
const visibility = bodyString(body, "visibility", existing.visibility);
|
||||||
|
if (!isStatusVisibility(visibility)) throw new HttpError(422, "invalid_visibility");
|
||||||
|
|
||||||
|
return {
|
||||||
|
statusText,
|
||||||
|
summary: bodyString(body, "spoiler_text", existing.summary),
|
||||||
|
sensitive: Object.prototype.hasOwnProperty.call(body, "sensitive")
|
||||||
|
? bodyString(body, "sensitive") === "true"
|
||||||
|
: Boolean(existing.sensitive),
|
||||||
|
visibility,
|
||||||
|
language: bodyString(body, "language", existing.language || "en"),
|
||||||
|
mediaIds: Object.prototype.hasOwnProperty.call(body, "media_ids") ? bodyArray(body, "media_ids") : null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function parsePollExpiresIn(value: string): number {
|
function parsePollExpiresIn(value: string): number {
|
||||||
const seconds = Number(value);
|
const seconds = Number(value);
|
||||||
if (!Number.isFinite(seconds)) throw new HttpError(422, "invalid_poll_expiration");
|
if (!Number.isFinite(seconds)) throw new HttpError(422, "invalid_poll_expiration");
|
||||||
@@ -895,6 +929,106 @@ export async function getStatusEndpoint(request: Request, env: Env, statusId: st
|
|||||||
return json(await statusJson(env, status, user, request));
|
return json(await statusJson(env, status, user, request));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getStatusSource(request: Request, env: Env, statusId: string): Promise<Response> {
|
||||||
|
const user = await requireUser(request, env);
|
||||||
|
const status = await getStatus(env, statusId);
|
||||||
|
if (!status || status.user_id !== user.id) return json({ error: "Record not found" }, 404);
|
||||||
|
return json({
|
||||||
|
id: status.id,
|
||||||
|
text: statusSourceText(status),
|
||||||
|
spoiler_text: status.summary
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateStatusEndpoint(request: Request, env: Env, statusId: string): Promise<Response> {
|
||||||
|
const user = await requireUser(request, env);
|
||||||
|
const status = await getStatus(env, statusId);
|
||||||
|
if (!status || status.user_id !== user.id) return json({ error: "Record not found" }, 404);
|
||||||
|
|
||||||
|
const body = await readBody(request);
|
||||||
|
const input = parseStatusEditInput(body, status);
|
||||||
|
const previousMentions = await listMentionsForStatus(env, status.id);
|
||||||
|
const mentionsAcct = extractMentions(input.statusText);
|
||||||
|
const hashtags = extractHashtags(input.statusText);
|
||||||
|
|
||||||
|
const resolvedMentions: { acct: string; actorId: string; url: string }[] = [];
|
||||||
|
for (const acct of mentionsAcct) {
|
||||||
|
const resolved = await resolveAcct(env, acct);
|
||||||
|
if (resolved) resolvedMentions.push(resolved);
|
||||||
|
}
|
||||||
|
const renderedContent = htmlContent(input.statusText, resolvedMentions.map(({ acct, url }) => ({ acct, url })), hashtags);
|
||||||
|
const editedAt = new Date().toISOString();
|
||||||
|
|
||||||
|
await env.DB.prepare(
|
||||||
|
`UPDATE statuses
|
||||||
|
SET content = ?, summary = ?, sensitive = ?, language = ?, visibility = ?, url = ?, source_text = ?, edited_at = ?
|
||||||
|
WHERE id = ? AND user_id = ?`
|
||||||
|
).bind(
|
||||||
|
renderedContent,
|
||||||
|
input.summary,
|
||||||
|
input.sensitive ? 1 : 0,
|
||||||
|
input.language,
|
||||||
|
input.visibility,
|
||||||
|
statusUrl(env, user, status.id),
|
||||||
|
input.statusText,
|
||||||
|
editedAt,
|
||||||
|
status.id,
|
||||||
|
user.id
|
||||||
|
).run();
|
||||||
|
|
||||||
|
if (input.mediaIds !== null) {
|
||||||
|
await env.DB.prepare("UPDATE media SET status_id = NULL WHERE status_id = ? AND user_id = ?").bind(status.id, user.id).run();
|
||||||
|
for (const mediaId of input.mediaIds) {
|
||||||
|
await env.DB.prepare("UPDATE media SET status_id = ? WHERE id = ? AND user_id = ?").bind(status.id, mediaId, user.id).run();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await env.DB.prepare("DELETE FROM mentions WHERE status_id = ?").bind(status.id).run();
|
||||||
|
for (const mention of resolvedMentions) {
|
||||||
|
await env.DB.prepare("INSERT OR IGNORE INTO mentions (status_id, actor, acct, url) VALUES (?, ?, ?, ?)")
|
||||||
|
.bind(status.id, mention.actorId, mention.acct, mention.url).run();
|
||||||
|
}
|
||||||
|
await env.DB.prepare("DELETE FROM hashtags WHERE status_id = ?").bind(status.id).run();
|
||||||
|
for (const tag of hashtags) {
|
||||||
|
await env.DB.prepare("INSERT OR IGNORE INTO hashtags (status_id, tag) VALUES (?, ?)").bind(status.id, tag).run();
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await getStatus(env, status.id);
|
||||||
|
if (!updated) throw new HttpError(500, "status_not_found");
|
||||||
|
|
||||||
|
if (updated.visibility === "public" || updated.visibility === "unlisted" || updated.visibility === "private" || updated.visibility === "direct") {
|
||||||
|
const inboxes = new Set<string>();
|
||||||
|
if (updated.visibility !== "direct") {
|
||||||
|
for (const inbox of await gatherFollowerInboxes(env, user.id)) inboxes.add(inbox);
|
||||||
|
}
|
||||||
|
const remoteActors = new Set<string>([
|
||||||
|
...previousMentions.map((mention) => mention.actor),
|
||||||
|
...resolvedMentions.map((mention) => mention.actorId)
|
||||||
|
].filter((actorId) => !actorId.startsWith(baseUrl(env))));
|
||||||
|
for (const actorId of remoteActors) {
|
||||||
|
const cache = await resolveRemoteActor(env, actorId);
|
||||||
|
if (cache) inboxes.add(cache.shared_inbox ?? cache.inbox);
|
||||||
|
}
|
||||||
|
|
||||||
|
const mentionActors = resolvedMentions.map((mention) => mention.actorId);
|
||||||
|
const to = updated.visibility === "public"
|
||||||
|
? ["https://www.w3.org/ns/activitystreams#Public"]
|
||||||
|
: updated.visibility === "unlisted"
|
||||||
|
? [`${actorUrl(env, user)}/followers`]
|
||||||
|
: updated.visibility === "private"
|
||||||
|
? [`${actorUrl(env, user)}/followers`, ...mentionActors]
|
||||||
|
: mentionActors;
|
||||||
|
const cc = updated.visibility === "public"
|
||||||
|
? [`${actorUrl(env, user)}/followers`, ...mentionActors]
|
||||||
|
: updated.visibility === "unlisted"
|
||||||
|
? ["https://www.w3.org/ns/activitystreams#Public", ...mentionActors]
|
||||||
|
: [];
|
||||||
|
await deliverToInboxes(env, user, inboxes, await updateNoteActivity(env, user, updated, { to, cc }));
|
||||||
|
}
|
||||||
|
|
||||||
|
return json(await statusJson(env, updated, user, request));
|
||||||
|
}
|
||||||
|
|
||||||
export async function getPoll(request: Request, env: Env, pollId: string): Promise<Response> {
|
export async function getPoll(request: Request, env: Env, pollId: string): Promise<Response> {
|
||||||
const poll = await env.DB.prepare("SELECT * FROM polls WHERE id = ?").bind(pollId).first<Poll>();
|
const poll = await env.DB.prepare("SELECT * FROM polls WHERE id = ?").bind(pollId).first<Poll>();
|
||||||
if (!poll) return json({ error: "Record not found" }, 404);
|
if (!poll) return json({ error: "Record not found" }, 404);
|
||||||
@@ -1868,6 +2002,28 @@ function parseCachedJson<T>(value: string): T[] {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function statusSourceText(status: Status): string {
|
||||||
|
return status.source_text || htmlToPlainText(status.content);
|
||||||
|
}
|
||||||
|
|
||||||
|
function htmlToPlainText(value: string): string {
|
||||||
|
return decodeHtmlEntities(value
|
||||||
|
.replace(/<br\s*\/?>/gi, "\n")
|
||||||
|
.replace(/<\/p>\s*<p[^>]*>/gi, "\n\n")
|
||||||
|
.replace(/<[^>]*>/g, "")
|
||||||
|
.trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodeHtmlEntities(value: string): string {
|
||||||
|
return value
|
||||||
|
.replace(/ /g, " ")
|
||||||
|
.replace(/&/g, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, "'");
|
||||||
|
}
|
||||||
|
|
||||||
async function statusJson(
|
async function statusJson(
|
||||||
env: Env,
|
env: Env,
|
||||||
status: Status,
|
status: Status,
|
||||||
@@ -1898,7 +2054,7 @@ function statusRecord(env: Env, status: Status, user: User, context: StatusSeria
|
|||||||
content: status.content,
|
content: status.content,
|
||||||
text: status.content,
|
text: status.content,
|
||||||
created_at: status.created_at,
|
created_at: status.created_at,
|
||||||
edited_at: null,
|
edited_at: status.edited_at,
|
||||||
visibility: status.visibility,
|
visibility: status.visibility,
|
||||||
language: status.language,
|
language: status.language,
|
||||||
sensitive: Boolean(status.sensitive),
|
sensitive: Boolean(status.sensitive),
|
||||||
|
|||||||
@@ -46,6 +46,8 @@ export type Status = {
|
|||||||
object_id: string;
|
object_id: string;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
url: string;
|
url: string;
|
||||||
|
source_text: string;
|
||||||
|
edited_at: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type DeletedStatus = {
|
export type DeletedStatus = {
|
||||||
|
|||||||
+5
-5
@@ -17,21 +17,21 @@
|
|||||||
},
|
},
|
||||||
"d1_databases": [
|
"d1_databases": [
|
||||||
{
|
{
|
||||||
"binding": "DB",
|
"binding": "DB", //请勿改动
|
||||||
"database_name": "toot_db",
|
"database_name": "toot_db",
|
||||||
"database_id": "8e042858-bf5f-4d7a-ad84-3e002b0b2f42"
|
"database_id": "8e042858-bf5f-4d7a-ad84-3e002b0b2f42"//改成自己的
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"r2_buckets": [
|
"r2_buckets": [
|
||||||
{
|
{
|
||||||
"binding": "MEDIA",
|
"binding": "MEDIA",//请勿改动
|
||||||
"bucket_name": "toot-media"
|
"bucket_name": "toot-media"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"kv_namespaces": [
|
"kv_namespaces": [
|
||||||
{
|
{
|
||||||
"binding": "KV",
|
"binding": "KV",//请勿改动
|
||||||
"id": "0e14d63f7d624358ab6507ef1bac9017"
|
"id": "0e14d63f7d624358ab6507ef1bac9017"//改成自己的
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user