v3: code quality pass, various bug fixes
This commit is contained in:
@@ -374,22 +374,32 @@ export async function replaceFileDump(
|
||||
}
|
||||
|
||||
const data = new Uint8Array(await file.arrayBuffer());
|
||||
await Deno.writeFile(`${DUMPS_DIR}/${dumpId}`, data);
|
||||
const filePath = `${DUMPS_DIR}/${dumpId}`;
|
||||
// Read old file contents so we can restore on DB failure
|
||||
const oldData = await Deno.readFile(filePath).catch(() => null);
|
||||
await Deno.writeFile(filePath, data);
|
||||
|
||||
const now = new Date();
|
||||
const newSlug = makeSlug(file.name, dumpId);
|
||||
db.prepare(
|
||||
`UPDATE dumps SET title = ?, slug = ?, file_name = ?, file_mime = ?, file_size = ?, comment = ?, updated_at = ? WHERE id = ?;`,
|
||||
).run(
|
||||
file.name,
|
||||
newSlug,
|
||||
file.name,
|
||||
file.type,
|
||||
file.size,
|
||||
comment ?? null,
|
||||
now.toISOString(),
|
||||
dumpId,
|
||||
);
|
||||
try {
|
||||
db.prepare(
|
||||
`UPDATE dumps SET title = ?, slug = ?, file_name = ?, file_mime = ?, file_size = ?, comment = ?, updated_at = ? WHERE id = ?;`,
|
||||
).run(
|
||||
file.name,
|
||||
newSlug,
|
||||
file.name,
|
||||
file.type,
|
||||
file.size,
|
||||
comment ?? null,
|
||||
now.toISOString(),
|
||||
dumpId,
|
||||
);
|
||||
} catch (err) {
|
||||
// Roll back the file to its previous contents on DB failure
|
||||
if (oldData) await Deno.writeFile(filePath, oldData).catch(() => {});
|
||||
else await Deno.remove(filePath).catch(() => {});
|
||||
throw err;
|
||||
}
|
||||
|
||||
if (comment) notifyMentions(dump.userId, comment, "dump", dumpId, file.name);
|
||||
return {
|
||||
|
||||
@@ -2,6 +2,7 @@ import type {
|
||||
Notification,
|
||||
NotificationData,
|
||||
NotificationType,
|
||||
UserDumpPostedData,
|
||||
} from "../model/interfaces.ts";
|
||||
import { APIErrorCode, APIException } from "../model/interfaces.ts";
|
||||
import { db, isNotificationRow, notificationRowToApi } from "../model/db.ts";
|
||||
@@ -156,14 +157,53 @@ export function notifyUserFollowersNewDump(
|
||||
`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 createdAt = new Date().toISOString();
|
||||
const sourceKey = `dump:${dumpId}`;
|
||||
|
||||
// Batch INSERT all follower notifications in a single statement
|
||||
const params: (string | number | null)[] = [];
|
||||
const placeholders: string[] = [];
|
||||
for (const row of followerRows) {
|
||||
createNotification(
|
||||
const id = crypto.randomUUID();
|
||||
placeholders.push("(?, ?, ?, ?, 0, ?, ?)");
|
||||
params.push(
|
||||
id,
|
||||
row.follower_id,
|
||||
"user_dump_posted",
|
||||
{ dumperId, dumperUsername: posterRow.username, dumpId, dumpTitle },
|
||||
`dump:${dumpId}`,
|
||||
dataJson,
|
||||
createdAt,
|
||||
sourceKey,
|
||||
);
|
||||
}
|
||||
|
||||
const result = db.prepare(
|
||||
`INSERT OR IGNORE INTO notifications (id, user_id, type, data, read, created_at, source_key)
|
||||
VALUES ${placeholders.join(", ")};`,
|
||||
).run(...params);
|
||||
|
||||
if ((result.changes as number) > 0) {
|
||||
for (const row of followerRows) {
|
||||
sendToUser(row.follower_id, {
|
||||
type: "notification_created",
|
||||
notification: {
|
||||
userId: row.follower_id,
|
||||
type: "user_dump_posted",
|
||||
data,
|
||||
read: false,
|
||||
createdAt,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function notifyDumpOwnerUpvote(
|
||||
|
||||
@@ -95,7 +95,8 @@ export function getPlaylist(
|
||||
|
||||
// For public playlists (or when viewed by non-owner), filter out private dumps
|
||||
const rows = db.prepare(
|
||||
`SELECT ${dumpCols}
|
||||
`SELECT ${dumpCols},
|
||||
(SELECT COUNT(*) FROM comments WHERE dump_id = d.id AND deleted = 0) as comment_count
|
||||
FROM dumps d
|
||||
INNER JOIN playlist_dumps pd ON d.id = pd.dump_id
|
||||
WHERE pd.playlist_id = ?
|
||||
|
||||
@@ -80,10 +80,18 @@ export function extractOgTag(
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function isPrivateHost(hostname: string): boolean {
|
||||
// Block loopback and RFC-1918 ranges. Note: DNS rebinding is not fully mitigated.
|
||||
if (hostname === "localhost" || hostname === "::1") return true;
|
||||
return /^(127\.|10\.|172\.(1[6-9]|2\d|3[01])\.|192\.168\.)/.test(hostname);
|
||||
}
|
||||
|
||||
export function isValidHttpUrl(raw: string): boolean {
|
||||
try {
|
||||
const u = new URL(raw);
|
||||
return u.protocol === "http:" || u.protocol === "https:";
|
||||
if (u.protocol !== "http:" && u.protocol !== "https:") return false;
|
||||
if (isPrivateHost(u.hostname)) return false;
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import type {
|
||||
Dump,
|
||||
OnlineUser,
|
||||
Playlist,
|
||||
User,
|
||||
} from "../model/interfaces.ts";
|
||||
|
||||
export interface WsClient {
|
||||
@@ -11,6 +12,7 @@ export interface WsClient {
|
||||
username?: string;
|
||||
avatarMime?: string;
|
||||
avatarVersion?: number;
|
||||
pongReceived?: boolean;
|
||||
}
|
||||
|
||||
const clients = new Set<WsClient>();
|
||||
@@ -151,6 +153,12 @@ export function broadcastPlaylistDumpsUpdated(
|
||||
});
|
||||
}
|
||||
|
||||
export function broadcastUserUpdated(user: Omit<User, "passwordHash">): void {
|
||||
for (const client of clients) {
|
||||
send(client.socket, { type: "user_updated", user });
|
||||
}
|
||||
}
|
||||
|
||||
export function broadcastCommentCreated(comment: Comment): void {
|
||||
for (const client of clients) {
|
||||
send(client.socket, { type: "comment_created", comment });
|
||||
@@ -172,7 +180,11 @@ export function broadcastCommentUpdated(comment: Comment): void {
|
||||
}
|
||||
}
|
||||
|
||||
// Keepalive: ping all clients every 30s, remove non-responsive ones
|
||||
export function handleClientPong(client: WsClient): void {
|
||||
client.pongReceived = true;
|
||||
}
|
||||
|
||||
// Keepalive: ping all clients every 30s, disconnect non-responsive ones
|
||||
const PING_INTERVAL = 30_000;
|
||||
|
||||
setInterval(() => {
|
||||
@@ -181,7 +193,13 @@ setInterval(() => {
|
||||
clients.delete(client);
|
||||
continue;
|
||||
}
|
||||
// Disconnect if no pong since last ping (pongReceived starts undefined, skip first cycle)
|
||||
if (client.pongReceived === false) {
|
||||
client.socket.close(1001, "Ping timeout");
|
||||
clients.delete(client);
|
||||
continue;
|
||||
}
|
||||
client.pongReceived = false;
|
||||
send(client.socket, { type: "ping" });
|
||||
// Schedule removal if no pong (tracked via heartbeat flag)
|
||||
}
|
||||
}, PING_INTERVAL);
|
||||
|
||||
Reference in New Issue
Block a user