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: {
select: `SELECT * FROM poll_vote WHERE "noteId" = ?`,
insert: `INSERT INTO poll_vote ("noteId", "userId", "userHost", "choice", "createdAt") VALUES (?, ?, ?, ?, ?)`,
delete: `DELETE FROM poll_vote WHERE "noteId" = ?`
},
notification: {
insert: `INSERT INTO notification

View file

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

View file

@ -27,7 +27,7 @@ export default async (
});
if (isActor(object)) {
await updatePerson(actor.uri!, resolver, object);
await updatePerson(actor.uri as string, resolver, object);
return "ok: Person updated";
}
@ -37,12 +37,13 @@ export default async (
case "Note":
case "Article":
case "Document":
case "Page":
case "Page": {
let failed = false;
await updateNote(object, resolver).catch((e: Error) => {
failed = true;
});
return failed ? "skip: Note update failed" : "ok: Note updated";
}
default:
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 { type Size, getEmojiSize } from "@/misc/emoji-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;
@ -573,20 +583,33 @@ export async function updateNote(value: string | IObject, resolver?: Resolver) {
if (uri.startsWith(`${config.url}/`)) throw new Error("uri points local");
// 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
const post = (await resolver.resolve(value)) as IPost;
const post = (await _resolver.resolve(value)) as IPost;
const actor = (await resolvePerson(
getOneApId(post.attributedTo),
resolver,
_resolver,
)) as CacheableRemoteUser;
// Already registered with this server?
const note = await Notes.findOneBy({ uri });
if (note == null) {
return await createNote(post, resolver);
let note: Note | ScyllaNote | null = null;
if (scyllaClient) {
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.
@ -613,7 +636,9 @@ export async function updateNote(value: string | IObject, resolver?: Resolver) {
? post.attachment
: [post.attachment]
: [];
const files = fileList.map((f) => (f.sensitive = post.sensitive));
const files = fileList.map((f) => {
f.sensitive = post.sensitive;
});
// Fetch files
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) => [])
).map((emoji) => emoji.name);
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,
);
@ -719,7 +744,34 @@ export async function updateNote(value: string | IObject, resolver?: Resolver) {
update.hasPoll = !!poll;
}
let scyllaPoll: ScyllaPoll | null = null;
let scyllaUpdating = false;
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 });
if (dbPoll == null) {
await Polls.insert({
@ -728,11 +780,11 @@ export async function updateNote(value: string | IObject, resolver?: Resolver) {
multiple: poll?.multiple,
votes: poll?.votes,
expiresAt: poll?.expiresAt,
noteVisibility: note.visibility === "hidden" ? "home" : note.visibility,
noteVisibility:
note.visibility === "hidden" ? "home" : note.visibility,
userId: actor.id,
userHost: actor.host,
});
updating = true;
} else if (
dbPoll.multiple !== poll.multiple ||
dbPoll.expiresAt !== poll.expiresAt ||
@ -750,7 +802,6 @@ export async function updateNote(value: string | IObject, resolver?: Resolver) {
note.visibility === "hidden" ? "home" : note.visibility,
},
);
updating = true;
} else {
for (let i = 0; i < poll.choices.length; i++) {
if (dbPoll.votes[i] !== poll.votes?.[i]) {
@ -761,11 +812,98 @@ export async function updateNote(value: string | IObject, resolver?: Resolver) {
}
}
}
}
// Update Note
if (notEmpty(update)) {
if (notEmpty(update) || scyllaUpdating) {
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
await Notes.update({ uri }, update);
@ -778,6 +916,7 @@ export async function updateNote(value: string | IObject, resolver?: Resolver) {
fileIds: note.fileIds,
updatedAt: update.updatedAt,
});
}
publishing = true;
}
@ -788,4 +927,6 @@ export async function updateNote(value: string | IObject, resolver?: Resolver) {
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 { deliverQuestionUpdate } from "@/services/note/polls/update.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 = {
tags: ["notes"],
@ -248,7 +259,7 @@ export const paramDef = {
} as const;
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)) {
throw new ApiError(meta.errors.notLocalUser);
@ -259,11 +270,23 @@ export default define(meta, paramDef, async (ps, user) => {
}
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,
});
}
if (note == null) {
if (!note) {
throw new ApiError(meta.errors.noSuchNote);
}
@ -272,7 +295,7 @@ export default define(meta, paramDef, async (ps, user) => {
}
let renote: Note | null = null;
if (ps.renoteId != null) {
if (ps.renoteId) {
// Fetch renote to note
renote = await getNote(ps.renoteId, user).catch((e) => {
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;
if (ps.replyId != null) {
if (ps.replyId) {
// Fetch reply
reply = await getNote(ps.replyId, user).catch((e) => {
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;
if (ps.channelId != null) {
if (ps.channelId) {
channel = await Channels.findOneBy({ id: ps.channelId });
if (channel == null) {
if (!channel) {
throw new ApiError(meta.errors.noSuchChannel);
}
}
@ -443,6 +466,9 @@ export default define(meta, paramDef, async (ps, user) => {
.getMany();
}
let scyllaPoll: ScyllaPoll | null = null;
let scyllaUpdating = false;
if (ps.poll) {
let expires = ps.poll.expiresAt;
if (typeof expires === "number") {
@ -453,6 +479,32 @@ export default define(meta, paramDef, async (ps, user) => {
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 });
const pp = ps.poll;
if (!poll && pp) {
@ -494,12 +546,13 @@ export default define(meta, paramDef, async (ps, user) => {
pollUpdate.votes = newVotes;
if (notEmpty(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);
}
publishing = true;
}
}
}
const mentionedUserLookup: Record<string, User> = {};
mentionedUsers.forEach((u) => {
@ -542,6 +595,7 @@ export default define(meta, paramDef, async (ps, user) => {
// update.visibility = ps.visibility;
throw new ApiError(meta.errors.cannotChangeVisibility);
}
update.localOnly = note.localOnly;
if (ps.localOnly !== note.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();
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);
// Add NoteEdit history
@ -599,11 +740,26 @@ export default define(meta, paramDef, async (ps, user) => {
fileIds: ps.fileIds,
updatedAt: new Date(),
});
}
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) {
throw new ApiError(meta.errors.noSuchNote);
}
@ -616,6 +772,7 @@ export default define(meta, paramDef, async (ps, user) => {
updatedAt: update.updatedAt,
});
if (!update.localOnly) {
(async () => {
const noteActivity = await renderNote(note, false);
noteActivity.updated = note.updatedAt.toISOString();
@ -656,6 +813,7 @@ export default define(meta, paramDef, async (ps, user) => {
dm.execute();
})();
}
}
return {
createdNote: await Notes.pack(note, user),