diff options
Diffstat (limited to 'comments/src/main.zig')
-rw-r--r-- | comments/src/main.zig | 294 |
1 files changed, 294 insertions, 0 deletions
diff --git a/comments/src/main.zig b/comments/src/main.zig new file mode 100644 index 0000000..898b689 --- /dev/null +++ b/comments/src/main.zig @@ -0,0 +1,294 @@ +const std = @import("std"); +const zws = @import("zws"); +const pq = @import("pq.zig"); +const mustache = @import("mustache"); + +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(); + } + + // 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(); +} |