261 lines
8.2 KiB
TypeScript
261 lines
8.2 KiB
TypeScript
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<User | null>;
|
|
sessionWhereToken(token: string): Promise<Session | null>;
|
|
createSession(init: Omit<Session, "id">): Promise<Session>;
|
|
}
|
|
|
|
try {
|
|
await Deno.lstat("bunker.db");
|
|
} catch (error) {
|
|
if (!(error instanceof Deno.errors.NotFound)) {
|
|
throw error;
|
|
}
|
|
console.log("'bunker.db' not found, creating...");
|
|
const cmd = new Deno.Command("sqlite3", {
|
|
args: ["bunker.db", ".read database.sql"],
|
|
stdout: "piped",
|
|
stderr: "piped",
|
|
});
|
|
const cmdChild = cmd.spawn();
|
|
cmdChild.stdout.pipeTo(Deno.stdout.writable);
|
|
cmdChild.stderr.pipeTo(Deno.stderr.writable);
|
|
const status = await cmdChild.status;
|
|
if (!status.success) {
|
|
console.log("failed creating 'bunker.db', exiting...");
|
|
Deno.exit(1);
|
|
}
|
|
}
|
|
|
|
class SqliteDb implements Db {
|
|
private db = new sqlite.Database("bunker.db", { create: false });
|
|
|
|
public async userWhereUsername(username: string): Promise<User | null> {
|
|
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<Partial<User>>(
|
|
(acc, kv) => ({ ...acc, ...kv }),
|
|
{},
|
|
) as User;
|
|
}
|
|
public async sessionWhereToken(token: string): Promise<Session | null> {
|
|
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<Partial<User>>(
|
|
(acc, kv) => ({ ...acc, ...kv }),
|
|
{},
|
|
) as Session;
|
|
}
|
|
public async deleteSessionsWithUsername(username: string): Promise<void> {
|
|
await Promise.resolve();
|
|
this.db.exec("delete from sessions where username=?", username);
|
|
}
|
|
public async createSession(init: Omit<Session, "id">): Promise<Session> {
|
|
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<Partial<User>>(
|
|
(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<string[]> {
|
|
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*/ `
|
|
<li><a href="./${filename}">${filename}</a></li>
|
|
`).join("\n");
|
|
return /*html*/ `
|
|
<!DOCTYPE html>
|
|
<html>
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>Bunker ${folderPath}</title>
|
|
<head>
|
|
</head>
|
|
<body>
|
|
<p><a href="/">Home</a></p>
|
|
<p>${folderPath}</p>
|
|
<ul>
|
|
<li><a href="..">..</a></li>
|
|
${filesHtml}
|
|
</ul>
|
|
</body>
|
|
</html>
|
|
`.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: parseInt(Deno.env.get("PORT")!) });
|