Files
gerbeur/api/services/notification-service.ts
khannurien a36a4cac6b v3: fix the backend's correctness findings — each by a rule shared across the code rather than one more special case. Link previews: readTextCapped decoded every page as UTF-8, so a Latin-1 page came out garbled unless it went through the curl fallback, which re-encoded it on its own; pages are now decoded in the charset their Content-Type or <meta> declares, and the curl fallback hands the bytes over as they are. The image proxy served and cached for a day an error answer that claimed an image type, and read a body without Content-Length whole before checking its size; it now refuses an error status and reads through the same capped reader. The icons task's Twemoji fetch gives up after 15 s instead of holding the start up, and extractLinkHref, which only its tests used, is gone. Backlinks matched a linked URL against the raw url column, so https://x.com/ missed a dump posted as https://x.com; they now compare canonical URLs, as reposts do. The four LIKE searches put the query in raw, so % and _ were wildcards and the mention search for "a_b" found "axb"; lib/like.ts escapes them. Every list query now treats a malformed row the same way, a 500 through model/rows.ts's parseRows, where ten of them silently dropped it; the playlist dumps query uses the model's column list rather than a hand-written copy, and the aliased list is derived from the plain one. The vote and like services commit through transaction() instead of their own BEGIN and ROLLBACK, which would have rolled back a caller's transaction.
Validation: the dump validators refuse the nulls their shared types don't allow (a null title or note on creation, a null URL on edit); a playlist title of spaces is refused and titles are stored trimmed; usernames and emails are checked by the same USERNAME_RE and EMAIL_RE in shared/validation.ts on sign-up, on PATCH /api/users/me (which checked other limits) and in the sign-up form. Mentions are read by shared/mention.ts's mentionedUsernames, which skips code and URLs as Markdown shows them: https://mastodon.social/@bob or a `@bob` in code notified bob; the editor's suggestions derive their trigger from the same pattern instead of a copy. Following an unknown user is a 404 rather than a foreign-key 500, and following a private playlist a 404 rather than a 403 that confirmed it exists. `ws` and `emojibase` are reserved category slugs, since the server answers them before the app. GET /api/users/me no longer turns every error into a 500.

Dead code goes: adminOnlyMiddleware, verifyEmailTransport, listUsers, deleteUser with disconnectUser and the force_logout message only it sent, PATCH /api/notifications/:id/read (with markOneRead), isSlugAvailable, the unreachable 500 in fetchComment, and the `if (!ctx.state.user)` checks behind authMiddleware. 214 backend and 627 frontend tests pass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 19:35:53 +00:00

415 lines
12 KiB
TypeScript

