master
Raw Download raw file
  1import bcrypt from "@node-rs/bcrypt";
  2import express from "express";
  3import { DateTime, Duration } from "luxon";
  4import { sql } from "slonik";
  5import { z } from "zod";
  6
  7import { pool } from "./db.js";
  8
  9const MaxSecretDate = "2030-01-01T00:00:00.000"; // if you want to keep a secret longer than this, do it yourself >:(
 10
 11const UpdateIntervals = [
 12	Duration.fromObject({ years: 2 }).toMillis(),
 13	Duration.fromObject({ years: 1 }).toMillis(),
 14	Duration.fromObject({ months: 6 }).toMillis(),
 15	Duration.fromObject({ months: 2 }).toMillis(),
 16	Duration.fromObject({ months: 1 }).toMillis(),
 17	Duration.fromObject({ weeks: 1 }).toMillis(),
 18	Duration.fromObject({ days: 1 }).toMillis(),
 19	Duration.fromObject({ hours: 1 }).toMillis(),
 20	Duration.fromObject({ minutes: 30 }).toMillis(),
 21	Duration.fromObject({ minutes: 10 }).toMillis(),
 22	Duration.fromObject({ minutes: 5 }).toMillis(),
 23	Duration.fromObject({ minutes: 1 }).toMillis(),
 24	Duration.fromObject({ seconds: 30 }).toMillis(),
 25	Duration.fromObject({ seconds: 10 }).toMillis(),
 26	Duration.fromObject({ seconds: 5 }).toMillis(),
 27	Duration.fromObject({ seconds: 1 }).toMillis(),
 28	Duration.fromObject({ milliseconds: 500 }).toMillis(),
 29	Duration.fromObject({ milliseconds: 100 }).toMillis(),
 30];
 31
 32function formatDuration(ms: number) {
 33	if (ms < 1000) {
 34		return "0s";
 35	}
 36
 37	const seconds = Math.floor(ms / 1000);
 38
 39	if (seconds < 60) {
 40		return `${seconds}s`;
 41	}
 42
 43	const minutes = Math.floor(seconds / 60);
 44
 45	if (minutes < 60) {
 46		return `${minutes}m`;
 47	}
 48
 49	const hours = Math.floor(minutes / 60);
 50
 51	if (hours < 24) {
 52		return `${hours}h`;
 53	}
 54
 55	const days = Math.floor(hours / 24);
 56
 57	if (days < 7) {
 58		return `${days}d`;
 59	}
 60
 61	const weeks = Math.floor(days / 7);
 62
 63	if (weeks < 4) {
 64		return `${weeks}w`;
 65	}
 66
 67	const months = Math.floor(weeks / 4);
 68
 69	if (months < 12) {
 70		return `${months}mo`;
 71	}
 72
 73	const years = Math.floor(months / 12);
 74
 75	return `${years}y`;
 76}
 77
 78export const apiRouter = express.Router();
 79
 80apiRouter.use(async (req, res, next) => {
 81	req.sundown = {};
 82
 83	if (typeof req.cookies.session === "string") {
 84		try {
 85			req.sundown.user =
 86				(await pool.maybeOneFirst(sql.type(z.object({ id: z.string() }))`
 87				SELECT id
 88				FROM sundown.user
 89					JOIN sundown.token ON sundown.token.user_id = sundown.user.id
 90				WHERE sundown.token.token = ${req.cookies.session}
 91			`)) ?? undefined;
 92		} catch (err) {
 93			// Ignore
 94		}
 95	}
 96
 97	next();
 98});
 99
