From 7ed589a06539fcc91ab779a1e6de0a995f8238b5 Mon Sep 17 00:00:00 2001 From: Martin Ashby Date: Tue, 22 Aug 2023 10:49:17 +0100 Subject: Replace comments with zig version --- comments/src/README.md | 0 comments/src/main.rs | 227 ----------------------- comments/src/main.zig | 294 ++++++++++++++++++++++++++++++ comments/src/migrations/0_init.sql | 2 + comments/src/migrations/1_capcha.sql | 6 + comments/src/pq.zig | 151 +++++++++++++++ comments/src/templates/badrequest.html | 6 + comments/src/templates/capchainvalid.html | 6 + comments/src/templates/comments.html | 9 + comments/src/templates/form.html | 11 ++ comments/src/templates/notfound.html | 6 + comments/src/templates/notification.txt | 5 + 12 files changed, 496 insertions(+), 227 deletions(-) create mode 100644 comments/src/README.md delete mode 100644 comments/src/main.rs create mode 100644 comments/src/main.zig create mode 100644 comments/src/migrations/0_init.sql create mode 100644 comments/src/migrations/1_capcha.sql create mode 100644 comments/src/pq.zig create mode 100644 comments/src/templates/badrequest.html create mode 100644 comments/src/templates/capchainvalid.html create mode 100644 comments/src/templates/comments.html create mode 100644 comments/src/templates/form.html create mode 100644 comments/src/templates/notfound.html create mode 100644 comments/src/templates/notification.txt (limited to 'comments/src') diff --git a/comments/src/README.md b/comments/src/README.md new file mode 100644 index 0000000..e69de29 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= - */ - -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, -} - -#[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, - Query(uq): Query -) -> Result, (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, -} -struct Comment { - author: String, - comment: String, - ts: OffsetDateTime, -} - -async fn get_comments( - State(ctx): State, - Query(uq): Query) -> Result, (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 = 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, - Form(post_comment): Form) -> Result { - 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 ".parse().unwrap()) - .to(mail_opts.notification_address.parse().unwrap()) - .subject(format!("New comment on {}", post_comment.url)) - .body(mail_body) - .unwrap(); - let mailer: AsyncSmtpTransport = - AsyncSmtpTransport::::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(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 @@ + + + +

Bad Request!

+ + \ 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 @@ + + + +

Capcha invalid!

+ + \ 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 @@ +
    +{{#comments}} +
  • + {{author}} +

    {{comment}}

    + {{ts}} +
  • +{{/comments}} +
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 @@ +
+
+
+
+
+
+
+
+
+ +
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 @@ + + + +

Not found!

+ + \ 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 -- cgit v1.2.3-ZIG