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{ AccessDenied, BrokenPipe, ColumnNotFound, ConnectionResetByPeer, ConnectionTimedOut, DeviceBusy, DiskQuota, FileTooBig, InputOutput, InvalidArgument, InvalidCharacter, InvalidLength, InvalidRequestMethod, IsDir, LockViolation, Malformatted, NetNameDeleted, NoSpaceLeft, NotOpenForReading, NotOpenForWriting, OperationAborted, OutOfMemory, PqError, SocketNotConnected, StreamTooLong, SystemResources, Unexpected, WouldBlock, Overflow, EndOfStream, }; 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 { const wtr = std.io.getStdOut().writer(); if (self.status.phrase()) |phrase| { try std.fmt.format(wtr, "status: {} {s}\r\n", .{ @intFromEnum(self.status), phrase }); } else { try std.fmt.format(wtr, "status: {}\r\n", .{@intFromEnum(self.status)}); } for (self.headers._internal.items) |tup| { try wtr.writeAll(tup.key); try wtr.writeAll(": "); try wtr.writeAll(tup.val); try wtr.writeAll("\r\n"); } try wtr.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.posix.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.posix.getenv("REQUEST_METHOD") orelse "GET") orelse { return error.InvalidRequestMethod; }, .target = std.posix.getenv("REQUEST_URI") orelse "/", }; var res = Response{ .allocator = allocator, .request = req, .status = .ok, .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 = mustache.parseText(res.allocator, rr, .{}, .{ .copy_strings = false }) catch unreachable; 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.success, data{ .comments = comments.items, }, res.writer()); try res.finish(); } fn post_comment(res: *Response, ctx: Ctx, _: Params) Err!void { const cl = if (std.posix.getenv("CONTENT_LENGTH")) |clh| try std.fmt.parseInt(usize, clh, 10) else { return error.InvalidLength; }; const body = try res.allocator.alloc(u8, cl); defer res.allocator.free(body); try res.reader().readNoEof(body); 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 { std.log.err("couldn't parse Form", .{}); 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)) { std.log.err("bad capcha answer {s} expected {s}", .{ form_val.capcha_answer, ans }); 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 = mustache.parseText(res.allocator, rr, .{}, .{ .copy_strings = false }) catch unreachable; const Data = struct { url: []const u8, author: []const u8, comment: []const u8 }; const notification = try mustache.allocRender(res.allocator, tt.success, Data{ .url = form_val.url, .author = form_val.author, .comment = form_val.comment, }); const smtp_username = std.posix.getenv("SMTP_USERNAME") orelse "comments@mfashby.net"; const smtp_password = std.posix.getenv("SMTP_PASSWORD") orelse "foobar"; const notification_address = std.posix.getenv("NOTIFICATION_ADDRESS") orelse "martin@mfashby.net"; const smtp_server = std.posix.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 = mustache.parseText(res.allocator, rr, .{}, .{ .copy_strings = false }) catch unreachable; 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.success, data{ .capcha_id = capcha.id, .capcha_question = capcha.question, .url = url, }, res.writer()); try res.finish(); }