note edit endpoint

This commit is contained in:
Namekuji 2023-09-02 08:57:10 -04:00
parent ef97630460
commit 697361ca03
No known key found for this signature in database
GPG key ID: 1D62332C07FBA532
5 changed files with 455 additions and 154 deletions

View file

@ -162,6 +162,7 @@ export const scyllaQueries = {
poll: { poll: {
select: `SELECT * FROM poll_vote WHERE "noteId" = ?`, select: `SELECT * FROM poll_vote WHERE "noteId" = ?`,
insert: `INSERT INTO poll_vote ("noteId", "userId", "userHost", "choice", "createdAt") VALUES (?, ?, ?, ?, ?)`, insert: `INSERT INTO poll_vote ("noteId", "userId", "userHost", "choice", "createdAt") VALUES (?, ?, ?, ?, ?)`,
delete: `DELETE FROM poll_vote WHERE "noteId" = ?`
}, },
notification: { notification: {
insert: `INSERT INTO notification insert: `INSERT INTO notification

View file

@ -109,7 +109,7 @@ export interface ScyllaDriveFile {
comment: string | null; comment: string | null;
blurhash: string | null; blurhash: string | null;
url: string; url: string;
thumbnailUrl: string; thumbnailUrl: string | null;
isSensitive: boolean; isSensitive: boolean;
isLink: boolean; isLink: boolean;
md5: string; md5: string;
@ -140,8 +140,8 @@ export function getScyllaDrivePublicUrl(
} }
export interface ScyllaNoteEditHistory { export interface ScyllaNoteEditHistory {
content: string; content: string | null;
cw: string; cw: string | null;
files: ScyllaDriveFile[]; files: ScyllaDriveFile[];
updatedAt: Date; updatedAt: Date;
} }

View file

@ -27,7 +27,7 @@ export default async (
}); });
if (isActor(object)) { if (isActor(object)) {
await updatePerson(actor.uri!, resolver, object); await updatePerson(actor.uri as string, resolver, object);
return "ok: Person updated"; return "ok: Person updated";
} }
@ -37,12 +37,13 @@ export default async (
case "Note": case "Note":
case "Article": case "Article":
case "Document": case "Document":
case "Page": case "Page": {
let failed = false; let failed = false;
await updateNote(object, resolver).catch((e: Error) => { await updateNote(object, resolver).catch((e: Error) => {
failed = true; failed = true;
}); });
return failed ? "skip: Note update failed" : "ok: Note updated"; return failed ? "skip: Note update failed" : "ok: Note updated";
}
default: default:
return `skip: Unknown type: ${objectType}`; return `skip: Unknown type: ${objectType}`;

View file

@ -53,7 +53,17 @@ import { DB_MAX_IMAGE_COMMENT_LENGTH } from "@/misc/hard-limits.js";
import { truncate } from "@/misc/truncate.js"; import { truncate } from "@/misc/truncate.js";
import { type Size, getEmojiSize } from "@/misc/emoji-meta.js"; import { type Size, getEmojiSize } from "@/misc/emoji-meta.js";
import { fetchMeta } from "@/misc/fetch-meta.js"; import { fetchMeta } from "@/misc/fetch-meta.js";
import { parseScyllaNote, prepared, scyllaClient } from "@/db/scylla.js"; import {
type ScyllaNote,
type ScyllaPoll,
type ScyllaDriveFile,
type ScyllaNoteEditHistory,
parseScyllaNote,
prepared,
scyllaClient,
parseHomeTimeline,
} from "@/db/scylla.js";
import type { Client } from "cassandra-driver";
const logger = apLogger; const logger = apLogger;
@ -573,20 +583,33 @@ export async function updateNote(value: string | IObject, resolver?: Resolver) {
if (uri.startsWith(`${config.url}/`)) throw new Error("uri points local"); if (uri.startsWith(`${config.url}/`)) throw new Error("uri points local");
// A new resolver is created if not specified // A new resolver is created if not specified
if (resolver == null) resolver = new Resolver(); let _resolver = resolver;
if (!_resolver) _resolver = new Resolver();
// Resolve the updated Note object // Resolve the updated Note object
const post = (await resolver.resolve(value)) as IPost; const post = (await _resolver.resolve(value)) as IPost;
const actor = (await resolvePerson( const actor = (await resolvePerson(
getOneApId(post.attributedTo), getOneApId(post.attributedTo),
resolver, _resolver,
)) as CacheableRemoteUser; )) as CacheableRemoteUser;
// Already registered with this server? // Already registered with this server?
const note = await Notes.findOneBy({ uri }); let note: Note | ScyllaNote | null = null;
if (note == null) { if (scyllaClient) {
return await createNote(post, resolver); const result = await scyllaClient.execute(
prepared.note.select.byUri,
[uri],
{ prepare: true },
);
if (result.rowLength > 0) {
note = parseScyllaNote(result.first());
}
} else {
note = await Notes.findOneBy({ uri });
}
if (!note) {
return await createNote(post, _resolver);
} }
// Whether to tell clients the note has been updated and requires refresh. // Whether to tell clients the note has been updated and requires refresh.
@ -613,7 +636,9 @@ export async function updateNote(value: string | IObject, resolver?: Resolver) {
? post.attachment ? post.attachment
: [post.attachment] : [post.attachment]
: []; : [];
const files = fileList.map((f) => (f.sensitive = post.sensitive)); const files = fileList.map((f) => {
f.sensitive = post.sensitive;
});
// Fetch files // Fetch files
const limit = promiseLimit(2); const limit = promiseLimit(2);
@ -654,9 +679,9 @@ export async function updateNote(value: string | IObject, resolver?: Resolver) {
await extractEmojis(post.tag || [], actor.host).catch((e) => []) await extractEmojis(post.tag || [], actor.host).catch((e) => [])
).map((emoji) => emoji.name); ).map((emoji) => emoji.name);
const apMentions = await extractApMentions(post.tag); const apMentions = await extractApMentions(post.tag);
const apHashtags = await extractApHashtags(post.tag); const apHashtags = extractApHashtags(post.tag);
const poll = await extractPollFromQuestion(post, resolver).catch( const poll = await extractPollFromQuestion(post, _resolver).catch(
() => undefined, () => undefined,
); );
@ -719,7 +744,34 @@ export async function updateNote(value: string | IObject, resolver?: Resolver) {
update.hasPoll = !!poll; update.hasPoll = !!poll;
} }
let scyllaPoll: ScyllaPoll | null = null;
let scyllaUpdating = false;
if (poll) { if (poll) {
if (scyllaClient) {
let expiresAt: Date | null;
if (!poll.expiresAt || isNaN(poll.expiresAt.getTime())) {
expiresAt = null;
} else {
expiresAt = poll.expiresAt;
}
scyllaPoll = {
expiresAt,
choices: Object.fromEntries(
poll.choices.map((v, i) => [i, v] as [number, string]),
),
multiple: poll.multiple,
};
scyllaUpdating = true;
publishing = true;
// Delete all votes cast to the target note (i.e., delete whole rows in the partition)
await scyllaClient.execute(prepared.poll.delete, [note.id], {
prepare: true,
});
} else {
const dbPoll = await Polls.findOneBy({ noteId: note.id }); const dbPoll = await Polls.findOneBy({ noteId: note.id });
if (dbPoll == null) { if (dbPoll == null) {
await Polls.insert({ await Polls.insert({
@ -728,11 +780,11 @@ export async function updateNote(value: string | IObject, resolver?: Resolver) {
multiple: poll?.multiple, multiple: poll?.multiple,
votes: poll?.votes, votes: poll?.votes,
expiresAt: poll?.expiresAt, expiresAt: poll?.expiresAt,
noteVisibility: note.visibility === "hidden" ? "home" : note.visibility, noteVisibility:
note.visibility === "hidden" ? "home" : note.visibility,
userId: actor.id, userId: actor.id,
userHost: actor.host, userHost: actor.host,
}); });
updating = true;
} else if ( } else if (
dbPoll.multiple !== poll.multiple || dbPoll.multiple !== poll.multiple ||
dbPoll.expiresAt !== poll.expiresAt || dbPoll.expiresAt !== poll.expiresAt ||
@ -750,7 +802,6 @@ export async function updateNote(value: string | IObject, resolver?: Resolver) {
note.visibility === "hidden" ? "home" : note.visibility, note.visibility === "hidden" ? "home" : note.visibility,
}, },
); );
updating = true;
} else { } else {
for (let i = 0; i < poll.choices.length; i++) { for (let i = 0; i < poll.choices.length; i++) {
if (dbPoll.votes[i] !== poll.votes?.[i]) { if (dbPoll.votes[i] !== poll.votes?.[i]) {
@ -761,11 +812,98 @@ export async function updateNote(value: string | IObject, resolver?: Resolver) {
} }
} }
} }
}
// Update Note // Update Note
if (notEmpty(update)) { if (notEmpty(update) || scyllaUpdating) {
update.updatedAt = new Date(); update.updatedAt = new Date();
if (scyllaClient) {
const client = scyllaClient as Client;
const fileMapper = (file: DriveFile) => ({
...file,
width: file.properties.width ?? null,
height: file.properties.height ?? null,
});
const scyllaFiles: ScyllaDriveFile[] = driveFiles.map(fileMapper);
const scyllaNote = note as ScyllaNote;
const editHistory: ScyllaNoteEditHistory = {
content: scyllaNote.text,
cw: scyllaNote.cw,
files: scyllaNote.files,
updatedAt: update.updatedAt,
};
const newScyllaNote: ScyllaNote = {
...scyllaNote,
...update,
hasPoll: !!scyllaPoll,
poll: scyllaPoll,
files: scyllaFiles,
noteEdit: [...scyllaNote.noteEdit, editHistory],
};
const params = [
newScyllaNote.createdAt,
newScyllaNote.createdAt,
newScyllaNote.id,
scyllaNote.visibility,
newScyllaNote.text,
newScyllaNote.name,
newScyllaNote.cw,
newScyllaNote.localOnly,
newScyllaNote.renoteCount ?? 0,
newScyllaNote.repliesCount ?? 0,
newScyllaNote.uri,
newScyllaNote.url,
newScyllaNote.score ?? 0,
newScyllaNote.files,
newScyllaNote.visibleUserIds,
newScyllaNote.mentions,
newScyllaNote.mentionedRemoteUsers,
newScyllaNote.emojis,
newScyllaNote.tags,
newScyllaNote.hasPoll,
newScyllaNote.poll,
newScyllaNote.threadId,
newScyllaNote.channelId,
newScyllaNote.userId,
newScyllaNote.userHost ?? "local",
newScyllaNote.replyId,
newScyllaNote.replyUserId,
newScyllaNote.replyUserHost,
newScyllaNote.replyText,
newScyllaNote.replyCw,
newScyllaNote.replyFiles,
newScyllaNote.renoteId,
newScyllaNote.renoteUserId,
newScyllaNote.renoteUserHost,
newScyllaNote.renoteText,
newScyllaNote.renoteCw,
newScyllaNote.renoteFiles,
newScyllaNote.reactions,
newScyllaNote.noteEdit,
newScyllaNote.updatedAt,
];
// To let ScyllaDB do upsert, do NOT change the visibility.
await client.execute(prepared.note.insert, params, { prepare: true });
// Update home timelines
client.eachRow(
prepared.homeTimeline.select.byId,
[scyllaNote.id],
{ prepare: true },
(_, row) => {
const timeline = parseHomeTimeline(row);
client.execute(
prepared.homeTimeline.insert,
[timeline.feedUserId, ...params],
{ prepare: true },
);
},
);
} else {
// Save updated note to the database // Save updated note to the database
await Notes.update({ uri }, update); await Notes.update({ uri }, update);
@ -778,6 +916,7 @@ export async function updateNote(value: string | IObject, resolver?: Resolver) {
fileIds: note.fileIds, fileIds: note.fileIds,
updatedAt: update.updatedAt, updatedAt: update.updatedAt,
}); });
}
publishing = true; publishing = true;
} }
@ -788,4 +927,6 @@ export async function updateNote(value: string | IObject, resolver?: Resolver) {
updatedAt: update.updatedAt, updatedAt: update.updatedAt,
}); });
} }
return { ...note, ...update };
} }

