master
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});