/**
* Stores notifications, pushes each new one to its user's sockets as
* `notification_created`, and reads them back for `routes/notifications.ts`.
* The services of follows, votes, likes, comments, dumps, playlists and the
* chat call its `notify…` triggers. Every trigger passes a source key, so the
* same event never notifies a user twice.
*
* @module
*/
import type {
Notification,
NotificationData,
NotificationType,
UserDumpPostedData,
} from "../../shared/notification.ts";
import { APIException } from "../lib/api-exception.ts";
import { db, transaction } from "../db/connection.ts";
import {
isNotificationRow,
notificationRowToApi,
} from "../model/notification.ts";
import { type Page, pageOf } from "../lib/pagination.ts";
import { sendToUser } from "./ws-service.ts";
import { mentionedUsernames } from "../../shared/mention.ts";
/**
* Stores a notification for `userId` and pushes it live. With a `sourceKey`,
* a second one for the same `(userId, sourceKey)` is neither stored nor sent.
*/
function createNotification(
userId: string,
type: NotificationType,
data: NotificationData,
sourceKey: string | null = null,
): void {
const id = crypto.randomUUID();
const now = new Date();
const createdAt = now.toISOString();
const dataJson = JSON.stringify(data);
let changes: number;
if (sourceKey) {
const result = db.prepare(
`INSERT OR IGNORE INTO notifications (id, user_id, type, data, read, created_at, source_key)
VALUES (?, ?, ?, ?, 0, ?, ?);`,
).run(id, userId, type, dataJson, createdAt, sourceKey);
changes = result.changes as number;
} else {
const result = db.prepare(
`INSERT INTO notifications (id, user_id, type, data, read, created_at, source_key)
VALUES (?, ?, ?, ?, 0, ?, NULL);`,
).run(id, userId, type, dataJson, createdAt);
changes = result.changes as number;
}
if (changes > 0) {
sendToUser(userId, {
type: "notification_created",
notification: {
id,
userId,
type,
data,
read: false,
createdAt: now,
},
});
}
}
/**
* Returns a page of `userId`'s notifications, newest first, for
* `GET /api/notifications`.
*
* @throws {APIException} 500 when a row doesn't parse.
*/
export function getNotificationsForUser(
userId: string,
page: number,
limit: number,
): Page<Notification> {
const offset = (page - 1) * limit;
const rawRows = db.prepare(
`SELECT * FROM notifications WHERE user_id = ? ORDER BY created_at DESC LIMIT ? OFFSET ?;`,
).all(userId, limit + 1, offset);
if (!rawRows.every(isNotificationRow)) {
throw new APIException(500, "Malformed notification data");
}
return pageOf(rawRows.map(notificationRowToApi), limit);
}
/**
* Returns how many of `userId`'s notifications are unread. The WebSocket route
* sends it in its `welcome` message, for the bell's count.
*/
export function getUnreadCount(userId: string): number {
const row = db.prepare(
`SELECT COUNT(*) as count FROM notifications WHERE user_id = ? AND read = 0;`,
).get(userId) as { count: number } | undefined;
return row?.count ?? 0;
}
/**
* Marks every notification of `userId` read, when they open the
* `/notifications` page.
*/
export function markAllRead(userId: string): void {
db.prepare(`UPDATE notifications SET read = 1 WHERE user_id = ?;`).run(
userId,
);
}
/**
* Notifies `followedUserId` that `followerId` follows them, once however often
* they unfollow and follow again. `followUser` calls it.
*/
export function notifyUserNewFollower(
followerId: string,
followedUserId: string,
): void {
const followerRow = db.prepare(
`SELECT username FROM users WHERE id = ?;`,
).get(followerId) as { username: string } | undefined;
if (!followerRow) return;
createNotification(
followedUserId,
"user_followed",
{ followerId, followerUsername: followerRow.username },
`user-followed:${followedUserId}:${followerId}`,
);
}
/**
* Notifies a playlist's owner that `followerId` follows it, unless they are
* the owner, and once however often they unfollow and follow again.
* `followPlaylist` calls it.
*/
export function notifyPlaylistOwnerNewFollower(
followerId: string,
playlistId: string,
): void {
const followerRow = db.prepare(
`SELECT username FROM users WHERE id = ?;`,
).get(followerId) as { username: string } | undefined;
const playlistRow = db.prepare(
`SELECT title, user_id FROM playlists WHERE id = ?;`,
).get(playlistId) as { title: string; user_id: string } | undefined;
if (!followerRow || !playlistRow) return;
if (followerId === playlistRow.user_id) return;
createNotification(
playlistRow.user_id,
"playlist_followed",
{
followerId,
followerUsername: followerRow.username,
playlistId,
playlistTitle: playlistRow.title,
},
`followed:${playlistId}:${followerId}`,
);
}
/**
* Notifies everyone following `dumperId` that they posted a dump. The dump
* service calls it for a public dump only, when it is created.
*/
export function notifyUserFollowersNewDump(
dumperId: string,
dumpId: string,
dumpTitle: string,
): void {
const posterRow = db.prepare(
`SELECT username FROM users WHERE id = ?;`,
).get(dumperId) as { username: string } | undefined;
if (!posterRow) return;
const followerRows = db.prepare(
`SELECT follower_id FROM follows WHERE followed_user_id = ?;`,
).all(dumperId) as { follower_id: string }[];
if (followerRows.length === 0) return;
const data: UserDumpPostedData = {
dumperId,
dumperUsername: posterRow.username,
dumpId,
dumpTitle,
};
const dataJson = JSON.stringify(data);
const now = new Date();
const createdAt = now.toISOString();
const sourceKey = `dump:${dumpId}`;
// One insert per follower, so that only the rows actually stored are sent,
// each with its stored id: the client dedupes a refetch against it.
const insert = db.prepare(
`INSERT OR IGNORE INTO notifications (id, user_id, type, data, read, created_at, source_key)
VALUES (?, ?, ?, ?, 0, ?, ?);`,
);
// One transaction for the whole fan-out: a commit per follower is what made
// a popular user's dump slow to post. The live events go out inside it, but
// the commit follows in the same tick, before any client can ask back.
transaction(() => {
for (const row of followerRows) {
const id = crypto.randomUUID();
const result = insert.run(
id,
row.follower_id,
"user_dump_posted",
dataJson,
createdAt,
sourceKey,
);
if ((result.changes as number) > 0) {
sendToUser(row.follower_id, {
type: "notification_created",
notification: {
id,
userId: row.follower_id,
type: "user_dump_posted",
data,
read: false,
createdAt: now,
},
});
}
}
});
}
/**
* Notifies a dump's poster that `voterId` upvoted it, unless they are the
* poster, and once however often the vote is withdrawn and cast again.
* `castVote` calls it.
*/
export function notifyDumpOwnerUpvote(
voterId: string,
dumpId: string,
): void {
const voterRow = db.prepare(
`SELECT username FROM users WHERE id = ?;`,
).get(voterId) as { username: string } | undefined;
const dumpRow = db.prepare(
`SELECT title, user_id FROM dumps WHERE id = ?;`,
).get(dumpId) as { title: string; user_id: string } | undefined;
if (!voterRow || !dumpRow) return;
if (voterId === dumpRow.user_id) return;
createNotification(
dumpRow.user_id,
"dump_upvoted",
{
voterId,
voterUsername: voterRow.username,
dumpId,
dumpTitle: dumpRow.title,
},
`upvote:${dumpId}:${voterId}`,
);
}
/**
* Notifies every user @mentioned in `body`, except its author, that they were
* mentioned in the comment, dump, playlist or chat message `contextId`. Call
* it again after each edit: a user already notified for this context isn't
* notified twice. `skipUserId` spares a mentioned user; the chat uses it for
* those who have it open.
*/
export function notifyMentions(
mentionerUserId: string,
body: string,
contextType: "comment" | "dump" | "playlist" | "chat",
contextId: string,
contextTitle: string,
dumpId?: string,
skipUserId?: (userId: string) => boolean,
): void {
const mentionerRow = db.prepare(
`SELECT username FROM users WHERE id = ?;`,
).get(mentionerUserId) as { username: string } | undefined;
if (!mentionerRow) return;
const usernames = mentionedUsernames(body);
for (const username of usernames) {
const mentionedRow = db.prepare(
`SELECT id FROM users WHERE lower(username) = ?;`,
).get(username) as { id: string } | undefined;
if (!mentionedRow || mentionedRow.id === mentionerUserId) continue;
if (skipUserId?.(mentionedRow.id)) continue;
createNotification(
mentionedRow.id,
"user_mentioned",
{
mentionerId: mentionerUserId,
mentionerUsername: mentionerRow.username,
contextType,
contextId,
contextTitle,
dumpId,
},
`mention:${contextType}:${contextId}:${mentionedRow.id}`,
);
}
}
/**
* Notifies a dump's poster that `commenterId` commented on it, unless they are
* the poster. `createComment` calls it for replies too, but not on an edit.
*/
export function notifyDumpOwnerNewComment(
commenterId: string,
commentId: string,
dumpId: string,
): void {
const commenterRow = db.prepare(
`SELECT username FROM users WHERE id = ?;`,
).get(commenterId) as { username: string } | undefined;
const dumpRow = db.prepare(
`SELECT title, user_id FROM dumps WHERE id = ?;`,
).get(dumpId) as { title: string; user_id: string } | undefined;
if (!commenterRow || !dumpRow) return;
if (commenterId === dumpRow.user_id) return;
createNotification(
dumpRow.user_id,
"dump_commented",
{
commenterId,
commenterUsername: commenterRow.username,
commentId,
dumpId,
dumpTitle: dumpRow.title,
},
`comment:${commentId}`,
);
}
/**
* Notifies a comment's author that `likerId` likes it, unless they are the
* author, and once however often the like is withdrawn and given again.
*/
export function notifyCommentOwnerLike(
likerId: string,
commentId: string,
): void {
const likerRow = db.prepare(
`SELECT username FROM users WHERE id = ?;`,
).get(likerId) as { username: string } | undefined;
const commentRow = db.prepare(
`SELECT c.user_id, c.dump_id, d.title as dump_title
FROM comments c JOIN dumps d ON c.dump_id = d.id WHERE c.id = ?;`,
).get(commentId) as
| { user_id: string; dump_id: string; dump_title: string }
| undefined;
if (!likerRow || !commentRow) return;
if (likerId === commentRow.user_id) return;
createNotification(
commentRow.user_id,
"comment_liked",
{
likerId,
likerUsername: likerRow.username,
commentId,
dumpId: commentRow.dump_id,
dumpTitle: commentRow.dump_title,
},
`like:${commentId}:${likerId}`,
);
}
/**
* Notifies everyone following a playlist that a dump was added to it. The
* playlist service calls it only when both are public, since a follower can't
* open a private one.
*/
export function notifyPlaylistFollowersNewDump(
playlistId: string,
playlistTitle: string,
dumpId: string,
dumpTitle: string,
): void {
const followerRows = db.prepare(
`SELECT follower_id FROM follows WHERE followed_playlist_id = ?;`,
).all(playlistId) as { follower_id: string }[];
transaction(() => {
for (const row of followerRows) {
createNotification(
row.follower_id,
"playlist_dump_added",
{ dumpId, dumpTitle, playlistId, playlistTitle },
`pdump:${playlistId}:${dumpId}`,
);
}
});
}