View file

@ -35,6 +35,17 @@ import renderUpdate from "@/remote/activitypub/renderer/update.js";
import { deliverToRelays } from "@/services/relay.js"; import { deliverToRelays } from "@/services/relay.js";
// import { deliverQuestionUpdate } from "@/services/note/polls/update.js"; // import { deliverQuestionUpdate } from "@/services/note/polls/update.js";
import { fetchMeta } from "@/misc/fetch-meta.js"; import { fetchMeta } from "@/misc/fetch-meta.js";
import {
type ScyllaNote,
type ScyllaPoll,
type ScyllaDriveFile,
type ScyllaNoteEditHistory,
scyllaClient,
prepared,
parseScyllaNote,
parseHomeTimeline,
} from "@/db/scylla.js";
import type { Client } from "cassandra-driver";
export const meta = { export const meta = {
tags: ["notes"], tags: ["notes"],
@ -248,7 +259,7 @@ export const paramDef = {
} as const; } as const;
export default define(meta, paramDef, async (ps, user) => { export default define(meta, paramDef, async (ps, user) => {
if (user.movedToUri != null) throw new ApiError(meta.errors.accountLocked); if (user.movedToUri) throw new ApiError(meta.errors.accountLocked);
if (!Users.isLocalUser(user)) { if (!Users.isLocalUser(user)) {
throw new ApiError(meta.errors.notLocalUser); throw new ApiError(meta.errors.notLocalUser);
@ -259,11 +270,23 @@ export default define(meta, paramDef, async (ps, user) => {
} }
let publishing = false; let publishing = false;
let note = await Notes.findOneBy({ let note: Note | ScyllaNote | null = null;
if (scyllaClient) {
const result = await scyllaClient.execute(
prepared.note.select.byId,
[ps.editId],
{ prepare: true },
);
if (result.rowLength > 0) {
note = parseScyllaNote(result.first());
}
} else {
note = await Notes.findOneBy({
id: ps.editId, id: ps.editId,
}); });
}
if (note == null) { if (!note) {
throw new ApiError(meta.errors.noSuchNote); throw new ApiError(meta.errors.noSuchNote);
} }
@ -272,7 +295,7 @@ export default define(meta, paramDef, async (ps, user) => {
} }
let renote: Note | null = null; let renote: Note | null = null;
if (ps.renoteId != null) { if (ps.renoteId) {
// Fetch renote to note // Fetch renote to note
renote = await getNote(ps.renoteId, user).catch((e) => { renote = await getNote(ps.renoteId, user).catch((e) => {
if (e.id === "9725d0ce-ba28-4dde-95a7-2cbb2c15de24") if (e.id === "9725d0ce-ba28-4dde-95a7-2cbb2c15de24")
@ -301,7 +324,7 @@ export default define(meta, paramDef, async (ps, user) => {
} }
let reply: Note | null = null; let reply: Note | null = null;
if (ps.replyId != null) { if (ps.replyId) {
// Fetch reply // Fetch reply
reply = await getNote(ps.replyId, user).catch((e) => { reply = await getNote(ps.replyId, user).catch((e) => {
if (e.id === "9725d0ce-ba28-4dde-95a7-2cbb2c15de24") if (e.id === "9725d0ce-ba28-4dde-95a7-2cbb2c15de24")
@ -326,10 +349,10 @@ export default define(meta, paramDef, async (ps, user) => {
} }
let channel: Channel | null = null; let channel: Channel | null = null;
if (ps.channelId != null) { if (ps.channelId) {
channel = await Channels.findOneBy({ id: ps.channelId }); channel = await Channels.findOneBy({ id: ps.channelId });
if (channel == null) { if (!channel) {
throw new ApiError(meta.errors.noSuchChannel); throw new ApiError(meta.errors.noSuchChannel);
} }
} }
@ -443,6 +466,9 @@ export default define(meta, paramDef, async (ps, user) => {
.getMany(); .getMany();
} }
let scyllaPoll: ScyllaPoll | null = null;
let scyllaUpdating = false;
if (ps.poll) { if (ps.poll) {
let expires = ps.poll.expiresAt; let expires = ps.poll.expiresAt;
if (typeof expires === "number") { if (typeof expires === "number") {
@ -453,6 +479,32 @@ export default define(meta, paramDef, async (ps, user) => {
expires = Date.now() + ps.poll.expiredAfter; expires = Date.now() + ps.poll.expiredAfter;
} }
if (scyllaClient) {
let expiresAt: Date | null;
if (!expires || isNaN(expires)) {
expiresAt = null;
} else {
expiresAt = new Date(expires);
}
scyllaPoll = {
expiresAt,
choices: Object.fromEntries(
ps.poll.choices.map((v, i) => [i, v] as [number, string]),
),
multiple: ps.poll.multiple,
};
publishing = true;
scyllaUpdating = true;
// FIXME: Keep votes for unmodified choices, reset votes if choice is modified or new
// Delete all votes cast to the target note (i.e., delete whole rows in the partition)
await scyllaClient.execute(prepared.poll.delete, [note.id], {
prepare: true,
});
} else {
let poll = await Polls.findOneBy({ noteId: note.id }); let poll = await Polls.findOneBy({ noteId: note.id });
const pp = ps.poll; const pp = ps.poll;
if (!poll && pp) { if (!poll && pp) {
@ -494,12 +546,13 @@ export default define(meta, paramDef, async (ps, user) => {
pollUpdate.votes = newVotes; pollUpdate.votes = newVotes;
if (notEmpty(pollUpdate)) { if (notEmpty(pollUpdate)) {
await Polls.update(note.id, pollUpdate); await Polls.update(note.id, pollUpdate);
// Seemingly already handled by by the rendered update activity // Seemingly already handled by the rendered update activity
// await deliverQuestionUpdate(note.id); // await deliverQuestionUpdate(note.id);
} }
publishing = true; publishing = true;
} }
} }
}
const mentionedUserLookup: Record<string, User> = {}; const mentionedUserLookup: Record<string, User> = {};
mentionedUsers.forEach((u) => { mentionedUsers.forEach((u) => {
@ -542,6 +595,7 @@ export default define(meta, paramDef, async (ps, user) => {
// update.visibility = ps.visibility; // update.visibility = ps.visibility;
throw new ApiError(meta.errors.cannotChangeVisibility); throw new ApiError(meta.errors.cannotChangeVisibility);
} }
update.localOnly = note.localOnly;
if (ps.localOnly !== note.localOnly) { if (ps.localOnly !== note.localOnly) {
update.localOnly = ps.localOnly; update.localOnly = ps.localOnly;
} }
@ -586,8 +640,95 @@ export default define(meta, paramDef, async (ps, user) => {
} }
} }
if (notEmpty(update)) { if (notEmpty(update) || scyllaUpdating) {
update.updatedAt = new Date(); update.updatedAt = new Date();
if (scyllaClient) {
const client = scyllaClient as Client;
const fileMapper = (file: DriveFile) => ({
...file,
width: file.properties.width ?? null,
height: file.properties.height ?? null,
});
const scyllaFiles: ScyllaDriveFile[] = files.map(fileMapper);
const scyllaNote = note as ScyllaNote;
const editHistory: ScyllaNoteEditHistory = {
content: scyllaNote.text,
cw: scyllaNote.cw,
files: scyllaNote.files,
updatedAt: update.updatedAt,
};
const newScyllaNote: ScyllaNote = {
...scyllaNote,
...update,
hasPoll: !!scyllaPoll,
poll: scyllaPoll,
files: scyllaFiles,
noteEdit: [...scyllaNote.noteEdit, editHistory],
};
const params = [
newScyllaNote.createdAt,
newScyllaNote.createdAt,
newScyllaNote.id,
scyllaNote.visibility,
newScyllaNote.text,
newScyllaNote.name,
newScyllaNote.cw,
newScyllaNote.localOnly,
newScyllaNote.renoteCount ?? 0,
newScyllaNote.repliesCount ?? 0,
newScyllaNote.uri,
newScyllaNote.url,
newScyllaNote.score ?? 0,
newScyllaNote.files,
newScyllaNote.visibleUserIds,
newScyllaNote.mentions,
newScyllaNote.mentionedRemoteUsers,
newScyllaNote.emojis,
newScyllaNote.tags,
newScyllaNote.hasPoll,
newScyllaNote.poll,
newScyllaNote.threadId,
newScyllaNote.channelId,
newScyllaNote.userId,
newScyllaNote.userHost ?? "local",
newScyllaNote.replyId,
newScyllaNote.replyUserId,
newScyllaNote.replyUserHost,
newScyllaNote.replyText,
newScyllaNote.replyCw,
newScyllaNote.replyFiles,
newScyllaNote.renoteId,
newScyllaNote.renoteUserId,
newScyllaNote.renoteUserHost,
newScyllaNote.renoteText,
newScyllaNote.renoteCw,
newScyllaNote.renoteFiles,
newScyllaNote.reactions,
newScyllaNote.noteEdit,
newScyllaNote.updatedAt,
];
// To let ScyllaDB do upsert, do NOT change the visibility.
await client.execute(prepared.note.insert, params, { prepare: true });
// Update home timelines
client.eachRow(
prepared.homeTimeline.select.byId,
[scyllaNote.id],
{ prepare: true },
(_, row) => {
const timeline = parseHomeTimeline(row);
client.execute(
prepared.homeTimeline.insert,
[timeline.feedUserId, ...params],
{ prepare: true },
);
},
);
} else {
await Notes.update(note.id, update); await Notes.update(note.id, update);
// Add NoteEdit history // Add NoteEdit history
@ -599,11 +740,26 @@ export default define(meta, paramDef, async (ps, user) => {
fileIds: ps.fileIds, fileIds: ps.fileIds,
updatedAt: new Date(), updatedAt: new Date(),
}); });
}
publishing = true; publishing = true;
} }
note = await Notes.findOneBy({ id: note.id }); if (scyllaClient) {
const result = await scyllaClient.execute(
prepared.note.select.byId,
[note.id],
{ prepare: true },
);
if (result.rowLength > 0) {
note = parseScyllaNote(result.first());
}
} else {
note = await Notes.findOneBy({
id: note.id,
});
}
if (!note) { if (!note) {
throw new ApiError(meta.errors.noSuchNote); throw new ApiError(meta.errors.noSuchNote);
} }
@ -616,6 +772,7 @@ export default define(meta, paramDef, async (ps, user) => {
updatedAt: update.updatedAt, updatedAt: update.updatedAt,
}); });
if (!update.localOnly) {
(async () => { (async () => {
const noteActivity = await renderNote(note, false); const noteActivity = await renderNote(note, false);
noteActivity.updated = note.updatedAt.toISOString(); noteActivity.updated = note.updatedAt.toISOString();
@ -656,6 +813,7 @@ export default define(meta, paramDef, async (ps, user) => {
dm.execute(); dm.execute();
})(); })();
} }
}
return { return {
createdNote: await Notes.pack(note, user), createdNote: await Notes.pack(note, user),