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 }