aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorMartin Ashby <martin@ashbysoft.com>2023-08-04 22:29:41 +0100
committerMartin Ashby <martin@ashbysoft.com>2023-08-11 22:48:56 +0100
commitf3302ecb76be3f42d782d73a58552775416b5052 (patch)
tree69fd40b315af876b3885769cbf4dedd83f1783b1
parent6ee53f319847e7535b19d2661fa501bcb423ef42 (diff)
downloadmfashby.net-zig-comments.tar.gz
mfashby.net-zig-comments.tar.bz2
mfashby.net-zig-comments.tar.xz
mfashby.net-zig-comments.zip
Convert comments to zigzig-comments
-rw-r--r--.gitmodules6
-rw-r--r--zig-comments/.gitignore2
-rw-r--r--zig-comments/build.zig80
-rw-r--r--zig-comments/comments.service12
m---------zig-comments/lib/mustache-zig0
m---------zig-comments/lib/zigwebserver0
-rw-r--r--zig-comments/src/README.md0
-rw-r--r--zig-comments/src/main.zig163
-rw-r--r--zig-comments/src/migrations/0_init.sql2
-rw-r--r--zig-comments/src/migrations/1_capcha.sql6
-rw-r--r--zig-comments/src/pq.zig140
-rw-r--r--zig-comments/src/templates/badrequest.html6
-rw-r--r--zig-comments/src/templates/comments.html9
-rw-r--r--zig-comments/src/templates/form.html11
-rw-r--r--zig-comments/src/templates/notfound.html6
-rw-r--r--zig-comments/src/templates/notification.txt5
16 files changed, 448 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..ffe6620
--- /dev/null
+++ b/zig-comments/src/main.zig
@@ -0,0 +1,163 @@
+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 } || 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;
+ };
+ // TODO validate the capcha
+
+ var stmt = try ctx.db.prepare_statement("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();
+
+ 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("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 {
+ capcha_id: []const u8,
+ capcha_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.capcha_id,
+ .capcha_question = capcha.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..d6339c7
--- /dev/null
+++ b/zig-comments/src/pq.zig
@@ -0,0 +1,140 @@
+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, query: [:0]const u8) PqError!Stmt {
+ return Stmt{
+ .db = self,
+ .query = query,
+ };
+ //pq.PQexec(conn: ?*PGconn, query: [*c]const u8)
+ }
+};
+
+pub const Stmt = struct {
+ const MAX_PARAMS = 128;
+ db: Db,
+ query: [:0]const u8,
+
+ // TODO take child allocator as a param
+ aa: std.heap.ArenaAllocator = std.heap.ArenaAllocator.init(std.heap.page_allocator),
+ 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) {
+ 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 val = try self.read_columnN(field.name, field.type);
+ @field(t, field.name) = val;
+ }
+ return t;
+ }
+};
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/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