const std = @import("std"); const zws = @import("zws"); const pq = @import("pq"); const mustache = @import("mustache"); const smtp = @import("smtp"); const Params = zws.Params; const Err = error{ Unexpected, AccessDenied, OutOfMemory, InputOutput, SystemResources, IsDir, OperationAborted, BrokenPipe, ConnectionResetByPeer, ConnectionTimedOut, NotOpenForReading, NetNameDeleted, WouldBlock, StreamTooLong, Malformatted, InvalidLength, InvalidCharacter, NoSpaceLeft, PqError, ColumnNotFound, DiskQuota, FileTooBig, DeviceBusy, InvalidArgument, NotOpenForWriting, LockViolation, InvalidRequestMethod, }; const Ctx = struct { db: pq.Db, pub fn clone(self: @This()) Ctx { return Ctx{ .db = self.db, }; } pub fn deinit(self: @This()) void { _ = self; } }; var gpa = std.heap.GeneralPurposeAllocator(.{}){}; const Request = struct { method: std.http.Method, target: []const u8, }; const ResponseTransfer = union(enum) { content_length: u64, chunked: void, none: void, }; const Headers = struct { const Entry = struct { key: []const u8, val: []const u8 }; _internal: std.ArrayList(Entry), fn init(allocator: std.mem.Allocator) Headers { return .{ ._internal = std.ArrayList(Entry).init(allocator) }; } fn append(self: *@This(), key: []const u8, val: []const u8) !void { try self._internal.append(.{ .key = key, .val = val }); } }; const Response = struct { allocator: std.mem.Allocator, request: Request, // TODO other fields and writer function to write headers and body to stdout status: std.http.Status, transfer_encoding: ResponseTransfer, headers: Headers, fn reader(_: @This()) std.fs.File.Reader { return std.io.getStdIn().reader(); } fn do(self: @This()) !void { for (self.headers._internal.items) |tup| { try std.io.getStdOut().writeAll(tup.key); try std.io.getStdOut().writeAll(": "); try std.io.getStdOut().writeAll(tup.val); try std.io.getStdOut().writeAll("\r\n"); } try std.io.getStdOut().writeAll("\r\n"); } fn writer(_: @This()) std.fs.File.Writer { return std.io.getStdOut().writer(); } fn finish(_: @This()) !void { // TODO Write empty lines? or just do nothing } }; const Rtr = zws.Router(*Response, Ctx, Err); const router = Rtr{ .allocator = gpa.allocator(), .handlers = &[_]Rtr.Handler{ .{ .method = .GET, .pattern = "/api/comment", .handle_fn = get_comment, }, .{ .method = .POST, .pattern = "/api/comment", .handle_fn = post_comment, }, .{ .method = .GET, .pattern = "/api/form", .handle_fn = get_form, }, }, .notfound = notfound, }; /// Run as a CGI program! pub fn main() !void { const allocator = gpa.allocator(); const db_url = std.os.getenv("DATABASE_URL") orelse "postgresql://comments@localhost/comments"; var db = try pq.Db.init(db_url); // try db.exec(@embedFile("migrations/0_init.sql")); // try db.exec(@embedFile("migrations/1_capcha.sql")); defer db.deinit(); const req = Request{ .method = std.meta.stringToEnum(std.http.Method, std.os.getenv("REQUEST_METHOD") orelse "GET") orelse { return error.InvalidRequestMethod; }, .target = std.os.getenv("REQUEST_URI") orelse "/", }; var res = Response{ .allocator = allocator, .request = req, .status = .bad_request, .transfer_encoding = .none, .headers = Headers.init(allocator), }; const ctx = Ctx{ .db = db }; try router.handle(&res, ctx); } fn notfound(res: *Response, _: Ctx) Err!void { const rr = @embedFile("templates/notfound.html"); try constresponse(res, rr, .not_found); } fn badrequest(res: *Response, _: Ctx) Err!void { const rr = @embedFile("templates/badrequest.html"); try constresponse(res, rr, .bad_request); } fn constresponse(res: *Response, rr: []const u8, status: std.http.Status) Err!void { res.status = status; res.transfer_encoding = .{ .content_length = rr.len }; try res.headers.append("content-type", "text/html"); try res.do(); try res.writer().writeAll(rr); try res.finish(); } fn get_comment(res: *Response, ctx: Ctx, _: Params) Err!void { var p = try zws.Path.parse(res.allocator, res.request.target); defer p.deinit(); const url: []const u8 = try p.get_query_param("url") orelse { try badrequest(res, ctx); return; }; const Comment = struct { author: []const u8, comment: []const u8, ts: []const u8, }; var comments = std.ArrayList(Comment).init(res.allocator); var stmt = try ctx.db.prepare_statement(res.allocator, \\ select author,comment,ts from comments where url = $1 order by ts ); defer stmt.deinit(); try stmt.bind(0, url); while (try stmt.step()) { const cmt = try stmt.read_struct(Comment); try comments.append(cmt); } const rr = @embedFile("templates/comments.html"); const tt = comptime mustache.parseComptime(rr, .{}, .{}); res.transfer_encoding = .chunked; try res.headers.append("content-type", "text/html"); try res.do(); const data = struct { comments: []const Comment, }; try mustache.render(tt, data{ .comments = comments.items, }, res.writer()); try res.finish(); } fn post_comment(res: *Response, ctx: Ctx, _: Params) Err!void { var body_aa = std.ArrayList(u8).init(res.allocator); try res.reader().readAllArrayList(&body_aa, 1_000_000); var body = try body_aa.toOwnedSlice(); var form = try zws.Form.parse(res.allocator, body); const Form = struct { url: []const u8, capcha_id: []const u8, author: []const u8, comment: []const u8, capcha_answer: []const u8, }; const form_val = form.form_to_struct(Form) catch { try badrequest(res, ctx); return; }; // Validate the capcha { var stmt = try ctx.db.prepare_statement(res.allocator, "select answer from capchas where id = $1"); defer stmt.deinit(); try stmt.bind(0, form_val.capcha_id); if (!try stmt.step()) { std.log.err("missing capcha_id {s}", .{form_val.capcha_id}); try badrequest(res, ctx); return; } const ans = try stmt.read_column(0, []const u8); if (!std.mem.eql(u8, ans, form_val.capcha_answer)) { try constresponse(res, @embedFile("templates/capchainvalid.html"), std.http.Status.unauthorized); return; } } // Add the comment... { var stmt = try ctx.db.prepare_statement(res.allocator, "insert into comments(url,author,comment) values($1, $2, $3)"); defer stmt.deinit(); try stmt.bind(0, form_val.url); try stmt.bind(1, form_val.author); try stmt.bind(2, form_val.comment); _ = try stmt.step(); } // Send me an email const rr = @embedFile("templates/notification.txt"); const tt = comptime mustache.parseComptime(rr, .{}, .{}); const Data = struct { url: []const u8, author: []const u8, comment: []const u8 }; const notification = try mustache.allocRender(res.allocator, tt, Data{ .url = form_val.url, .author = form_val.author, .comment = form_val.comment, }); const smtp_username = std.os.getenv("SMTP_USERNAME") orelse "comments@mfashby.net"; const smtp_password = std.os.getenv("SMTP_PASSWORD") orelse "foobar"; const notification_address = std.os.getenv("NOTIFICATION_ADDRESS") orelse "martin@mfashby.net"; const smtp_server = std.os.getenv("SMTP_SERVER") orelse "mail.mfashby.net:587"; smtp.send_mail(res.allocator, smtp_server, .{ .user = smtp_username, .pass = smtp_password }, smtp_username, &[_][]const u8{notification_address}, notification) catch |err| { std.log.err("failed to send notification email {}", .{err}); }; // And redirect! res.transfer_encoding = .none; res.status = .found; try res.headers.append("location", form_val.url); try res.do(); try res.finish(); } fn get_form(res: *Response, ctx: Ctx, _: Params) Err!void { var p = try zws.Path.parse(res.allocator, res.request.target); defer p.deinit(); const url: []const u8 = try p.get_query_param("url") orelse { try badrequest(res, ctx); return; }; var stmt = try ctx.db.prepare_statement(res.allocator, "select id, question from capchas order by random() limit 1"); defer stmt.deinit(); if (!try stmt.step()) { std.log.err("no capcha!", .{}); try badrequest(res, ctx); return; } const Capcha = struct { id: []const u8, question: []const u8, }; const capcha = try stmt.read_struct(Capcha); const rr = @embedFile("templates/form.html"); const tt = comptime mustache.parseComptime(rr, .{}, .{}); res.transfer_encoding = .chunked; try res.headers.append("content-type", "text/html"); try res.do(); // For some reason, mustache.render won't work with anonymous struct const data = struct { capcha_id: []const u8, capcha_question: []const u8, url: []const u8, }; try mustache.render(tt, data{ .capcha_id = capcha.id, .capcha_question = capcha.question, .url = url, }, res.writer()); try res.finish(); }