diff options
-rw-r--r-- | .gitmodules | 6 | ||||
-rw-r--r-- | zig-comments/.gitignore | 2 | ||||
-rw-r--r-- | zig-comments/build.zig | 80 | ||||
-rw-r--r-- | zig-comments/comments.service | 12 | ||||
m--------- | zig-comments/lib/mustache-zig | 0 | ||||
m--------- | zig-comments/lib/zigwebserver | 0 | ||||
-rw-r--r-- | zig-comments/src/README.md | 0 | ||||
-rw-r--r-- | zig-comments/src/main.zig | 183 | ||||
-rw-r--r-- | zig-comments/src/migrations/0_init.sql | 2 | ||||
-rw-r--r-- | zig-comments/src/migrations/1_capcha.sql | 6 | ||||
-rw-r--r-- | zig-comments/src/pq.zig | 149 | ||||
-rw-r--r-- | zig-comments/src/templates/badrequest.html | 6 | ||||
-rw-r--r-- | zig-comments/src/templates/capchainvalid.html | 6 | ||||
-rw-r--r-- | zig-comments/src/templates/comments.html | 9 | ||||
-rw-r--r-- | zig-comments/src/templates/form.html | 11 | ||||
-rw-r--r-- | zig-comments/src/templates/notfound.html | 6 | ||||
-rw-r--r-- | zig-comments/src/templates/notification.txt | 5 |
17 files changed, 483 insertions, 0 deletions
diff --git a/.gitmodules b/.gitmodules index a38e1fb..7fedfc5 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,3 +1,9 @@ [submodule "hugo-xmin"] path = hugo-xmin url = git@github.com:yihui/hugo-xmin +[submodule "zig-comments/lib/zigwebserver"] + path = zig-comments/lib/zigwebserver + url = git://code.mfashby.net:/zigwebserver +[submodule "zig-comments/lib/mustache-zig"] + path = zig-comments/lib/mustache-zig + url = https://github.com/MFAshby/mustache-zig diff --git a/zig-comments/.gitignore b/zig-comments/.gitignore new file mode 100644 index 0000000..ee7098f --- /dev/null +++ b/zig-comments/.gitignore @@ -0,0 +1,2 @@ +zig-out/ +zig-cache/ diff --git a/zig-comments/build.zig b/zig-comments/build.zig new file mode 100644 index 0000000..21102a7 --- /dev/null +++ b/zig-comments/build.zig @@ -0,0 +1,80 @@ +const std = @import("std"); + +// Although this function looks imperative, note that its job is to +// declaratively construct a build graph that will be executed by an external +// runner. +pub fn build(b: *std.Build) void { + // Standard target options allows the person running `zig build` to choose + // what target to build for. Here we do not override the defaults, which + // means any target is allowed, and the default is native. Other options + // for restricting supported target set are available. + const target = b.standardTargetOptions(.{}); + + // Standard optimization options allow the person running `zig build` to select + // between Debug, ReleaseSafe, ReleaseFast, and ReleaseSmall. Here we do not + // set a preferred release mode, allowing the user to decide how to optimize. + const optimize = b.standardOptimizeOption(.{}); + + const exe = b.addExecutable(.{ + .name = "comments", + // In this case the main source file is merely a path, however, in more + // complicated build scripts, this could be a generated file. + .root_source_file = .{ .path = "src/main.zig" }, + .target = target, + .optimize = optimize, + }); + + const zws = b.addModule("zws", .{ .source_file = .{ .path = "lib/zigwebserver/src/zigwebserver.zig" } }); + exe.addModule("zws", zws); + + exe.linkLibC(); + exe.linkSystemLibrary("libpq"); + exe.addIncludePath(.{ .path = "/usr/include" }); + + const mustache = b.addModule("mustache", .{ .source_file = .{ .path = "lib/mustache-zig/src/mustache.zig" } }); + exe.addModule("mustache", mustache); + + // This declares intent for the executable to be installed into the + // standard location when the user invokes the "install" step (the default + // step when running `zig build`). + b.installArtifact(exe); + + // This *creates* a Run step in the build graph, to be executed when another + // step is evaluated that depends on it. The next line below will establish + // such a dependency. + const run_cmd = b.addRunArtifact(exe); + + // By making the run step depend on the install step, it will be run from the + // installation directory rather than directly from within the cache directory. + // This is not necessary, however, if the application depends on other installed + // files, this ensures they will be present and in the expected location. + run_cmd.step.dependOn(b.getInstallStep()); + + // This allows the user to pass arguments to the application in the build + // command itself, like this: `zig build run -- arg1 arg2 etc` + if (b.args) |args| { + run_cmd.addArgs(args); + } + + // This creates a build step. It will be visible in the `zig build --help` menu, + // and can be selected like this: `zig build run` + // This will evaluate the `run` step rather than the default, which is "install". + const run_step = b.step("run", "Run the app"); + run_step.dependOn(&run_cmd.step); + + // Creates a step for unit testing. This only builds the test executable + // but does not run it. + const unit_tests = b.addTest(.{ + .root_source_file = .{ .path = "src/main.zig" }, + .target = target, + .optimize = optimize, + }); + + const run_unit_tests = b.addRunArtifact(unit_tests); + + // Similar to creating the run step earlier, this exposes a `test` step to + // the `zig build --help` menu, providing a way for the user to request + // running the unit tests. + const test_step = b.step("test", "Run unit tests"); + test_step.dependOn(&run_unit_tests.step); +} diff --git a/zig-comments/comments.service b/zig-comments/comments.service new file mode 100644 index 0000000..7d8f0e5 --- /dev/null +++ b/zig-comments/comments.service @@ -0,0 +1,12 @@ +[Unit] +Description=Comments service +After=postgres.service + +[Service] +User=comments +ExecStart=/usr/local/bin/comments +Restart=on-failure +EnvironmentFile=/etc/sysconfig/comments + +[Install] +WantedBy=multi-user.target diff --git a/zig-comments/lib/mustache-zig b/zig-comments/lib/mustache-zig new file mode 160000 +Subproject cfcd483639458793d7d7818cf1c89cc37bb6344 diff --git a/zig-comments/lib/zigwebserver b/zig-comments/lib/zigwebserver new file mode 160000 +Subproject 17d8cc65fe6396d505dda6bf162942cab9e0040 diff --git a/zig-comments/src/README.md b/zig-comments/src/README.md new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/zig-comments/src/README.md diff --git a/zig-comments/src/main.zig b/zig-comments/src/main.zig new file mode 100644 index 0000000..951f5f0 --- /dev/null +++ b/zig-comments/src/main.zig @@ -0,0 +1,183 @@ +const std = @import("std"); +const zws = @import("zws"); +const pq = @import("pq.zig"); +const mustache = @import("mustache"); + +const Params = zws.Params; + +const Err = error{ Overflow, InvalidCharacter, StreamTooLong, ColumnNotFound } || pq.PqError || std.http.Server.Response.WaitError || std.http.Server.Response.DoError || std.http.Server.Response.ReadError || std.http.Server.Response.FinishError || zws.Path.ParseError; +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 Rtr = zws.Router(Ctx, Err); +const router = Rtr{ + .handlers = &[_]Rtr.Handler{ + .{ + .method = .GET, + .pattern = "/api/comments", + .handle_fn = get_comments, + }, + .{ + .method = .POST, + .pattern = "/api/comments", + .handle_fn = post_comments, + }, + .{ + .method = .GET, + .pattern = "/api/form/*", + .handle_fn = get_form, + }, + }, + .notfound = notfound, +}; + +pub fn main() !void { + var db = try pq.Db.init("postgresql://comments@localhost/comments"); + try db.exec(@embedFile("migrations/0_init.sql")); + try db.exec(@embedFile("migrations/1_capcha.sql")); + defer db.deinit(); + const server = zws.Server(Ctx, Rtr){ + .allocator = gpa.allocator(), + .address = std.net.Address{ .in = std.net.Ip4Address.init(.{ 127, 0, 0, 1 }, 8080) }, + .context = Ctx{ .db = db }, + .handler = router, + }; + + try server.serve(); +} + +fn notfound(res: *std.http.Server.Response, _: Ctx) Err!void { + const rr = @embedFile("templates/notfound.html"); + try constresponse(res, rr, .not_found); +} + +fn badrequest(res: *std.http.Server.Response, _: Ctx) Err!void { + const rr = @embedFile("templates/badrequest.html"); + try constresponse(res, rr, .bad_request); +} + +fn constresponse(res: *std.http.Server.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.writeAll(rr); + try res.finish(); +} + +fn get_comments(res: *std.http.Server.Response, ctx: Ctx, _: Params) Err!void { + _ = ctx; + // Run SQL + // Render comments template + //zws.parse + + const rr = @embedFile("templates/comments.html"); + res.transfer_encoding = .{ .content_length = rr.len }; + try res.headers.append("content-type", "text/html"); + try res.do(); + try res.writeAll(rr); + try res.finish(); +} + +fn post_comments(res: *std.http.Server.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: *std.http.Server.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(); +} diff --git a/zig-comments/src/migrations/0_init.sql b/zig-comments/src/migrations/0_init.sql new file mode 100644 index 0000000..a799784 --- /dev/null +++ b/zig-comments/src/migrations/0_init.sql @@ -0,0 +1,2 @@ +create table if not exists comments (url text not null, author text not null, comment text not null, ts timestamptz not null default now()); +create index if not exists idx_comments_url on comments(url);
\ No newline at end of file diff --git a/zig-comments/src/migrations/1_capcha.sql b/zig-comments/src/migrations/1_capcha.sql new file mode 100644 index 0000000..c80a4bd --- /dev/null +++ b/zig-comments/src/migrations/1_capcha.sql @@ -0,0 +1,6 @@ +create table if not exists capchas(question text not null, answer text not null, id uuid not null default gen_random_uuid()); +create unique index if not exists idx_capchas_id on capchas(id); +insert into capchas(question, answer) values ('What is 1 + 3?', '4'); +insert into capchas(question, answer) values ('If I have 3 apples and 4 pears, how many fruit do I have?', '7'); +insert into capchas(question, answer) values ('What is 3 squared?', '9'); +insert into capchas(question, answer) values ('What is the meaning of life, the universe, and everything?', '42'); diff --git a/zig-comments/src/pq.zig b/zig-comments/src/pq.zig new file mode 100644 index 0000000..52220fa --- /dev/null +++ b/zig-comments/src/pq.zig @@ -0,0 +1,149 @@ +const std = @import("std"); +const pq = @cImport( + @cInclude("libpq-fe.h"), +); + +pub const PqError = error{PqError}; + +// libpq wrapper +// later, this could be a pure-zig client implementation +pub const Db = struct { + c_conn: *pq.PGconn, + pub fn init(connect_url: [:0]const u8) !Db { + if (pq.PQisthreadsafe() == 0) { + std.log.err("PQisthreadsafe returned 0, can't use libpq in this program", .{}); + return PqError.PqError; + } + var maybe_conn: ?*pq.PGconn = pq.PQconnectdb(connect_url); + if (maybe_conn == null) { + std.log.err("PQconnectdb returned null", .{}); + return PqError.PqError; + } + if (pq.PQstatus(maybe_conn) == pq.CONNECTION_BAD) { + std.log.err("PQstatus returned CONNECTION_BAD: {s}", .{pq.PQerrorMessage(maybe_conn)}); + + return PqError.PqError; + } else if (pq.PQstatus(maybe_conn) != pq.CONNECTION_OK) { + std.log.err("PQstatus returned unknown status {}: {s}", .{ pq.PQstatus(maybe_conn), pq.PQerrorMessage(maybe_conn) }); + return PqError.PqError; + } + return Db{ + .c_conn = maybe_conn.?, + }; + } + + pub fn deinit(self: Db) void { + pq.PQfinish(self.c_conn); + } + + pub fn exec(self: Db, query: [:0]const u8) !void { + var res: ?*pq.PGresult = pq.PQexec(self.c_conn, query); + defer pq.PQclear(res); + var est: pq.ExecStatusType = pq.PQresultStatus(res); + if (est != pq.PGRES_COMMAND_OK) { + std.log.err("PQexec error code {} message {s}", .{ est, pq.PQerrorMessage(self.c_conn) }); + return PqError.PqError; + } + } + + pub fn prepare_statement(self: Db, allocator: std.mem.Allocator, query: [:0]const u8) PqError!Stmt { + return Stmt{ + .db = self, + .aa = std.heap.ArenaAllocator.init(allocator), + .query = query, + }; + } +}; + +pub const Stmt = struct { + const MAX_PARAMS = 128; + db: Db, + query: [:0]const u8, + aa: std.heap.ArenaAllocator, + + n_params: usize = 0, + param_values: [MAX_PARAMS][*c]const u8 = undefined, + did_exec: bool = false, + c_res: ?*pq.PGresult = null, + res_index: c_int = -1, + n_tuples: c_int = -1, + n_fields: c_int = -1, + + pub fn deinit(self: *Stmt) void { + self.aa.deinit(); + pq.PQclear(self.c_res); + } + + pub fn step(self: *Stmt) !bool { + if (!self.did_exec) { + self.did_exec = true; + self.c_res = pq.PQexecParams(self.db.c_conn, self.query, @as(c_int, @intCast(self.n_params)), null, &self.param_values, null, null, 0); + const rs = pq.PQresultStatus(self.c_res); + if (rs != pq.PGRES_TUPLES_OK and rs != pq.PGRES_SINGLE_TUPLE and rs != pq.PGRES_COMMAND_OK) { + std.log.err("PQresultStatus {} error: {s}", .{ rs, pq.PQerrorMessage(self.db.c_conn) }); + return PqError.PqError; + } + self.n_tuples = pq.PQntuples(self.c_res); + self.n_fields = pq.PQnfields(self.c_res); + } + self.res_index = self.res_index + 1; + return self.res_index < self.n_tuples; + } + + pub fn read_column(self: Stmt, idx: c_int, comptime T: type) !T { + const value_c: [*c]u8 = pq.PQgetvalue(self.c_res, self.res_index, idx); + const value: []const u8 = std.mem.sliceTo(value_c, 0); + return switch (@typeInfo(T)) { + .Int => std.fmt.parseInt(T, value, 10), + .Pointer => |ptrinfo| blk: { + if (ptrinfo.child != u8) { + @compileError("pointer type []const u8 only is supported in read_column"); + } + if (ptrinfo.size != .Slice) { + @compileError("pointer type []const u8 only is supported in read_column"); + } + break :blk value; + }, + else => @compileError("unhandled type " ++ @tagName(@typeInfo(T)) ++ " in read_column"), + }; + } + + pub fn read_columnN(self: Stmt, name: [:0]const u8, comptime T: type) !T { + const idx = pq.PQfnumber(self.c_res, name.ptr); + if (idx == -1) { + std.log.err("read_columnN ColumnNotFound [{s}]", .{name}); + return error.ColumnNotFound; + } + return read_column(self, idx, T); + } + + pub fn bind(self: *Stmt, idx: usize, t: anytype) !void { + const T = @TypeOf(t); + const value: [:0]const u8 = switch (@typeInfo(T)) { + .Pointer => try std.fmt.allocPrintZ(self.aa.allocator(), "{s}", .{t}), + .Int => try std.fmt.allocPrintZ(self.aa.allocator(), "{d}", .{t}), + else => @compileError("unhandled type " ++ @tagName(@typeInfo(T) ++ " in bind")), + }; + self.param_values[idx] = value.ptr; + self.n_params = @max(self.n_params, idx + 1); + } + + pub fn read_struct(self: Stmt, comptime T: type) !T { + const ti = @typeInfo(T); + var t: T = undefined; + inline for (ti.Struct.fields) |field| { + const name: [:0]const u8 = &addZ(field.name.len,field.name[0..].*); + const val = try self.read_columnN(name, field.type); + @field(t, field.name) = val; + } + return t; + } +}; + +// https://github.com/ziglang/zig/issues/16116 +pub fn addZ(comptime length: usize, value: [length]u8) [length:0]u8 { + var terminated_value: [length:0]u8 = undefined; + terminated_value[length] = 0; + @memcpy(&terminated_value, &value); + return terminated_value; +} diff --git a/zig-comments/src/templates/badrequest.html b/zig-comments/src/templates/badrequest.html new file mode 100644 index 0000000..927e908 --- /dev/null +++ b/zig-comments/src/templates/badrequest.html @@ -0,0 +1,6 @@ +<doctype HTML> +<html> +<body> + <p>Bad Request!</p> +</body> +</html>
\ No newline at end of file diff --git a/zig-comments/src/templates/capchainvalid.html b/zig-comments/src/templates/capchainvalid.html new file mode 100644 index 0000000..f13899b --- /dev/null +++ b/zig-comments/src/templates/capchainvalid.html @@ -0,0 +1,6 @@ +<doctype HTML> +<html> +<body> + <p>Capcha invalid!</p> +</body> +</html>
\ No newline at end of file diff --git a/zig-comments/src/templates/comments.html b/zig-comments/src/templates/comments.html new file mode 100644 index 0000000..a1d8b76 --- /dev/null +++ b/zig-comments/src/templates/comments.html @@ -0,0 +1,9 @@ +<ul class="comments"> +{{ #comments }} + <li class="comment"> + <span class="comment author">{{ author }}</span> + <p class="comment content">{{ comment }}</p> + <span class="comment timestamp">{{ ts }}</span> + </li> +{{ /comments }} +</ul> diff --git a/zig-comments/src/templates/form.html b/zig-comments/src/templates/form.html new file mode 100644 index 0000000..8c492ea --- /dev/null +++ b/zig-comments/src/templates/form.html @@ -0,0 +1,11 @@ +<form action="/api/comments" method="post"> + <input type="hidden" name="url" value="{{ url }}"><br> + <input type="hidden" name="capcha_id" value="{{ capcha_id }}"><br> + <label for="author">Name:</label><br> + <input type="text" id="author" name="author"><br> + <label for="comment">Comment:</label><br> + <input type="text" id="comment" name="comment"><br> + <label for="capcha">{{ capcha_question }}</label><br> + <input type="text" id="capcha" name="capcha_answer"><br> + <input type="submit" value="Submit"> +</form> diff --git a/zig-comments/src/templates/notfound.html b/zig-comments/src/templates/notfound.html new file mode 100644 index 0000000..417e20d --- /dev/null +++ b/zig-comments/src/templates/notfound.html @@ -0,0 +1,6 @@ +<doctype HTML> +<html> +<body> + <p>Not found!</p> +</body> +</html>
\ No newline at end of file diff --git a/zig-comments/src/templates/notification.txt b/zig-comments/src/templates/notification.txt new file mode 100644 index 0000000..ba85056 --- /dev/null +++ b/zig-comments/src/templates/notification.txt @@ -0,0 +1,5 @@ +New comment on {{ url }}: + +Author: {{ author }} + +Comment: {{ comment }}
\ No newline at end of file |