100apiRouter.post("/register", async (req, res) => {
101	await pool.transaction(async (tx) => {
102		const body = z
103			.object({
104				username: z.string(),
105				password: z.string(),
106			})
107			.parse(req.body);
108
109		const passwordHash = await bcrypt.hash(body.password, 12);
110
111		await tx.one(sql.unsafe`
112			INSERT INTO sundown.user (id, password)
113			VALUES (${body.username}, ${passwordHash})
114			RETURNING *
115		`);
116
117		res.json({ success: true });
118	});
119});
120
121apiRouter.post("/login", async (req, res) => {
122	const body = z
123		.object({
124			username: z.string(),
125			password: z.string(),
126		})
127		.parse(req.body);
128
129	const user = await pool.maybeOne(sql.type(z.object({ id: z.string(), password: z.string() }))`
130		SELECT * FROM sundown.user
131		WHERE id = ${body.username}
132	`);
133
134	if (user === null) {
135		res.status(403).send("Invalid username or password");
136		return;
137	}
138
139	const passwordOk = await bcrypt.compare(body.password, user.password);
140
141	if (!passwordOk) {
142		res.status(403).send("Invalid username or password");
143		return;
144	}
145
146	const token = await pool.one(sql.type(z.object({ token: z.string() }))`
147		INSERT INTO sundown.token (user_id)
148		VALUES (${user.id})
149		RETURNING token
150	`);
151
152	res.cookie("session", token.token, { httpOnly: true });
153	res.json({ success: true });
154});
155
156apiRouter.post("/logout", async (req, res) => {
157	if (req.sundown.user === undefined) {
158		res.status(403).send("Not authenticated");
159		return;
160	}
161
162	await pool.query(sql.unsafe`
163		DELETE FROM sundown.token
164		WHERE user_id = ${req.sundown.user}
165	`);
166
167	res.clearCookie("session");
168	res.json({ success: true });
169});
170
171apiRouter.get("/me", async (req, res) => {
172	if (req.sundown.user === undefined) {
173		res.status(403).send("Not authenticated");
174		return;
175	}
176
177	res.json({ user: req.sundown.user });
178});
179
180apiRouter.get("/secrets/my", async (req, res) => {
181	if (req.sundown.user === undefined) {
182		res.status(403).send("Not authenticated");
183		return;
184	}
185
186	const secrets = await pool.any(sql.type(
187		z.object({
188			id: z.string(),
189			name: z.string(),
190			reveal_at: z.number(),
191			created_at: z.number(),
192		}),
193	)`
194		SELECT id, name, timestamp_to_ms(reveal_at) AS reveal_at, timestamp_to_ms(created_at) AS created_at
195		FROM sundown.secret
196		WHERE owner_id = ${req.sundown.user}
197	`);
198
199	res.json({
200		secrets: secrets.map((secret) => ({
201			id: secret.id,
202			name: secret.name,
203			revealAt: DateTime.fromMillis(secret.reveal_at, { zone: "UTC" }).toISO(),
204			createdAt: DateTime.fromMillis(secret.created_at, {
205				zone: "UTC",
206			}).toISO(),
207		})),
208	});
209});
210
211apiRouter.post("/secrets/create", async (req, res) => {
212	const body = z
213		.object({
214			secret: z.string().min(1).max(1000),
215			name: z.string().min(1).max(100),
216			revealAt: z.string().transform((s, ctx) => {
217				const date = DateTime.fromISO(s);
218
219				if (!date.isValid) {
220					ctx.addIssue({ code: "custom", message: "Invalid date" });
221					return z.NEVER;
222				}
223
224				if (s > MaxSecretDate) {
225					ctx.addIssue({
226						code: "custom",
227						message: "Reveal date too far in the future",
228					});
229					return z.NEVER;
230				}
231
232				return date;
233			}),
234		})
235		.parse(req.body);
236
237	if (req.sundown.user === undefined) {
238		res.status(403).send("Not authenticated");
239		return;
240	}
241
242	const id = await pool.oneFirst(sql.type(z.object({ id: z.string() }))`
243		INSERT INTO sundown.secret (owner_id, name, secret, reveal_at)
244		VALUES (${req.sundown.user}, ${body.name}, ${body.secret}, ms_to_timestamp(${body.revealAt.toMillis()}))
245		RETURNING id
246	`);
247
248	res.json({ id });
249});
250
251apiRouter.ws("/ws", (ws, req) => {
252	let secretId: string | undefined;
253	let secret: string | undefined;
254	let timeout: NodeJS.Timeout | undefined;
255	let remaining: number | undefined;
256	let timeoutDuration: number | undefined;
257
258	function revealSecret() {
259		if (secretId === undefined || secret === undefined) {
260			return;
261		}
262
263		ws.send(JSON.stringify({ kind: "Reveal", id: secretId, secret }));
264		secretId = undefined;
265		secret = undefined;
266		timeout = undefined;
267		remaining = undefined;
268	}
269
270	function updateTimeoutDuration() {
271		timeoutDuration = UpdateIntervals.find((interval) => interval <= remaining! / 100) ?? 100;
272	}
273
274	function updateTimeout() {
275		if (remaining === undefined) {
276			return;
277		}
278
279		if (timeout !== undefined) {
280			clearTimeout(timeout);
281		}
282
283		ws.send(JSON.stringify({ kind: "Update", remaining: formatDuration(remaining) }));
284
285		timeout = setTimeout(() => {
286			remaining! -= timeoutDuration!;
287			if (remaining! <= 0) {
288				revealSecret();
289			} else {
290				updateTimeoutDuration();
291				updateTimeout();
292			}
293		}, timeoutDuration);
294	}
295
296	ws.on("close", () => {
297		if (timeout !== undefined) {
298			clearTimeout(timeout);
299		}
300	});
301
302	ws.on("message", async (message) => {
303		try {
304			let data: { command: "open"; id: string };
305
306			try {
307				let rawData: unknown;
308
309				if (typeof message === "string") {
310					rawData = JSON.parse(message);
311				} else {
312					rawData = JSON.parse(message.toString());
313				}
314
315				data = z
316					.object({
317						command: z.literal("open"),
318						id: z.string(),
319					})
320					.parse(rawData);
321			} catch (err) {
322				ws.send(JSON.stringify({ error: "Failed to parse command" }));
323				return;
324			}
325
326			const secretData = await pool.maybeOne(sql.type(
327				z.object({
328					id: z.string(),
329					owner_id: z.string(),
330					name: z.string(),
331					secret: z.string(),
332					reveal_at: z.number(),
333					created_at: z.number(),
334				}),
335			)`
336				SELECT id, owner_id, name, secret, timestamp_to_ms(reveal_at) AS reveal_at, timestamp_to_ms(created_at) AS created_at
337				FROM sundown.secret
338				WHERE id = ${data.id}
339			`);
340
341			if (secretData === null) {
342				ws.send(JSON.stringify({ error: "Secret not found" }));
343				return;
344			}
345
346			secretId = secretData.id;
347			secret = secretData.secret;
348			ws.send(JSON.stringify({ kind: "Watch", id: secretId, name: secretData.name }));
349			remaining = new Date(secretData.reveal_at).getTime() - Date.now();
350
351			if (remaining <= 0) {
352				revealSecret();
353			} else {
354				if (timeoutDuration === undefined) {
355					updateTimeoutDuration();
356				}
357				updateTimeout();
358			}
359		} catch (err) {
360			console.error("Unexpected error", err);
361			ws.close();
362		}
363	});
364
365	ws.send(JSON.stringify({ kind: "Connected" }));
366});