import * as sqlite from "jsr:@db/sqlite@0.11"; import * as oak from "jsr:@oak/oak@14"; import * as bcrypt from "https://deno.land/x/bcrypt@v0.4.1/mod.ts"; type User = { id: number; username: string; passwordHash: string; }; type Session = { id: number; token: string; userId: number; username: string; }; interface Db { userWhereUsername(username: string): Promise; sessionWhereToken(token: string): Promise; createSession(init: Omit): Promise; } class SqliteDb implements Db { private db = new sqlite.Database("bunker.db", { create: false }); public async userWhereUsername(username: string): Promise { await Promise.resolve(); const res = this.db.prepare( "select rowid as id, * from users where username=?", ) .value<[User]>(username); if (!res) { return null; } return ["id", "username", "passwordHash"] .map((key, index) => ({ [key]: res[index] })) .reduce>( (acc, kv) => ({ ...acc, ...kv }), {}, ) as User; } public async sessionWhereToken(token: string): Promise { await Promise.resolve(); const res = this.db.prepare( "select rowid as id, * from sessions where token=?", ) .value<[Session]>(token); if (!res) { return null; } return ["id", "token", "userId", "username"] .map((key, index) => ({ [key]: res[index] })) .reduce>( (acc, kv) => ({ ...acc, ...kv }), {}, ) as Session; } public async deleteSessionsWithUsername(username: string): Promise { await Promise.resolve(); this.db.exec("delete from sessions where username=?", username); } public async createSession(init: Omit): Promise { await Promise.resolve(); this.db.exec( "insert into sessions (token, userId, username) values (:token, :userId, :username)", init, ); const res = this.db.prepare( "select rowid as id, * from sessions where username=? and token=?", ) .value<[Session]>(init.username, init.token)!; return ["id", "token", "userId", "username"] .map((key, index) => ({ [key]: res[index] })) .reduce>( (acc, kv) => ({ ...acc, ...kv }), {}, ) as Session; } } const db = new SqliteDb(); type LoginReq = { username: string; password: string; }; function respond< Ctx extends { respond: boolean; // deno-lint-ignore no-explicit-any response: { status: oak.Status; body: any }; }, > // deno-lint-ignore no-explicit-any (ctx: Ctx, status: oak.Status, body: any) { ctx.respond = true; ctx.response.status = status; ctx.response.body = body; } function generateToken(): string { let token = ""; const alfabet = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXUZ123456789"; for (let i = 0; i < 64; ++i) { token += alfabet[Math.random() * alfabet.length]; } return token; } const publicRouter = new oak.Router() .get("/favicon.ico", async (ctx) => { await ctx.send({ path: ctx.request.url.pathname, root: "./public" }); }) .get("/login.html", async (ctx) => { await ctx.send({ path: ctx.request.url.pathname, root: "./public" }); }) .get("/login.js", async (ctx) => { await ctx.send({ path: ctx.request.url.pathname, root: "./public" }); }) .post("/api/login", async (ctx) => { const body = await ctx.request.body.json() as LoginReq; if ( typeof body.username !== "string" || typeof body.password !== "string" ) { return respond(ctx, 400, { ok: false, msg: "malformed request" }); } const user = await db.userWhereUsername(body.username); if (!user) { return respond(ctx, 401, { ok: false, msg: "bad creds1" }); } if (!await bcrypt.compare(body.password, user.passwordHash)) { return respond(ctx, 401, { ok: false, msg: "bad creds2" }); } await db.deleteSessionsWithUsername(user.username); const token = generateToken(); await db.createSession({ token, userId: user.id, username: user.username, }); await ctx.cookies.set("Authorization", token, { sameSite: "strict" }); return respond(ctx, 200, { ok: true }); }); function authMiddleware(): oak.Middleware { return async (ctx, next) => { const token = await ctx.cookies.get("Authorization"); if (!token || !await db.sessionWhereToken(token)) { const path = encodeURIComponent(ctx.request.url.pathname); ctx.response.redirect(`/login.html?refer=${path}`); return; } await next(); }; } async function listFilesInFolder(folderPath: string): Promise { if (!/^\/files\//.test(folderPath)) { throw new Error("malformed path"); } folderPath = folderPath.replace(/^\/files\//, "./files/"); const command = new Deno.Command("ls", { args: ["-1F", "--group-directories-first", folderPath], stdout: "piped", }); const output = await command.output(); const text = new TextDecoder().decode(output.stdout); return text .split("\n") .filter((v) => v); } function listPage(folderPath: string, files: string[]): string { const filesHtml = files.map((filename) => /*html*/ `
  • ${filename}
  • `).join("\n"); return /*html*/ ` Bunker ${folderPath}

    Home

    ${folderPath}

    • ..
    • ${filesHtml}
    `.replace(/^ {8}/gm, ""); } const authRouter = new oak.Router(); new oak.Application() .use(publicRouter.routes()) .use(publicRouter.allowedMethods()) .use(authMiddleware(), authRouter.routes()) .use(authMiddleware(), authRouter.allowedMethods()) .use(authMiddleware(), async (ctx) => { const { request: { url: { pathname } } } = ctx; if (pathname.startsWith("/files/")) { const name = decodeURIComponent(pathname); const info = await Deno.stat( name.replace(/^\/files\//, "files/"), ); if (name.endsWith("/") || info.isDirectory) { const files = await listFilesInFolder(name); ctx.response.type = "text/html"; ctx.response.body = listPage(name, files); ctx.response.status = 200; ctx.respond = true; return; } await oak.send( ctx, name .replace(/^\/files\//, "") .replaceAll("..", "."), { root: "./files", }, ); return; } await oak.send(ctx, pathname, { root: "./public", index: "index.html", }); }) .listen({ port: 8000 });