mirror of
https://codeberg.org/valpackett/tiddlypwa.git
synced 2026-04-27 08:10:44 -07:00
345 lines
11 KiB
TypeScript
345 lines
11 KiB
TypeScript
import { serveListener } from 'https://deno.land/std@0.150.0/http/server.ts';
|
|
import { parse as argparse } from 'https://deno.land/std@0.150.0/flags/mod.ts';
|
|
import * as base64 from 'https://deno.land/std@0.150.0/encoding/base64.ts';
|
|
import * as brotli from 'https://deno.land/x/brotli@v0.1.4/mod.ts';
|
|
import { DB } from 'https://deno.land/x/sqlite@v3.4.0/mod.ts';
|
|
|
|
const args = argparse(Deno.args);
|
|
const admintoken = (args.admintoken || Deno.env.get('ADMIN_TOKEN'))?.trim();
|
|
const db = new DB(args.db || Deno.env.get('SQLITE_DB') || '.data/tiddly.db');
|
|
const utfenc = new TextEncoder();
|
|
|
|
const dbver = db.query('PRAGMA user_version')[0][0] as number;
|
|
if (dbver < 1) {
|
|
db.execute(`
|
|
BEGIN;
|
|
CREATE TABLE wikis (
|
|
id INTEGER PRIMARY KEY,
|
|
token TEXT NOT NULL,
|
|
authcode TEXT,
|
|
apphtml BLOB,
|
|
apphtmletag BLOB,
|
|
swjs BLOB,
|
|
swjsetag BLOB
|
|
) STRICT;
|
|
CREATE TABLE tiddlers (
|
|
thash BLOB PRIMARY KEY NOT NULL,
|
|
title BLOB,
|
|
tiv BLOB,
|
|
data BLOB,
|
|
iv BLOB,
|
|
mtime INTEGER NOT NULL,
|
|
deleted INTEGER NOT NULL DEFAULT 0,
|
|
wiki INTEGER NOT NULL,
|
|
FOREIGN KEY(wiki) REFERENCES wikis(id) ON DELETE CASCADE
|
|
) STRICT;
|
|
PRAGMA user_version = 1;
|
|
COMMIT;
|
|
`);
|
|
}
|
|
|
|
const wikiAuthQuery = db.prepareQuery(`
|
|
SELECT id, authcode, apphtmletag FROM wikis WHERE token = :token
|
|
`);
|
|
|
|
const apphtmlQuery = db.prepareQuery(`
|
|
SELECT apphtml, apphtmletag FROM wikis WHERE token LIKE :halftoken || '%'
|
|
`);
|
|
|
|
const swjsQuery = db.prepareQuery(`
|
|
SELECT swjs, swjsetag FROM wikis WHERE token LIKE :halftoken || '%'
|
|
`);
|
|
|
|
const changedQuery = db.prepareQuery<
|
|
[Uint8Array, Uint8Array, Uint8Array, Uint8Array, Uint8Array, number, boolean]
|
|
>(`
|
|
SELECT thash, title, tiv, data, iv, mtime, deleted
|
|
FROM tiddlers WHERE mtime > :modsince AND wiki = :wiki
|
|
`);
|
|
|
|
const upsertQuery = db.prepareQuery(`
|
|
INSERT INTO tiddlers (thash, title, tiv, data, iv, mtime, deleted, wiki)
|
|
VALUES (:thash, :title, :tiv, :data, :iv, :mtime, :deleted, :wiki)
|
|
ON CONFLICT (thash) DO UPDATE SET
|
|
title = excluded.title,
|
|
tiv = excluded.tiv,
|
|
data = excluded.data,
|
|
iv = excluded.iv,
|
|
mtime = excluded.mtime,
|
|
deleted = excluded.deleted
|
|
WHERE excluded.mtime > mtime
|
|
`);
|
|
|
|
const apiPat = new URLPattern({ pathname: '/tid.dly' });
|
|
const apphtmlPat = new URLPattern({ pathname: '/:halftoken/app.html' });
|
|
const swjsPat = new URLPattern({ pathname: '/:halftoken/sw.js' });
|
|
|
|
const respHdrs = { 'access-control-allow-origin': '*' };
|
|
|
|
function parseTime(x: number) {
|
|
const time = new Date();
|
|
time.setTime(x);
|
|
return time;
|
|
}
|
|
|
|
function processEtag(etag: Uint8Array, headers: Headers): [boolean, string] {
|
|
const supportsBrotli = !!headers.get('accept-encoding')?.split(',').find((x) => x.trim().split(';')[0] === 'br');
|
|
return [supportsBrotli, '"' + base64.encode(etag) + (supportsBrotli ? '-b' : '-x') + '"'];
|
|
}
|
|
|
|
function notifyMonitors(token: string, browserToken: string) {
|
|
const chan = new BroadcastChannel(token);
|
|
chan.postMessage({ exclude: browserToken });
|
|
// chan.close(); // -> Uncaught (in promise) BadResource: Bad resource ID ?!
|
|
}
|
|
|
|
function handleSync(
|
|
{ token, browserToken, authcode, now, clientChanges, lastSync }: {
|
|
token: any;
|
|
browserToken: any;
|
|
authcode: any;
|
|
now: any;
|
|
clientChanges: any;
|
|
lastSync: any;
|
|
},
|
|
headers: Headers,
|
|
) {
|
|
if (
|
|
typeof token !== 'string' || typeof authcode !== 'string' || typeof now !== 'string' ||
|
|
typeof lastSync !== 'string' ||
|
|
!Array.isArray(clientChanges)
|
|
) {
|
|
return Response.json({ error: 'EPROTO' }, { headers: respHdrs, status: 400 });
|
|
}
|
|
if (Math.abs(new Date(now).getTime() - new Date().getTime()) > 60000) {
|
|
return Response.json({ error: 'ETIMESYNC' }, { headers: respHdrs, status: 400 });
|
|
}
|
|
const wikiRows = wikiAuthQuery.all({ token });
|
|
if (wikiRows.length < 1) {
|
|
return Response.json({ error: 'EAUTH' }, { headers: respHdrs, status: 401 });
|
|
}
|
|
const [wiki, dbauthcode, apphtmletag] = wikiRows[0];
|
|
if (dbauthcode && authcode !== dbauthcode) {
|
|
return Response.json({ error: 'EAUTH' }, { headers: respHdrs, status: 401 });
|
|
}
|
|
const serverChanges: Array<Record<string, string | Date | boolean | null>> = [];
|
|
db.transaction(() => {
|
|
if (!dbauthcode) {
|
|
db.query(
|
|
`UPDATE wikis SET authcode = :authcode WHERE token = :token`,
|
|
{ token, authcode },
|
|
);
|
|
}
|
|
for (
|
|
const { thash, title, tiv, data, iv, mtime, deleted } of changedQuery.iterEntries({
|
|
modsince: new Date(lastSync).getTime(),
|
|
wiki,
|
|
})
|
|
) {
|
|
serverChanges.push({
|
|
thash: thash ? base64.encode(thash as Uint8Array) : null,
|
|
title: title ? base64.encode(title as Uint8Array) : null,
|
|
tiv: tiv ? base64.encode(tiv as Uint8Array) : null,
|
|
data: data ? base64.encode(data as Uint8Array) : null,
|
|
iv: iv ? base64.encode(iv as Uint8Array) : null,
|
|
mtime: parseTime(mtime as number),
|
|
deleted: !!(deleted as number),
|
|
});
|
|
}
|
|
for (const { thash, title, tiv, data, iv, mtime, deleted } of clientChanges) {
|
|
upsertQuery.execute({
|
|
thash: base64.decode(thash),
|
|
title: base64.decode(title),
|
|
tiv: base64.decode(tiv),
|
|
data: base64.decode(data),
|
|
iv: base64.decode(iv),
|
|
mtime: new Date(mtime || now).getTime(),
|
|
deleted: deleted || false,
|
|
wiki,
|
|
});
|
|
}
|
|
});
|
|
if (clientChanges.length > 0 && typeof browserToken === 'string') notifyMonitors(token, browserToken);
|
|
// assuming here that the browser would use the same Accept-Encoding as when requesting the page
|
|
const [_, appEtag] = processEtag(apphtmletag, headers);
|
|
return Response.json({ serverChanges, appEtag }, { headers: respHdrs });
|
|
}
|
|
|
|
function handleCreate({ atoken }: { atoken: any }) {
|
|
if (typeof atoken !== 'string') {
|
|
return Response.json({ error: 'EPROTO' }, { headers: respHdrs, status: 400 });
|
|
}
|
|
if (atoken !== admintoken) {
|
|
return Response.json({ error: 'EAUTH' }, { headers: respHdrs, status: 401 });
|
|
}
|
|
const token = base64.encode(crypto.getRandomValues(new Uint8Array(32)));
|
|
db.query(`INSERT INTO wikis (token) VALUES (:token)`, { token });
|
|
return Response.json({ token }, { headers: respHdrs, status: 201 });
|
|
}
|
|
|
|
function handleDelete({ atoken, token }: { atoken: any; token: any }) {
|
|
if (typeof atoken !== 'string' || typeof token !== 'string') {
|
|
return Response.json({ error: 'EPROTO' }, { headers: respHdrs, status: 400 });
|
|
}
|
|
if (atoken !== admintoken) {
|
|
return Response.json({ error: 'EAUTH' }, { headers: respHdrs, status: 401 });
|
|
}
|
|
db.query(`DELETE FROM wikis WHERE token = :token`, { token });
|
|
return Response.json({}, { headers: respHdrs, status: 200 });
|
|
}
|
|
|
|
async function handleUploadApp(
|
|
{ token, browserToken, apphtml, swjs }: { token: any; browserToken: any; apphtml: any; swjs: any },
|
|
) {
|
|
if (typeof token !== 'string' || typeof apphtml !== 'string' || typeof swjs !== 'string') {
|
|
return Response.json({ error: 'EPROTO' }, { headers: respHdrs, status: 400 });
|
|
}
|
|
const apphtmlutf = utfenc.encode(apphtml);
|
|
const swjsutf = utfenc.encode(swjs);
|
|
db.query(
|
|
`UPDATE wikis SET
|
|
apphtml = :apphtml,
|
|
apphtmletag = :apphtmletag,
|
|
swjs = :swjs,
|
|
swjsetag = :swjsetag
|
|
WHERE token = :token`,
|
|
{
|
|
token,
|
|
apphtml: brotli.compress(apphtmlutf, 4096, 8),
|
|
apphtmletag: new Uint8Array(await crypto.subtle.digest('SHA-1', apphtmlutf)),
|
|
swjs: brotli.compress(swjsutf, 4096, 8),
|
|
swjsetag: new Uint8Array(await crypto.subtle.digest('SHA-1', swjsutf)),
|
|
},
|
|
);
|
|
if (typeof browserToken === 'string') notifyMonitors(token, browserToken);
|
|
return Response.json({ url: token.slice(0, token.length / 2) + '/app.html' }, { headers: respHdrs, status: 200 });
|
|
}
|
|
|
|
function handleMonitor(query: URLSearchParams) {
|
|
const token = query.get('token');
|
|
const browserToken = query.get('browserToken');
|
|
if (!token || !browserToken) {
|
|
return Response.json({ error: 'EPROTO' }, { headers: respHdrs, status: 400 });
|
|
}
|
|
const wikiRows = wikiAuthQuery.all({ token });
|
|
if (wikiRows.length < 1) {
|
|
return Response.json({ error: 'EAUTH' }, { headers: respHdrs, status: 401 });
|
|
}
|
|
let pushChan: BroadcastChannel;
|
|
return new Response(
|
|
new ReadableStream({
|
|
async start(ctrl) {
|
|
ctrl.enqueue('event: hi\ndata: 1\n\n'); // seems to ensure the 'open' event is fired?
|
|
pushChan = new BroadcastChannel(token);
|
|
pushChan.onmessage = (evt) => {
|
|
if (evt.data.exclude !== browserToken) ctrl.enqueue('event: sync\ndata: 1\n\n');
|
|
};
|
|
},
|
|
cancel() {
|
|
pushChan.close();
|
|
},
|
|
}).pipeThrough(new TextEncoderStream()),
|
|
{
|
|
headers: {
|
|
...respHdrs,
|
|
'content-type': 'text/event-stream',
|
|
'cache-control': 'no-store',
|
|
},
|
|
},
|
|
);
|
|
}
|
|
|
|
function preflightResp(methods: string) {
|
|
return new Response(null, {
|
|
headers: {
|
|
...respHdrs,
|
|
'access-control-allow-methods': methods,
|
|
'access-control-allow-headers': '*',
|
|
},
|
|
status: 204,
|
|
});
|
|
}
|
|
|
|
function handleDbFile(pattern: URLPattern, req: Request, query: any, ctype: string): Response | null {
|
|
const match = pattern.exec(req.url);
|
|
if (!match) return null;
|
|
if (req.method !== 'GET' && req.method !== 'HEAD' && req.method !== 'OPTIONS') {
|
|
return new Response(null, { status: 405, headers: { 'allow': 'GET, HEAD, OPTIONS' } });
|
|
}
|
|
const res = query.all(match.pathname.groups);
|
|
if (res.length === 0) {
|
|
return Response.json({}, { status: 404 });
|
|
}
|
|
if (req.method === 'OPTIONS') {
|
|
return preflightResp('GET, HEAD, OPTIONS');
|
|
}
|
|
let [body, etag] = res[0];
|
|
if (!body) {
|
|
return Response.json({}, { status: 404 });
|
|
}
|
|
const [supportsBrotli, etagstr] = processEtag(etag, req.headers);
|
|
// if we decomress and Deno recompresses to something else (gzip) it'll mark the ETag as a weak validator
|
|
const headers = new Headers({
|
|
'content-type': ctype,
|
|
'vary': 'Accept-Encoding',
|
|
'cache-control': 'no-cache',
|
|
'etag': etagstr,
|
|
});
|
|
if (req.headers.get('if-none-match') === etagstr) {
|
|
return new Response(null, { status: 304, headers });
|
|
}
|
|
if (supportsBrotli) {
|
|
headers.set('content-encoding', 'br');
|
|
} else {
|
|
body = brotli.decompress(body);
|
|
}
|
|
headers.set('content-length', body.length);
|
|
return new Response(req.method === 'HEAD' ? null : body, { headers });
|
|
}
|
|
|
|
async function handleApiEndpoint(req: Request) {
|
|
if (!apiPat.exec(req.url)) return null;
|
|
if (req.method === 'OPTIONS') {
|
|
return preflightResp('POST, GET, OPTIONS');
|
|
}
|
|
if (req.method === 'POST') {
|
|
const data = await req.json();
|
|
if (data.tiddlypwa !== 1 || !data.op) {
|
|
return Response.json({ error: 'EPROTO' }, { headers: respHdrs, status: 400 });
|
|
}
|
|
if (data.op === 'sync') return handleSync(data, req.headers);
|
|
if (data.op === 'create') return handleCreate(data);
|
|
if (data.op === 'delete') return handleDelete(data);
|
|
if (data.op === 'uploadapp') return await handleUploadApp(data);
|
|
}
|
|
if (req.method === 'GET') {
|
|
const query = new URL(req.url).searchParams;
|
|
if (query.get('op') === 'monitor') return handleMonitor(query);
|
|
}
|
|
return Response.json({ error: 'EPROTO' }, { status: 405, headers: { ...respHdrs, 'allow': 'OPTIONS, POST, GET' } });
|
|
}
|
|
|
|
async function handle(req: Request) {
|
|
return await handleDbFile(apphtmlPat, req, apphtmlQuery, 'text/html;charset=utf-8') ||
|
|
await handleDbFile(swjsPat, req, swjsQuery, 'text/javascript;charset=utf-8') ||
|
|
await handleApiEndpoint(req) ||
|
|
Response.json({}, { headers: respHdrs, status: 404 });
|
|
}
|
|
|
|
if (!('BroadcastChannel' in window)) {
|
|
throw new Error('BroadcastChannel not found, you may need to run deno with the --unstable flag');
|
|
}
|
|
|
|
const listen: any = { port: 8000 };
|
|
if ('port' in args) {
|
|
listen.port = args.port;
|
|
}
|
|
if ('host' in args) {
|
|
listen.hostname = args.host;
|
|
}
|
|
if ('socket' in args) {
|
|
listen.transport = 'unix';
|
|
listen.path = args.socket;
|
|
}
|
|
console.log('Listening:', listen);
|
|
await serveListener(Deno.listen(listen), handle);
|