aboutsummaryrefslogtreecommitdiff
path: root/comments/src
diff options
context:
space:
mode:
Diffstat (limited to 'comments/src')
-rw-r--r--comments/src/README.md0
-rw-r--r--comments/src/main.rs227
-rw-r--r--comments/src/main.zig294
-rw-r--r--comments/src/migrations/0_init.sql2
-rw-r--r--comments/src/migrations/1_capcha.sql6
-rw-r--r--comments/src/pq.zig151
-rw-r--r--comments/src/templates/badrequest.html6
-rw-r--r--comments/src/templates/capchainvalid.html6
-rw-r--r--comments/src/templates/comments.html9
-rw-r--r--comments/src/templates/form.html11
-rw-r--r--comments/src/templates/notfound.html6
-rw-r--r--comments/src/templates/notification.txt5
12 files changed, 496 insertions, 227 deletions
diff --git a/comments/src/README.md b/comments/src/README.md
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/comments/src/README.md
diff --git a/comments/src/main.rs b/comments/src/main.rs
deleted file mode 100644
index 50f568f..0000000
--- a/comments/src/main.rs
+++ /dev/null
@@ -1,227 +0,0 @@
-/**
- * Comments generator
- * POST /comment - submits a comment
- *
- * GET /form - generate html comment form
- * GET /comment - gets html fragment with comments
- * both accept a query parameter ?page=<url>
- */
-
-use askama::Template;
-use axum::{
- extract::{Query, Form, State},
- response::{Redirect,Html},
- http::StatusCode,
- routing::get,
- Router,
-};
-use lettre::{
- AsyncSmtpTransport, AsyncTransport, Message, Tokio1Executor,
- transport::smtp::authentication::Credentials
-};
-use serde::Deserialize;
-use sqlx::{
- postgres::{PgPool, PgPoolOptions},
- types::{
- time::OffsetDateTime,
- uuid::Uuid,
- },
-};
-use std::{net::SocketAddr, time::Duration};
-
-
-// Useful global thingies
-#[derive(Clone)]
-struct Ctx {
- pool: PgPool,
- base_path: String,
- mail_opts: Option<MailCtx>,
-}
-
-#[derive(Clone)]
-struct MailCtx {
- notification_address: String,
- smtp_server: String,
- smtp_credentials: Credentials,
-}
-
-#[tokio::main]
-async fn main() {
- let base_path = std::env::var("BASE_PATH")
- .unwrap_or_else(|_| "/api".to_string());
-
- let db_connection_str = std::env::var("DATABASE_URL")
- .unwrap_or_else(|_| "postgres://comments@localhost/comments".to_string());
-
- let notification_address = std::env::var("NOTIFICATION_ADDRESS").ok();
- let mail_opts = notification_address.map(|na| {
- let smtp_server = std::env::var("SMTP_SERVER").expect("SMTP_SERVER not supplied!");
- let smtp_username = std::env::var("SMTP_USERNAME").expect("SMTP_USERNAME not supplied!");
- let smtp_password = std::env::var("SMTP_PASSWORD").expect("SMTP_PASSWORD not supplied!");
- MailCtx{
- smtp_server: smtp_server,
- smtp_credentials: Credentials::new(smtp_username, smtp_password),
- notification_address: na,
- }
- });
-
- let pool = PgPoolOptions::new()
- .max_connections(5)
- .connect_timeout(Duration::from_secs(3))
- .connect(&db_connection_str)
- .await
- .expect("can't connect to database");
- sqlx::migrate!()
- .run(&pool)
- .await
- .expect("failed to run migrations");
-
- let ctx = Ctx {pool, base_path: base_path.clone(), mail_opts};
-
- let app = Router::new()
- .nest(&base_path, Router::new()
- .route(
- "/form",
- get(get_form),
- )
- .route(
- "/comment",
- get(get_comments).post(post_comments),
- )
- .with_state(ctx));
-
- let addr = SocketAddr::from(([127, 0, 0, 1], 5678));
- axum::Server::bind(&addr)
- .serve(app.into_make_service())
- .await
- .unwrap();
-}
-
-#[derive(Deserialize)]
-struct UrlQuery {
- url: String
-}
-
-#[derive(Template)]
-#[template(path = "form.html")]
-struct CommentForm {
- url: String,
- capcha_question: String,
- capcha_id: Uuid,
- base_path: String,
-}
-
-async fn get_form(
- State(ctx): State<Ctx>,
- Query(uq): Query<UrlQuery>
-) -> Result<Html<String>, (StatusCode, String)> {
- let capcha = sqlx::query!("select id, question from capchas order by random() limit 1")
- .fetch_one(&ctx.pool)
- .await
- .map_err(internal_error)?;
- let c = CommentForm{url: uq.url, capcha_question: capcha.question, capcha_id: capcha.id, base_path: ctx.base_path};
- let res = c.render().map_err(internal_error)?;
- Ok(Html(res))
-}
-
-#[derive(Template)]
-#[template(path = "comments.html")]
-struct Comments {
- comments: Vec<Comment>,
-}
-struct Comment {
- author: String,
- comment: String,
- ts: OffsetDateTime,
-}
-
-async fn get_comments(
- State(ctx): State<Ctx>,
- Query(uq): Query<UrlQuery>) -> Result<Html<String>, (StatusCode,String)> {
- let comments = sqlx::query!("select author,comment,ts from comments where url = $1 order by ts", uq.url)
- .fetch_all(&ctx.pool)
- .await
- .map_err(internal_error)?;
- let render_comments: Vec<Comment> = comments.into_iter().map(|comment| {
- Comment {
- author: comment.author,
- comment: comment.comment,
- ts: comment.ts,
- }
- }).collect();
- let c = Comments{comments: render_comments};
- let res = c.render().map_err(internal_error)?;
- Ok(Html(res))
-}
-
-#[derive(Deserialize)]
-struct PostComment {
- url: String,
- author: String,
- comment: String,
- capcha_id: String,
- capcha_answer: String,
-}
-
-struct CapchaAnswer {
- answer:String
-}
-
-#[derive(Template)]
-#[template(path = "notification.txt")]
-struct Notification<'a> {
- url: &'a str,
- comment: &'a str,
- author: &'a str,
-}
-
-async fn post_comments(
- State(ctx): State<Ctx>,
- Form(post_comment): Form<PostComment>) -> Result<Redirect,(StatusCode,String)> {
- let capcha_id: Uuid = post_comment.capcha_id.parse()
- .map_err(|_| {(StatusCode::BAD_REQUEST, "Invalid capcha_id".to_string())})?;
- let ans: CapchaAnswer = sqlx::query_as!(CapchaAnswer, "select answer from capchas where id = $1", capcha_id)
- .fetch_one(&ctx.pool)
- .await
- .map_err(internal_error)?;
- if post_comment.capcha_answer != ans.answer {
- return Err((StatusCode::BAD_REQUEST, "Capcha was wrong!".to_string()));
- }
- sqlx::query!("insert into comments(url,author,comment) values($1, $2, $3)", post_comment.url, post_comment.author, post_comment.comment)
- .execute(&ctx.pool)
- .await
- .map_err(internal_error)?;
-
- if let Some(mail_opts) = ctx.mail_opts {
- let mail_body = Notification{
- url: &post_comment.url,
- author: &post_comment.author,
- comment: &post_comment.comment
- }.render().unwrap();
- let email = Message::builder()
- // TODO should be a config value
- .from("Comments Service <comments@mfashby.net>".parse().unwrap())
- .to(mail_opts.notification_address.parse().unwrap())
- .subject(format!("New comment on {}", post_comment.url))
- .body(mail_body)
- .unwrap();
- let mailer: AsyncSmtpTransport<Tokio1Executor> =
- AsyncSmtpTransport::<Tokio1Executor>::relay(&mail_opts.smtp_server)
- .unwrap()
- .credentials(mail_opts.smtp_credentials)
- .build();
- match mailer.send(email).await {
- Ok(_) => (),
- Err(e) => eprintln!("Could not send email: {:?}", e),
- }
- };
-
- Ok(Redirect::temporary(&post_comment.url))
-}
-
-fn internal_error<E>(err: E) -> (StatusCode, String)
-where
- E: std::error::Error,
-{
- (StatusCode::INTERNAL_SERVER_ERROR, err.to_string())
-}
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();
+}
diff --git a/comments/src/migrations/0_init.sql b/comments/src/migrations/0_init.sql
new file mode 100644
index 0000000..a799784
--- /dev/null
+++ b/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/comments/src/migrations/1_capcha.sql b/comments/src/migrations/1_capcha.sql
new file mode 100644
index 0000000..bf43331
--- /dev/null
+++ b/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, id) values ('What is 1 + 3?', '4', 'de850c99-906b-4ff8-b8e7-fce3bccd89bc') on conflict do nothing;
+insert into capchas(question, answer, id) values ('If I have 3 apples and 4 pears, how many fruit do I have?', '7', 'c83e23a8-ec53-48d4-a468-a530dfa634b7') on conflict do nothing;
+insert into capchas(question, answer, id) values ('What is 3 squared?', '9', '798bf299-4e3b-4f98-acac-f0c41201b613') on conflict do nothing;
+insert into capchas(question, answer, id) values ('What is the meaning of life, the universe, and everything?', '42', '7025fcbf-ebe8-4679-809f-08d632591b03') on conflict do nothing;
diff --git a/comments/src/pq.zig b/comments/src/pq.zig
new file mode 100644
index 0000000..a4fc382
--- /dev/null
+++ b/comments/src/pq.zig
@@ -0,0 +1,151 @@
+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();
+ if (self.c_res != null) {
+ 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/comments/src/templates/badrequest.html b/comments/src/templates/badrequest.html
new file mode 100644
index 0000000..927e908
--- /dev/null
+++ b/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/comments/src/templates/capchainvalid.html b/comments/src/templates/capchainvalid.html
new file mode 100644
index 0000000..f13899b
--- /dev/null
+++ b/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/comments/src/templates/comments.html b/comments/src/templates/comments.html
new file mode 100644
index 0000000..e2fc6da
--- /dev/null
+++ b/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/comments/src/templates/form.html b/comments/src/templates/form.html
new file mode 100644
index 0000000..e7a836f
--- /dev/null
+++ b/comments/src/templates/form.html
@@ -0,0 +1,11 @@
+<form action="/api/comment" 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/comments/src/templates/notfound.html b/comments/src/templates/notfound.html
new file mode 100644
index 0000000..795d262
--- /dev/null
+++ b/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/comments/src/templates/notification.txt b/comments/src/templates/notification.txt
new file mode 100644
index 0000000..ba85056
--- /dev/null
+++ b/comments/src/templates/notification.txt
@@ -0,0 +1,5 @@
+New comment on {{ url }}:
+
+Author: {{ author }}
+
+Comment: {{ comment }} \ No newline at end of file