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;
}