mfashby.net

Website mfashby.net
git clone git://code.mfashby.net:/mfashby.net
Log | Files | Refs | Submodules | README

main.zig (10692B)


      1 const std = @import("std");
      2 const zws = @import("zws");
      3 const pg = @import("pg");
      4 const mustache = @import("mustache");
      5 const smtp = @import("smtp");
      6 
      7 const Params = zws.Params;
      8 
      9 const Err = error{
     10     AccessDenied,
     11     AlreadyConnected,
     12     BrokenPipe,
     13     Closed,
     14     ConnectionBusy,
     15     ConnectionResetByPeer,
     16     ConnectionTimedOut,
     17     DeviceBusy,
     18     DiskQuota,
     19     EndOfStream,
     20     FieldColumnMismatch,
     21     FileDescriptorNotASocket,
     22     FileTooBig,
     23     InputOutput,
     24     InvalidArgument,
     25     InvalidCharacter,
     26     InvalidDataRow,
     27     InvalidLength,
     28     InvalidProtocolOption,
     29     InvalidUUID,
     30     IsDir,
     31     LockViolation,
     32     Malformatted,
     33     NetworkSubsystemFailed,
     34     NoDevice,
     35     NoSpaceLeft,
     36     NotAString,
     37     NotOpenForReading,
     38     NotOpenForWriting,
     39     OperationAborted,
     40     OutOfMemory,
     41     Overflow,
     42     PermissionDenied,
     43     PG,
     44     SocketNotBound,
     45     SocketNotConnected,
     46     SystemResources,
     47     TimeoutTooBig,
     48     Unexpected,
     49     UnexpectedDBMessage,
     50     WouldBlock,
     51 };
     52 
     53 const Ctx = struct {
     54     db: *pg.Conn,
     55     pub fn clone(self: @This()) Ctx {
     56         return Ctx{
     57             .db = self.db,
     58         };
     59     }
     60     pub fn deinit(self: @This()) void {
     61         _ = self;
     62     }
     63 };
     64 
     65 var gpa = std.heap.GeneralPurposeAllocator(.{}){};
     66 const Request = struct {
     67     method: std.http.Method,
     68     target: []const u8,
     69 };
     70 const ResponseTransfer = union(enum) {
     71     content_length: u64,
     72     chunked: void,
     73     none: void,
     74 };
     75 const Headers = struct {
     76     const Entry = struct { key: []const u8, val: []const u8 };
     77     _internal: std.ArrayList(Entry),
     78 
     79     fn init(allocator: std.mem.Allocator) Headers {
     80         return .{ ._internal = std.ArrayList(Entry).init(allocator) };
     81     }
     82     fn append(self: *@This(), key: []const u8, val: []const u8) !void {
     83         try self._internal.append(.{ .key = key, .val = val });
     84     }
     85 };
     86 const Response = struct {
     87     allocator: std.mem.Allocator,
     88     request: Request,
     89     // TODO other fields and writer function to write headers and body to stdout
     90     status: std.http.Status,
     91     transfer_encoding: ResponseTransfer,
     92     headers: Headers,
     93     fn reader(_: @This()) std.fs.File.Reader {
     94         return std.io.getStdIn().reader();
     95     }
     96     fn do(self: @This()) !void {
     97         const wtr = std.io.getStdOut().writer();
     98         if (self.status.phrase()) |phrase| {
     99             try std.fmt.format(wtr, "status: {} {s}\r\n", .{ @intFromEnum(self.status), phrase });
    100         } else {
    101             try std.fmt.format(wtr, "status: {}\r\n", .{@intFromEnum(self.status)});
    102         }
    103         for (self.headers._internal.items) |tup| {
    104             try wtr.writeAll(tup.key);
    105             try wtr.writeAll(": ");
    106             try wtr.writeAll(tup.val);
    107             try wtr.writeAll("\r\n");
    108         }
    109         try wtr.writeAll("\r\n");
    110     }
    111     fn writer(_: @This()) std.fs.File.Writer {
    112         return std.io.getStdOut().writer();
    113     }
    114     fn finish(_: @This()) !void {
    115         // TODO Write empty lines? or just do nothing
    116     }
    117 };
    118 
    119 const Rtr = zws.Router(*Response, Ctx, Err);
    120 const router = Rtr{
    121     .allocator = gpa.allocator(),
    122     .handlers = &[_]Rtr.Handler{
    123         .{
    124             .method = .GET,
    125             .pattern = "/api/comment",
    126             .handle_fn = get_comment,
    127         },
    128         .{
    129             .method = .POST,
    130             .pattern = "/api/comment",
    131             .handle_fn = post_comment,
    132         },
    133         .{
    134             .method = .GET,
    135             .pattern = "/api/form",
    136             .handle_fn = get_form,
    137         },
    138     },
    139     .notfound = notfound,
    140 };
    141 
    142 /// Run as a CGI program!
    143 pub fn main() !void {
    144     const allocator = gpa.allocator();
    145     const db_url = std.posix.getenv("DATABASE_URL") orelse "postgresql://comments@localhost/comments";
    146     const uri = try std.Uri.parse(db_url);
    147     var db = try pg.Conn.openAndAuthUri(allocator, uri);
    148     defer db.deinit();
    149 
    150     // try db.exec(@embedFile("migrations/0_init.sql"));
    151     // try db.exec(@embedFile("migrations/1_capcha.sql"));
    152     const req = Request{
    153         .method = std.meta.stringToEnum(std.http.Method, std.posix.getenv("REQUEST_METHOD") orelse "GET") orelse {
    154             return error.InvalidRequestMethod;
    155         },
    156         .target = std.posix.getenv("REQUEST_URI") orelse "/",
    157     };
    158     var res = Response{
    159         .allocator = allocator,
    160         .request = req,
    161         .status = .ok,
    162         .transfer_encoding = .none,
    163         .headers = Headers.init(allocator),
    164     };
    165     const ctx = Ctx{ .db = &db };
    166     try router.handle(&res, ctx);
    167 }
    168 
    169 fn notfound(res: *Response, _: Ctx) Err!void {
    170     const rr = @embedFile("templates/notfound.html");
    171     try constresponse(res, rr, .not_found);
    172 }
    173 
    174 fn badrequest(res: *Response, _: Ctx) Err!void {
    175     const rr = @embedFile("templates/badrequest.html");
    176     try constresponse(res, rr, .bad_request);
    177 }
    178 
    179 fn constresponse(res: *Response, rr: []const u8, status: std.http.Status) Err!void {
    180     res.status = status;
    181     res.transfer_encoding = .{ .content_length = rr.len };
    182     try res.headers.append("content-type", "text/html");
    183     try res.do();
    184     try res.writer().writeAll(rr);
    185     try res.finish();
    186 }
    187 
    188 fn get_comment(res: *Response, ctx: Ctx, _: Params) Err!void {
    189     var p = try zws.Path.parse(res.allocator, res.request.target);
    190     defer p.deinit();
    191     const url: []const u8 = try p.get_query_param("url") orelse {
    192         try badrequest(res, ctx);
    193         return;
    194     };
    195 
    196     const Comment = struct {
    197         author: []const u8,
    198         comment: []const u8,
    199         ts: []const u8,
    200     };
    201     var comments = std.ArrayList(Comment).init(res.allocator);
    202     var qr = try ctx.db.queryOpts(
    203         \\ select author,comment,ts::text from comments where url = $1 order by ts
    204     , .{url}, .{ .column_names = true });
    205     defer qr.deinit();
    206     var mapper = qr.mapper(Comment, .{ .allocator = res.allocator, .dupe = true });
    207     while (try mapper.next()) |nxt| {
    208         try comments.append(nxt);
    209     }
    210     const rr = @embedFile("templates/comments.html");
    211     const tt = mustache.parseText(res.allocator, rr, .{}, .{ .copy_strings = false }) catch unreachable;
    212     res.transfer_encoding = .chunked;
    213     try res.headers.append("content-type", "text/html");
    214     try res.do();
    215 
    216     const data = struct {
    217         comments: []const Comment,
    218     };
    219     try mustache.render(tt.success, data{
    220         .comments = comments.items,
    221     }, res.writer());
    222     try res.finish();
    223 }
    224 
    225 fn post_comment(res: *Response, ctx: Ctx, _: Params) Err!void {
    226     const cl = if (std.posix.getenv("CONTENT_LENGTH")) |clh| try std.fmt.parseInt(usize, clh, 10) else {
    227         return error.InvalidLength;
    228     };
    229     const body = try res.allocator.alloc(u8, cl);
    230     defer res.allocator.free(body);
    231     try res.reader().readNoEof(body);
    232     var form = try zws.Form.parse(res.allocator, body);
    233     const Form = struct {
    234         url: []const u8,
    235         capcha_id: []const u8,
    236         author: []const u8,
    237         comment: []const u8,
    238         capcha_answer: []const u8,
    239     };
    240     const form_val = form.form_to_struct(Form) catch {
    241         std.log.err("couldn't parse Form", .{});
    242         try badrequest(res, ctx);
    243         return;
    244     };
    245 
    246     // Validate the capcha
    247     {
    248         var qr = try ctx.db.query("select answer from capchas where id = $1", .{form_val.capcha_id});
    249         defer qr.deinit();
    250         const row: pg.Row = try qr.next() orelse {
    251             std.log.err("missing capcha_id {s}", .{form_val.capcha_id});
    252             try badrequest(res, ctx);
    253             return;
    254         };
    255         const ans = row.get([]const u8, 0);
    256         if (!std.mem.eql(u8, ans, form_val.capcha_answer)) {
    257             std.log.err("bad capcha answer {s} expected {s}", .{ form_val.capcha_answer, ans });
    258             try constresponse(res, @embedFile("templates/capchainvalid.html"), std.http.Status.unauthorized);
    259             return;
    260         }
    261         try qr.drain();
    262     }
    263 
    264     // Add the comment...
    265     {
    266         _ = try ctx.db.exec("insert into comments(url,author,comment) values($1, $2, $3)", .{
    267             form_val.url,
    268             form_val.author,
    269             form_val.comment,
    270         });
    271     }
    272 
    273     // Send me an email
    274     const rr = @embedFile("templates/notification.txt");
    275     const tt = mustache.parseText(res.allocator, rr, .{}, .{ .copy_strings = false }) catch unreachable;
    276     const Data = struct { url: []const u8, author: []const u8, comment: []const u8 };
    277     const notification = try mustache.allocRender(res.allocator, tt.success, Data{
    278         .url = form_val.url,
    279         .author = form_val.author,
    280         .comment = form_val.comment,
    281     });
    282     const smtp_username = std.posix.getenv("SMTP_USERNAME") orelse "comments@mfashby.net";
    283     const smtp_password = std.posix.getenv("SMTP_PASSWORD") orelse "foobar";
    284     const notification_address = std.posix.getenv("NOTIFICATION_ADDRESS") orelse "martin@mfashby.net";
    285     const smtp_server = std.posix.getenv("SMTP_SERVER") orelse "mail.mfashby.net:587";
    286     smtp.send_mail(res.allocator, smtp_server, .{ .user = smtp_username, .pass = smtp_password }, smtp_username, &[_][]const u8{notification_address}, notification) catch |err| {
    287         std.log.err("failed to send notification email {}", .{err});
    288     };
    289 
    290     // And redirect!
    291     res.transfer_encoding = .none;
    292     res.status = .found;
    293     try res.headers.append("location", form_val.url);
    294     try res.do();
    295     try res.finish();
    296 }
    297 
    298 fn get_form(res: *Response, ctx: Ctx, _: Params) Err!void {
    299     var p = try zws.Path.parse(res.allocator, res.request.target);
    300     defer p.deinit();
    301     const url: []const u8 = try p.get_query_param("url") orelse {
    302         try badrequest(res, ctx);
    303         return;
    304     };
    305 
    306     const Capcha = struct {
    307         id: []const u8,
    308         question: []const u8,
    309     };
    310 
    311     var qr = try ctx.db.queryOpts("select id::text, question from capchas order by random() limit 1", .{}, .{ .column_names = true });
    312     defer qr.deinit();
    313     var m = qr.mapper(Capcha, .{ .allocator = res.allocator, .dupe = true });
    314     const capcha = try m.next() orelse {
    315         std.log.err("no capcha!", .{});
    316         try badrequest(res, ctx);
    317         return;
    318     };
    319 
    320     const rr = @embedFile("templates/form.html");
    321     const tt = mustache.parseText(res.allocator, rr, .{}, .{ .copy_strings = false }) catch unreachable;
    322 
    323     res.transfer_encoding = .chunked;
    324     try res.headers.append("content-type", "text/html");
    325     try res.do();
    326     // For some reason, mustache.render won't work with anonymous struct
    327     const data = struct {
    328         capcha_id: []const u8,
    329         capcha_question: []const u8,
    330         url: []const u8,
    331     };
    332     try mustache.render(tt.success, data{
    333         .capcha_id = capcha.id,
    334         .capcha_question = capcha.question,
    335         .url = url,
    336     }, res.writer());
    337     try res.finish();
    338 }