const Io = std.Io; const pad = @import("pad"); const sqlite = @import("sqlite"); const std = @import("std"); const httpz = @import("httpz"); const origin = "http://localhost:8000"; const db_file_path = "/m/pad.db"; const token_expires = 24 * 60 * 60 * 365; const del_session_cookie = "Session=; Path=/; Max-Age=0; HttpOnly; Secure; Partitioned; SameSite=Lax; Expires=Thu, 01 Jan 1970 00:00:00 GMT"; pub fn main(init: std.process.Init) !void { const allocator = init.gpa; var signup_code: []const u8 = ""; const signup_code_env = init.environ_map.get("PAD_SIGNUP_CODE"); if (signup_code_env) |code| { signup_code = code; } var db = try sqlite.Db.init(.{ .mode = sqlite.Db.Mode{ .File = db_file_path }, .open_flags = .{ .write = true, .create = true, }, .threading_mode = .MultiThread, }); defer db.deinit(); try db.exec(pad.sqlSchemaWriter, .{}, .{}); try db.exec(pad.sqlSchemaSession, .{}, .{}); try db.exec(pad.sqlSchemaPad, .{}, .{}); var app = App{ .db = db, .io = init.io, .signup_code = signup_code, }; var server = try httpz.Server(*App).init(init.io, allocator, .{ .address = .localhost(8000), .request = .{ .max_form_count = 10, }, }, &app); _ = try server.middleware(httpz.middleware.Cors, .{ .origin = "http://localhost:8000", .credentials = "true", .methods = "GET,POST", }); var router = try server.router(.{}); router.get("", viewOwnPads, .{}); router.get("/", viewOwnPads, .{}); router.get("/login", login, .{}); router.post("/login", doLogin, .{}); router.get("/logout", logOutAndSendToLoginHandler, .{}); router.get("/signup", login, .{}); router.post("/signup", doSignup, .{}); router.get("/deleteaccount", deleteAccountPage, .{}); router.post("/deleteaccount", deleteAccount, .{}); router.get("/p/:slug", getPad, .{}); router.get("/a", viewOwnPads, .{}); router.get("/newpad", newPadPage, .{}); router.post("/newpad", newPad, .{}); router.post("/a/:slug/delete", deleteOwnPad, .{}); std.debug.print("listening on :8000\n", .{}); try server.listen(); } const App = struct { db: sqlite.Db, io: std.Io, signup_code: []const u8, }; fn deletePad(app: *App, slug: []const u8) !void { try app.db.exec("DELETE FROM pad WHERE slug = $slug{[]const u8}", .{}, .{ .slug = slug, }); } fn deleteOwnPad(app: *App, req: *httpz.Request, res: *httpz.Response) !void { const userExpires = middlewareAuthOrLogout(app, req, res) catch return; const slug = req.param("slug").?; try app.db.exec("DELETE FROM pad WHERE slug = $slug{[]const u8} AND writer_username = $username{[]const u8}", .{}, .{ .slug = slug, .username = userExpires.username, }); goHome(res); } fn goHome(res: *httpz.Response) void { res.status = 303; res.header("Content-Type", "text/html; charset=utf-8"); res.header("Location", origin ++ "/a"); } fn goLogin(res: *httpz.Response) void { res.status = 303; res.header("Content-Type", "text/html; charset=utf-8"); res.header("Location", origin ++ "/login"); } fn goSlug(res: *httpz.Response, slug: []const u8) void { res.status = 303; res.header("Content-Type", "text/html; charset=utf-8"); res.header("Location", slug); } fn setPadViewsLeft(app: *App, slug: []const u8, views_left: usize) !void { try app.db.exec("UPDATE pad SET VIEWS_LEFT = $views_left{usize} WHERE slug = $slug{[]const u8}", .{}, .{ .views_left = views_left, .slug = slug, }); } fn getPad(app: *App, req: *httpz.Request, res: *httpz.Response) !void { const slug = req.param("slug").?; const pad_opt = try app.db.oneAlloc(pad.Pad, res.arena, "SELECT slug, content, basic_hash, views_left, delete_at, writer_username FROM pad WHERE slug = $slug{[]const u8}", .{}, .{ .slug = slug, }); if (pad_opt == null) { res.status = 404; res.body = "404 Not found"; return; } const record = pad_opt.?; res.body = try std.fmt.allocPrint(res.arena, "{s}", .{record.content}); const new_views = @as(i32, @intCast(record.views_left)) - 1; if (new_views == 0) { try deletePad(app, slug); } else if (new_views > 0) { try setPadViewsLeft(app, slug, @as(usize, @intCast(new_views))); } } fn logOutAndSendToLogin(app: *App, req: *httpz.Request, res: *httpz.Response) void { var cookies = req.cookies(); if (cookies.get("Session")) |auth| { if (!std.mem.eql(u8, auth, "")) { app.db.exec("DELETE FROM session WHERE token = $token{[]const u8}", .{}, .{ .token = auth, }) catch return; } res.header("Set-Cookie", del_session_cookie); } goLogin(res); } fn logOutAndSendToLoginHandler(app: *App, req: *httpz.Request, res: *httpz.Response) !void { logOutAndSendToLogin(app, req, res); } const UserExpires = struct { expires: usize, username: []const u8, }; fn viewOwnPads(app: *App, req: *httpz.Request, res: *httpz.Response) !void { const userExpires = middlewareAuthOrLogout(app, req, res) catch return; var getPadsSql = try app.db.prepare("SELECT slug, content, basic_hash, views_left, delete_at, writer_username FROM pad WHERE writer_username = $username{[]const u8}"); defer getPadsSql.deinit(); const user_pads = try getPadsSql.all(pad.Pad, res.arena, .{}, .{ .username = userExpires.username, }); res.body = try pad.hAdminPage(res.arena, user_pads); } fn newPadPage(app: *App, req: *httpz.Request, res: *httpz.Response) !void { _ = middlewareAuthOrLogout(app, req, res) catch return; res.body = pad.hNewPadPage; } fn newPad(app: *App, req: *httpz.Request, res: *httpz.Response) !void { const userExpires = try middlewareAuthOrLogout(app, req, res); const slug = try pad.formOrBad(req, res, "slug", false); const content = try pad.formOrBad(req, res, "content", false); const pad_user = try pad.formOrBad(req, res, "username", true); const pad_pass = try pad.formOrBad(req, res, "password", true); const views_left_str = try pad.formOrBad(req, res, "views_left", true); const delete_at_str = try pad.formOrBad(req, res, "delete_at", true); const views_left = try std.fmt.parseInt(usize, views_left_str, 10); const delete_at = try std.fmt.parseInt(usize, delete_at_str, 10); std.debug.print( \\new form submission: \\slug: {s} \\content: {s} \\username: {any} \\password: {any} \\views_left: {d} \\delete_at: {d} , .{ slug, content, pad_user.len > 0, pad_pass.len > 0, views_left, delete_at, }); var basic_hash: []const u8 = ""; if (!std.mem.eql(u8, pad_user, "") or !std.mem.eql(u8, pad_pass, "")) { basic_hash = try std.fmt.allocPrint(res.arena, "{s}:{s}", .{ pad_user, pad_pass }); } try app.db.exec( \\INSERT INTO pad (slug, content, basic_hash, views_left, delete_at, writer_username) VALUES \\($slug{[]const u8}, $content{[]const u8}, $basic_hash{[]const u8}, \\$views_left{usize}, $delete_at{usize}, $writer_username{[]const u8}); , .{}, .{ .slug = slug, .content = content, .basic_hash = basic_hash, .views_left = views_left, .delete_at = delete_at, .writer_username = userExpires.username, }); goHome(res); // goSlug(res, try std.fmt.allocPrint(res.arena, "/a/{s}", .{slug})); } fn deleteAccountPage(_: *App, _: *httpz.Request, res: *httpz.Response) !void { res.body = pad.deleteAccountForm; } fn deleteAccount(app: *App, req: *httpz.Request, res: *httpz.Response) !void { const userExpires = try middlewareAuthOrLogout(app, req, res); try app.db.exec("DELETE FROM writer WHERE username = $username{[]const u8}", .{}, .{ .username = userExpires.username, }); // sqlite version may or may not honor delete cascades. try app.db.exec("DELETE FROM session WHERE writer_username = $username{[]const u8}", .{}, .{ .username = userExpires.username, }); try app.db.exec("DELETE FROM pad WHERE writer_username = $username{[]const u8}", .{}, .{ .username = userExpires.username, }); res.header("Set-Cookie", del_session_cookie); goLogin(res); } const AuthError = error{ NoRecord, NoCookie, }; fn middlewareAuthOrLogout(app: *App, req: *httpz.Request, res: *httpz.Response) !UserExpires { var cookies = req.cookies(); if (cookies.get("Session")) |auth| { const userExpires = try app.db.oneAlloc(UserExpires, res.arena, "SELECT s.expires, s.writer_username AS username FROM session s WHERE s.token = $token{[]const u8}", .{}, .{ .token = auth, }); if (userExpires == null) { logOutAndSendToLogin(app, req, res); return AuthError.NoRecord; } return userExpires.?; } else { logOutAndSendToLogin(app, req, res); return AuthError.NoCookie; } } fn login(app: *App, req: *httpz.Request, res: *httpz.Response) !void { var cookies = req.cookies(); if (cookies.get("Session")) |auth| { const userExpires = try app.db.oneAlloc(UserExpires, res.arena, "SELECT s.expires, c.username FROM session s WHERE s.token = $token{[]const u8}", .{}, .{ .token = auth, }); if (userExpires != null) { goHome(res); return; } } res.status = 200; res.header("Content-Type", "text/html; charset=utf-8"); var form: []const u8 = pad.loginForm; if (std.mem.eql(u8, req.url.path, "/signup")) { form = pad.signupForm; if (!std.mem.eql(u8, app.signup_code, "")) { form = pad.signupFormCode; } } res.body = form; } fn doLogin(app: *App, req: *httpz.Request, res: *httpz.Response) !void { const fd = try req.formData(); const mUsername = fd.get("username"); const mPassword = fd.get("password"); if (mUsername == null or std.mem.eql(u8, mUsername.?, "") or mPassword == null or std.mem.eql(u8, mPassword.?, "")) { res.status = 400; res.body = try std.fmt.allocPrint(res.arena, "{s}", .{pad.h400Login("logging in")}); return; } const username = mUsername.?; const password = mPassword.?; std.debug.print("user login attempt with {s}: {d}\n", .{ username, password.len }); // TODO: hash password const sqlResult = try app.db.one(usize, "SELECT COUNT(*) FROM writer WHERE username = $username{[]const u8} AND password = $password{[]const u8};", .{}, .{ .username = username, .password = password, }); if (sqlResult == 0) { res.status = 400; res.body = try std.fmt.allocPrint(res.arena, "{s}", .{pad.h400Login("logging in")}); return; } const random = getRandom(app.io); const token: []u8 = randomString(random, res.arena, 32); const token_const: []const u8 = token; const expires: usize = @as(usize, @intCast(std.Io.Timestamp.now(app.io, .real).toSeconds())) + token_expires; std.debug.print("vals {s} {s} {d}\n", .{ username, token, expires }); try app.db.exec("INSERT INTO session (writer_username, token, expires) VALUES ($username{[]const u8}, $token{[]const u8}, $expires{usize});", .{}, .{ .username = username, .token = token_const, .expires = expires, }); try res.setCookie("Session", token, .{ .path = "/", .max_age = token_expires, .secure = true, .http_only = true, .partitioned = true, .same_site = .lax, // or .none, or .strict (or null to leave out) }); goHome(res); } fn doSignup(app: *App, req: *httpz.Request, res: *httpz.Response) !void { const fd = try req.formData(); const mUsername = fd.get("username"); const mPassword = fd.get("password"); if (mUsername == null or std.mem.eql(u8, mUsername.?, "") or mPassword == null or std.mem.eql(u8, mPassword.?, "")) { res.status = 400; res.body = try std.fmt.allocPrint(res.arena, "{s}", .{pad.h400Login("signing up")}); return; } if (!std.mem.eql(u8, app.signup_code, "")) { const mCode = fd.get("code"); if (mCode == null or !std.mem.eql(u8, app.signup_code, mCode.?)) { std.debug.print("user signup attempt with {s}:{s}", .{ app.signup_code, mCode.? }); res.status = 400; res.body = try std.fmt.allocPrint(res.arena, "{s}", .{pad.h400Login("signing up")}); return; } } const username = mUsername.?; const password = mPassword.?; std.debug.print("user signup attempt with {s}: {d}", .{ username, password.len }); // TODO: hash password try app.db.exec("INSERT INTO writer (username, password) VALUES ($username{[]const u8}, $password{[]const u8});", .{}, .{ .username = username, .password = password, }); const random = getRandom(app.io); const token: []u8 = randomString(random, res.arena, 32); const token_const: []const u8 = token[0..]; const expires: usize = 1234567890; try app.db.exec("INSERT INTO session (writer_username, token, expires) VALUES ($username{[]const u8}, $token{[]const u8}, $expires{usize});", .{}, .{ .username = username, .token = token_const, .expires = expires, }); try res.setCookie("Session", token_const, .{ .path = "/", .max_age = token_expires, .secure = true, .http_only = true, .partitioned = true, .same_site = .lax, // or .none, or .strict (or null to leave out) }); goHome(res); } pub fn getRandom(io: std.Io) std.Random { var seed: u64 = undefined; io.random(std.mem.asBytes(&seed)); var rand = std.Random.DefaultPrng.init(seed); return rand.random(); } pub fn randomString(random: std.Random, a: std.mem.Allocator, max: usize) []u8 { var buf = a.alloc(u8, max + 1) catch unreachable; const buf_len = buf.len; const valid = "abcdefghijklmnopqrstuvwxyz0123456789"; for (0..buf_len) |i| { const letter_idx = random.uintAtMost(usize, valid.len - 1); buf[i] = valid[letter_idx]; } buf[buf.len - 1] = 'a'; return buf; }