add (messy) file upload

This commit is contained in:
sample-text-here 2022-01-06 04:35:40 -08:00
parent 522ff0121f
commit 7089b6f39e
17 changed files with 159 additions and 32 deletions

View file

@ -23,9 +23,10 @@ app.use((req, _, next) => {
// load context
const ctx = {};
ctx.db = await load("./server/database.js", log);
ctx.sessions = await load("./server/sessions.js", log);
ctx.log = log;
ctx.db = await load("./server/database.js", log);
ctx.sessions = await load("./server/sessions.js");
ctx.files = await load("./server/files.js", ctx, { maxSize: 1000 * 1000 * 20 });
// load routes
for(let i of await fs.readdir("routes")) {

View file

@ -10,6 +10,7 @@
"type": "module",
"license": "ISC",
"dependencies": {
"busboy": "^1.3.0",
"cookie-parser": "^1.4.6",
"express": "^4.17.2",
"express-handlebars": "^6.0.2",

View file

@ -2,6 +2,7 @@ lockfileVersion: 5.3
specifiers:
'@types/express': ^4.17.13
busboy: ^1.3.0
cookie-parser: ^1.4.6
express: ^4.17.2
express-handlebars: ^6.0.2
@ -11,6 +12,7 @@ specifiers:
sqlite3: ^5.0.2
dependencies:
busboy: 1.3.0
cookie-parser: 1.4.6
express: 4.17.2
express-handlebars: 6.0.2
@ -212,6 +214,13 @@ packages:
ieee754: 1.2.1
dev: false
/busboy/1.3.0:
resolution: {integrity: sha512-ytF8pdwEKCNwl0K9PSwmv+yPcicy+ef+YNAw+L0FTfyBLzCWhp5V3jEfau2kb5A0JD0TkOPrdtdCKLoAHlMu1A==}
engines: {node: '>=10.16.0'}
dependencies:
streamsearch: 1.1.0
dev: false
/bytes/3.1.1:
resolution: {integrity: sha512-dWe4nWO/ruEOY7HkUJ5gFt1DCFV9zPRoJr8pV0/ASQermOZjtq8jMjOprC0Kd10GLN+l7xaUPvxzJFWtxGu8Fg==}
engines: {node: '>= 0.8'}
@ -1444,6 +1453,11 @@ packages:
engines: {node: '>= 0.6'}
dev: false
/streamsearch/1.1.0:
resolution: {integrity: sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==}
engines: {node: '>=10.0.0'}
dev: false
/string-width/1.0.2:
resolution: {integrity: sha1-EYvfW4zcUaKn5w0hHgfisLmxB9M=}
engines: {node: '>=0.10.0'}

View file

@ -27,3 +27,7 @@ p {
margin: 0.5em 0;
}
img {
max-width: 100%;
}

View file

@ -1,6 +1,6 @@
/* the main css stylesheet for everything */
@import "/static/form.css";
@import "/static/inline.css";
@import "/static/content.css";
@import "/static/misc.css";
:root {
@ -31,7 +31,8 @@ body {
header, .center {
max-width: 700px;
margin: 0 auto;
padding: 0 2em;
padding: 0 2rem;
padding: 0 2rem;
}
.error {

View file

@ -29,6 +29,7 @@ export default (app, { log, db, sessions }) => {
const hash = genHash(password + salt);
const userId = await db("users").insert({ username, createdAt: new Date() });
await db("passwords").insert({ userId, password: hash, salt });
await db("log").insert({ createdAt: new Date(), type: "user.new", creator: userId });
// celebrate with cookies!
log.info(`new user! welcome, ${username}!`);

View file

@ -27,6 +27,7 @@ export default (app, { log, db, sessions }) => {
const sess = sessions.from(req);
if(!sess) return res.redirect("/login");
await db("users").update({ about: req.body.about }).where("userId", sess);
await db("log").insert({ createdAt: new Date(), type: "user.about", creator: sess, data: req.body.about });
log.debug(`update ${req.cookies.username}'s about`);
res.redirect("/config");
}
@ -79,6 +80,7 @@ export default (app, { log, db, sessions }) => {
// update user
await db("users").delete().where("userId", sess);
await db("passwords").delete().where("userId", sess);
await db("log").insert({ createdAt: new Date(), type: "user.death", creator: sess });
// cry in the corner (but with cookies...)
log.info(`farewell, ${req.cookies.username}`);

View file

@ -1,26 +1,10 @@
import { render, getUser, getPost } from "../server/posts.js";
export default (app, { log, db, sessions }) => {
app.post("/create", create);
export default (app, { db, files }) => {
app.get("/post/:id", routePost, cantFind);
app.get("/raw/:id", routePostRaw, cantFind);
async function create(req, res) {
const userId = sessions.get(req.cookies.session);
if(!userId) return res.redirect("/login");
const post = req.body;
const [id] = await db("posts").insert({
createdAt: new Date(),
title: post.title || "unnamed",
body: post.body || "",
author: userId,
});
log.info(`created post #${id}, by ${req.cookies.username}`);
log.debug(`redirecting to info /post/${id}...`);
res.redirect(`/post/${id}`);
}
app.get("/media/:hash", routeMedia, cantFind);
app.get("/media/:hash/*", routeMedia, cantFind);
async function routePost(req, res, next) {
const post = await getPost(db, req.params.id);
@ -34,6 +18,12 @@ export default (app, { log, db, sessions }) => {
res.contentType("text/markdown").send(post.body);
}
async function routeMedia(req, res, next) {
const file = await files.get(req.params.hash).catch(() => null);
if(!file) return next();
file.pipe(res);
}
async function cantFind(_, res) {
res.render("404.html", { title: "bantiose::404" });
}

50
routes/create.js Normal file
View file

@ -0,0 +1,50 @@
import busboy from "busboy";
// TODO: this code is messy! redo it
async function parse(req, files) {
const bb = busboy({ headers: req.headers, limits: { files: 1 } });
return new Promise((res) => {
const post = {};
let hasFile = false;
bb.on("field", (key, val) => post[key] = val);
bb.on("file", async (_name, file, info) => {
hasFile = true;
res({
post,
file: {
attachHash: await files.insert(file),
attachName: info.filename,
attachType: info.mimeType,
},
});
});
bb.on("close", () => {
if(!hasFile) res({ post });
});
req.pipe(bb);
});
}
export default (app, { log, db, files, sessions }) => {
app.post("/create", create);
async function create(req, res) {
const userId = sessions.get(req.cookies.session);
if(!userId) return res.redirect("/login");
const { post, file } = await parse(req, files);
const [id] = await db("posts").insert({
createdAt: new Date(),
title: post.title || "unnamed",
body: post.body || "",
author: userId,
...(file ?? {})
});
await db("log").insert({ createdAt: new Date(), type: "post.new", creator: userId, data: id });
log.info(`created post #${id}, by ${req.cookies.username}`);
log.debug(`redirecting to info /post/${id}...`);
res.redirect(`/post/${id}`);
}
};

View file

@ -25,11 +25,9 @@ async function init(schema, log) {
table.integer("author").unsigned();
table.string("title");
table.string("body");
}).createTable("media", table => {
// for the file store
table.string("filename");
table.string("hash");
table.integer("filesize").unsigned();
table.string("attachHash");
table.string("attachName");
table.string("attachType");
}).createTable("log", table => {
// log of everything that happens
table.increments("auditId");
@ -42,7 +40,6 @@ async function init(schema, log) {
export default async (log) => {
if(!fs.existsSync(".data")) fs.mkdirSync(".data");
if(!fs.existsSync(".data/files")) fs.mkdirSync(".data/files");
const db = knex({
useNullAsDefault: true,

52
server/files.js Normal file
View file

@ -0,0 +1,52 @@
import fs from "fs/promises";
import path from "path";
import crypto from "crypto";
class Filestore {
where = path.join(process.cwd(), ".data");
constructor(log, db, options) {
this.log = log;
this.db = db;
this.options = options;
}
async insert(stream) {
let hash = crypto.createHash("sha256");
let size = 0;
const tmpName = path.join(this.where, "tmp", crypto.randomUUID());
const tmpFd = await fs.open(tmpName, "w");
const tmp = tmpFd.createWriteStream(tmpName);
return new Promise((res, rej) => {
stream.on("data", (data) => {
size += data.length;
if(size >= this.options.maxSize) rej("file too big");
hash.update(data);
tmp.write(data);
});
stream.on("end", async () => {
const h = hash.digest();
await tmpFd.close();
await fs.rename(tmpName, path.join(this.where, "files", h.toString("hex")));
this.log.info("saved file with hash " + h.toString("hex"));
res(h);
});
});
}
async get(hash) {
const fd = await fs.open(path.join(this.where, "files", hash));
return fd.createReadStream();
}
}
async function exists(where) {
return fs.access(where).then(() => true).catch(() => false);
}
export default async function(ctx, options) {
if(!await exists(".data/files")) await fs.mkdir(".data/files");
if(!await exists(".data/tmp")) await fs.mkdir(".data/tmp");
return new Filestore(ctx.log, ctx.db, options);
}

View file

@ -26,8 +26,18 @@ export function render(post, author, trim = false) {
time: date,
timefmt: format(date),
author: author?.username ?? "unknown...",
attachment: attachment(post),
};
}
// TODO: add support for other content types
export function attachment(post) {
switch(post.attachType?.split("/")[0]) {
case "image":
return `<img src="/media/${post.attachHash.toString("hex")}/${escape(post.attachName)}" alt="main image" class="attachment" />`;
default:
return null;
}
}
const units = [

View file

@ -26,5 +26,5 @@ export class Sessions extends Map {
}
// the default session manager
export default () => new Sessions(1000 * 30);
export default () => new Sessions();

View file

@ -1,10 +1,12 @@
<header>
<h1>new post</h1>
</header>
<form method="POST" action="/create" class="center">
<form method="POST" action="/create" class="center" enctype="multipart/form-data">
<label for="title">title</label><br />
<input required name="title" type="text" placeholder="sample text here..." /><br />
<label for="body">content</label><br />
<textarea name="body" placeholder="wow look cool even more sample text" /></textarea><br />
<label for="attachment">attachment</label><br />
<input type="file" name="attachment" /><br />
<input type="submit" value="create" />
</form>

View file

@ -1,8 +1,8 @@
<header>
<h1>welcome to bantiose</h1>
{{#user}}
<a href="/config">config</a>
<a href="/create">new post</a>
<a href="/config">config</a>
{{/user}}
{{^user}}
<a href="/login">login</a>

View file

@ -2,4 +2,5 @@
<h2><a href="/post/{{id}}">{{title}}</a></h2>
<div class="fineprint"><i><time datetime="{{time}}">{{timefmt}}</time> - by {{author}}</i></div>
<p>{{{body}}}</p>
{{#attachment}}{{{this}}}{{/attachment}}
</div>

View file

@ -1,5 +1,6 @@
<main class="center">
<h1>{{title}}</h1>
<div class="fineprint"><i><time datetime="{{time}}">{{timefmt}}</time> - by {{author}}</i></div>
{{#attachment}}{{{this}}}{{/attachment}}
<p>{{{body}}}</p>
</main>