aboutsummaryrefslogtreecommitdiff
path: root/backend/src
diff options
context:
space:
mode:
Diffstat (limited to 'backend/src')
-rw-r--r--backend/src/lib/nanoid.zig287
-rw-r--r--backend/src/main.zig65
-rw-r--r--backend/src/repos/maps_repo.zig84
-rw-r--r--backend/src/repos/markers_repo.zig114
-rw-r--r--backend/src/repos/migrations/01.sql29
-rw-r--r--backend/src/repos/repos.zig62
-rw-r--r--backend/src/repos/users_repo.zig90
-rw-r--r--backend/src/services/auth_service.zig31
-rw-r--r--backend/src/services/common.zig30
-rw-r--r--backend/src/services/handler.zig69
-rw-r--r--backend/src/services/maps_service.zig39
-rw-r--r--backend/src/services/markers_service.zig31
-rw-r--r--backend/src/services/static.zig32
-rw-r--r--backend/src/services/users_service.zig8
14 files changed, 971 insertions, 0 deletions
diff --git a/backend/src/lib/nanoid.zig b/backend/src/lib/nanoid.zig
new file mode 100644
index 0000000..64ab9bd
--- /dev/null
+++ b/backend/src/lib/nanoid.zig
@@ -0,0 +1,287 @@
+// Copied from:
+// https://github.com/SasLuca/zig-nanoid/blob/91e0a9a8890984f3dcdd98c99002a05a83d0ee89/src/nanoid.zig
+// As it doesn’t support ZON build yet.
+
+const std = @import("std");
+
+/// A collection of useful alphabets that can be used to generate ids.
+pub const alphabets = struct {
+ /// Numbers from 0 to 9.
+ pub const numbers = "0123456789";
+
+ /// English hexadecimal with lowercase characters.
+ pub const hexadecimal_lowercase = numbers ++ "abcdef";
+
+ /// English hexadecimal with uppercase characters.
+ pub const hexadecimal_uppercase = numbers ++ "ABCDEF";
+
+ /// Lowercase English letters.
+ pub const lowercase = "abcdefghijklmnopqrstuvwxyz";
+
+ /// Uppercase English letters.
+ pub const uppercase = "ABCDEFGHIJKLMNOPQRSTUVWXYZ";
+
+ /// Numbers and english letters without lookalikes: 1, l, I, 0, O, o, u, v, 5, S, s, 2, Z.
+ pub const no_look_alikes = "346789ABCDEFGHJKLMNPQRTUVWXYabcdefghijkmnpqrtwxyz";
+
+ /// Same as nolookalikes but with removed vowels and following letters: 3, 4, x, X, V.
+ /// This list should protect you from accidentally getting obscene words in generated strings.
+ pub const no_look_alikes_safe = "6789BCDFGHJKLMNPQRTWbcdfghjkmnpqrtwz";
+
+ /// Combination of all the lowercase, uppercase characters and numbers from 0 to 9.
+ /// Does not include any symbols or special characters.
+ pub const alphanumeric = numbers ++ lowercase ++ uppercase;
+
+ /// URL friendly characters used by the default generate procedure.
+ pub const default = "_-" ++ alphanumeric;
+
+ /// An array of all the alphabets.
+ pub const all = [_][]const u8{
+ numbers,
+ hexadecimal_lowercase,
+ hexadecimal_uppercase,
+ lowercase,
+ uppercase,
+ no_look_alikes,
+ no_look_alikes_safe,
+ alphanumeric,
+ default,
+ };
+};
+
+/// The default length for nanoids.
+pub const default_id_len = 21;
+
+/// The mask for the default alphabet length.
+pub const default_mask = computeMask(alphabets.default.len);
+
+/// This should be enough memory for an rng step buffer when generating an id of default length regardless of alphabet length.
+/// It can be used for allocating your rng step buffer if you know the length of your id is `<= default_id_len`.
+pub const rng_step_buffer_len_sufficient_for_default_length_ids = computeSufficientRngStepBufferLengthFor(default_id_len);
+
+/// The maximum length of the alphabet accepted by the nanoid algorithm.
+pub const max_alphabet_len: u8 = std.math.maxInt(u8);
+
+/// Computes the mask necessary for the nanoid algorithm given an alphabet length.
+/// The mask is used to transform a random byte into an index into an array of length `alphabet_len`.
+///
+/// Parameters:
+/// - `alphabet_len`: the length of the alphabet used. The alphabet length must be in the range `(0, max_alphabet_len]`.
+pub fn computeMask(alphabet_len: u8) u8 {
+ std.debug.assert(alphabet_len > 0);
+
+ const clz: u5 = @clz(@as(u31, (alphabet_len - 1) | 1));
+ const mask = (@as(u32, 2) << (31 - clz)) - 1;
+ const result: u8 = @truncate(mask);
+ return result;
+}
+
+/// Computes the length necessary for a buffer which can hold the random byte in a step of a the nanoid generation algorithm given a
+/// certain alphabet length.
+///
+/// Parameters:
+/// - `id_len`: the length of the id you will generate. Can be any value.
+///
+/// - `alphabet_len`: the length of the alphabet used. The alphabet length must be in the range `(0, max_alphabet_len]`.
+pub fn computeRngStepBufferLength(id_len: usize, alphabet_len: u8) usize {
+ // @Note:
+ // Original dev notes regarding this algorithm.
+ // Source: https://github.com/ai/nanoid/blob/0454333dee4612d2c2e163d271af6cc3ce1e5aa4/index.js#L45
+ //
+ // "Next, a step determines how many random bytes to generate.
+ // The number of random bytes gets decided upon the ID length, mask,
+ // alphabet length, and magic number 1.6 (using 1.6 peaks at performance
+ // according to benchmarks)."
+
+ const id_len_f: f64 = @as(f64, @floatFromInt(id_len));
+ const mask_f: f64 = @as(f64, @floatFromInt(computeMask(alphabet_len)));
+ const alphabet_len_f: f64 = @as(f64, @floatFromInt(alphabet_len));
+ const step_buffer_len: f64 = @ceil(1.6 * mask_f * id_len_f / alphabet_len_f);
+ const result = @as(usize, @intFromFloat(step_buffer_len));
+
+ return result;
+}
+
+/// This function computes the biggest possible rng step buffer length necessary
+/// to compute an id with a max length of `max_id_len` regardless of the alphabet length.
+///
+/// Parameters:
+/// - `max_id_len`: The biggest id length for which the step buffer length needs to be sufficient.
+pub fn computeSufficientRngStepBufferLengthFor(max_id_len: usize) usize {
+ @setEvalBranchQuota(2500);
+ var max_step_buffer_len: usize = 0;
+ var i: u9 = 1;
+ while (i <= max_alphabet_len) : (i += 1) {
+ const alphabet_len: u8 = @truncate(i);
+ const step_buffer_len = computeRngStepBufferLength(max_id_len, alphabet_len);
+
+ if (step_buffer_len > max_step_buffer_len) {
+ max_step_buffer_len = step_buffer_len;
+ }
+ }
+
+ return max_step_buffer_len;
+}
+
+/// Generates a nanoid inside `result_buffer` and returns it back to the caller.
+///
+/// Parameters:
+/// - `rng`: a random number generator.
+/// Provide a secure one such as `std.Random.DefaultCsprng` and seed it properly if you have security concerns.
+/// See `Regarding RNGs` in `readme.md` for more information.
+///
+/// - `alphabet`: an array of the bytes that will be used in the id, its length must be in the range `(0, max_alphabet_len]`.
+/// Consider the options from `nanoid.alphabets`.
+///
+/// - `result_buffer`: is an output buffer that will be filled *completely* with random bytes from `alphabet`, thus generating an id of
+/// length `result_buffer.len`. This buffer will be returned at the end of the function.
+///
+/// - `step_buffer`: The buffer will be filled with random bytes using `rng.bytes()`.
+/// Must be at least `computeRngStepBufferLength(computeMask(@truncate(alphabet.len)), result_buffer.len, alphabet.len)` bytes.
+pub fn generateEx(rng: std.Random, alphabet: []const u8, result_buffer: []u8, step_buffer: []u8) []u8 {
+ std.debug.assert(alphabet.len > 0 and alphabet.len <= max_alphabet_len);
+
+ const alphabet_len: u8 = @truncate(alphabet.len);
+ const mask = computeMask(alphabet_len);
+ const necessary_step_buffer_len = computeRngStepBufferLength(result_buffer.len, alphabet_len);
+ const actual_step_buffer = step_buffer[0..necessary_step_buffer_len];
+
+ var result_iter: usize = 0;
+ while (true) {
+ rng.bytes(actual_step_buffer);
+
+ for (actual_step_buffer) |it| {
+ const alphabet_index = it & mask;
+
+ if (alphabet_index >= alphabet_len) {
+ continue;
+ }
+
+ result_buffer[result_iter] = alphabet[alphabet_index];
+
+ if (result_iter == result_buffer.len - 1) {
+ return result_buffer;
+ } else {
+ result_iter += 1;
+ }
+ }
+ }
+}
+
+/// Generates a nanoid inside `result_buffer` and returns it back to the caller.
+///
+/// This function will use `rng.int` instead of `rng.bytes` thus avoiding the need for a step buffer.
+/// Depending on your choice of rng this can be useful, since you avoid the need for a step buffer,
+/// but repeated calls to `rng.int` might be slower than a single call `rng.bytes`.
+///
+/// Parameters:
+/// - `rng`: a random number generator.
+/// Provide a secure one such as `std.Random.DefaultCsprng` and seed it properly if you have security concerns.
+/// See `Regarding RNGs` in `readme.md` for more information.
+///
+/// - `alphabet`: an array of the bytes that will be used in the id, its length must be in the range `(0, max_alphabet_len]`.
+/// Consider the options from `nanoid.alphabets`.
+///
+/// - `result_buffer` is an output buffer that will be filled *completely* with random bytes from `alphabet`, thus generating an id of
+/// length `result_buffer.len`. This buffer will be returned at the end of the function.
+pub fn generateExWithIterativeRng(rng: std.Random, alphabet: []const u8, result_buffer: []u8) []u8 {
+ std.debug.assert(result_buffer.len > 0);
+ std.debug.assert(alphabet.len > 0 and alphabet.len <= max_alphabet_len);
+
+ const alphabet_len: u8 = @truncate(alphabet.len);
+ const mask = computeMask(alphabet_len);
+
+ var result_iter: usize = 0;
+ while (true) {
+ const random_byte = rng.int(u8);
+
+ const alphabet_index = random_byte & mask;
+
+ if (alphabet_index >= alphabet_len) {
+ continue;
+ }
+
+ result_buffer[result_iter] = alphabet[alphabet_index];
+
+ if (result_iter == result_buffer.len - 1) {
+ return result_buffer;
+ } else {
+ result_iter += 1;
+ }
+ }
+
+ return result_buffer;
+}
+
+/// Generates a nanoid using the provided alphabet.
+///
+/// Parameters:
+///
+/// - `rng`: a random number generator.
+/// Provide a secure one such as `std.Random.DefaultCsprng` and seed it properly if you have security concerns.
+/// See `Regarding RNGs` in `README.md` for more information.
+///
+/// - `alphabet`: an array of the bytes that will be used in the id, its length must be in the range `(0, max_alphabet_len]`.
+pub fn generateWithAlphabet(rng: std.Random, alphabet: []const u8) [default_id_len]u8 {
+ var nanoid: [default_id_len]u8 = undefined;
+ var step_buffer: [rng_step_buffer_len_sufficient_for_default_length_ids]u8 = undefined;
+ _ = generateEx(rng, alphabet, &nanoid, &step_buffer);
+ return nanoid;
+}
+
+/// Generates a nanoid using the default alphabet.
+///
+/// Parameters:
+///
+/// - `rng`: a random number generator.
+/// Provide a secure one such as `std.Random.DefaultCsprng` and seed it properly if you have security concerns.
+/// See `Regarding RNGs` in `README.md` for more information.
+pub fn generate(rng: std.Random) [default_id_len]u8 {
+ const result = generateWithAlphabet(rng, alphabets.default);
+ return result;
+}
+
+/// Non public utility functions used mostly in unit tests.
+const internal_utils = struct {
+ fn makeDefaultCsprng() std.Random.DefaultCsprng {
+ // Generate seed
+ var seed: [std.Random.DefaultCsprng.secret_seed_length]u8 = undefined;
+ std.crypto.random.bytes(&seed);
+
+ // Initialize the rng and allocator
+ const rng = std.Random.DefaultCsprng.init(seed);
+ return rng;
+ }
+
+ fn makeDefaultPrngWithConstantSeed() std.Random.DefaultPrng {
+ const rng = std.Random.DefaultPrng.init(0);
+ return rng;
+ }
+
+ fn makeDefaultCsprngWithConstantSeed() std.Random.DefaultCsprng {
+ // Generate seed
+ const seed: [std.Random.DefaultCsprng.secret_seed_length]u8 = undefined;
+ for (seed) |*it| it.* = 'a';
+
+ // Initialize the rng and allocator
+ const rng = std.Random.DefaultCsprng.init(seed);
+ return rng;
+ }
+
+ /// Taken from https://github.com/codeyu/nanoid-net/blob/445f4d363e0079e151ea414dab1a9f9961679e7e/test/Nanoid.Test/NanoidTest.cs#L145
+ fn toBeCloseTo(actual: f64, expected: f64, precision: f64) bool {
+ const pass = @abs(expected - actual) < std.math.pow(f64, 10, -precision) / 2;
+ return pass;
+ }
+
+ /// Checks if all elements in `array` are present in `includedIn`.
+ fn allIn(comptime T: type, array: []const T, includedIn: []const T) bool {
+ for (array) |it| {
+ if (std.mem.indexOfScalar(u8, includedIn, it) == null) {
+ return false;
+ }
+ }
+
+ return true;
+ }
+};
diff --git a/backend/src/main.zig b/backend/src/main.zig
new file mode 100644
index 0000000..5363392
--- /dev/null
+++ b/backend/src/main.zig
@@ -0,0 +1,65 @@
+const Allocator = std.mem.Allocator;
+const httpz = @import("httpz");
+const std = @import("std");
+
+const repos = @import("repos/repos.zig");
+
+const static = @import("services/static.zig");
+const handler = @import("services/handler.zig");
+const auth_service = @import("services/auth_service.zig");
+const users_service = @import("services/users_service.zig");
+const maps_service = @import("services/maps_service.zig");
+const markers_service = @import("services/markers_service.zig");
+
+pub fn main() !void {
+
+ // ENV
+ const env = std.posix.getenv;
+ const port = try std.fmt.parseInt(u16, env("PORT") orelse "3000", 10);
+ const db_path = env("DB_PATH") orelse "db.sqlite3";
+ const secure_tokens = if (std.mem.eql(u8, env("SECURE_TOKENS") orelse "false", "true")) true else false;
+
+ // Allocator
+ var gpa = std.heap.GeneralPurposeAllocator(.{}){};
+ const allocator = gpa.allocator();
+ defer _ = gpa.deinit();
+
+ // DB connection
+ var conn = try repos.init(allocator, db_path);
+ defer conn.close();
+
+ var h = handler.Handler{ .conn = conn, .secure_tokens = secure_tokens };
+ var server = try httpz.Server(*handler.Handler).init(allocator, .{
+ .port = port,
+ }, &h);
+ defer server.deinit();
+ defer server.stop();
+
+ var router = try server.router(.{});
+
+ const public_route = &handler.RouteData{ .is_public = true };
+
+ // Static files
+ router.get("/public/main.css", static.main_css, .{ .data = public_route });
+ router.get("/public/main.js", static.main_js, .{ .data = public_route });
+ router.get("/public/icon.png", static.icon_png, .{ .data = public_route });
+
+ // API
+ router.post("/api/login", auth_service.login, .{ .data = public_route });
+ router.post("/api/logout", auth_service.logout, .{});
+ router.get("/api/user", users_service.get_user, .{});
+ // Maps
+ router.get("/api/maps", maps_service.list, .{});
+ router.get("/api/maps/:id", maps_service.get, .{});
+ router.post("/api/maps", maps_service.create, .{});
+ router.put("/api/maps/:id", maps_service.update, .{});
+ router.delete("/api/maps/:id", maps_service.delete, .{});
+ // Markers
+ router.get("/api/markers", markers_service.list_by_map, .{});
+ router.post("/api/markers", markers_service.create, .{});
+ router.put("/api/markers/:id", markers_service.update, .{});
+ router.delete("/api/markers/:id", markers_service.delete, .{});
+
+ std.debug.print("listening http://localhost:{d}/\n", .{port});
+ try server.listen();
+}
diff --git a/backend/src/repos/maps_repo.zig b/backend/src/repos/maps_repo.zig
new file mode 100644
index 0000000..8031827
--- /dev/null
+++ b/backend/src/repos/maps_repo.zig
@@ -0,0 +1,84 @@
+const std = @import("std");
+const zqlite = @import("zqlite");
+const nanoid = @import("../lib/nanoid.zig");
+
+pub const Map = struct {
+ id: []const u8,
+ name: []const u8,
+};
+
+pub fn get_maps(allocator: std.mem.Allocator, conn: zqlite.Conn) !std.ArrayList(Map) {
+ const query =
+ \\SELECT id, name
+ \\FROM maps
+ ;
+
+ var rows = try conn.rows(query, .{});
+ defer rows.deinit();
+
+ var list = std.ArrayList(Map).init(allocator);
+ while (rows.next()) |row| {
+ try list.append(Map{
+ .id = try allocator.dupe(u8, row.text(0)),
+ .name = try allocator.dupe(u8, row.text(1)),
+ });
+ }
+ if (rows.err) |err| return err;
+
+ return list;
+}
+
+pub fn get_map(allocator: std.mem.Allocator, conn: zqlite.Conn, id: []const u8) !?Map {
+ const query =
+ \\SELECT id, name
+ \\FROM maps
+ \\WHERE id = ?
+ ;
+
+ if (try conn.row(query, .{id})) |row| {
+ defer row.deinit();
+ return Map{
+ .id = try allocator.dupe(u8, row.text(0)),
+ .name = try allocator.dupe(u8, row.text(1)),
+ };
+ } else {
+ return null;
+ }
+}
+
+pub fn create(allocator: std.mem.Allocator, conn: zqlite.Conn, name: []const u8) !Map {
+ const query =
+ \\INSERT INTO maps(id, name)
+ \\VALUES (?, ?)
+ ;
+
+ const id: []const u8 = &nanoid.generate(std.crypto.random);
+ try conn.exec(query, .{ id, name });
+ return Map{
+ .id = try allocator.dupe(u8, id),
+ .name = name,
+ };
+}
+
+pub fn update(conn: zqlite.Conn, id: []const u8, name: []const u8) !Map {
+ const query =
+ \\UPDATE maps
+ \\SET name = ?, updated_at = datetime()
+ \\WHERE id = ?
+ ;
+
+ try conn.exec(query, .{ name, id });
+ return Map{
+ .id = id,
+ .name = name,
+ };
+}
+
+pub fn delete(conn: zqlite.Conn, id: []const u8) !void {
+ const query =
+ \\DELETE FROM maps
+ \\WHERE id = ?
+ ;
+
+ try conn.exec(query, .{id});
+}
diff --git a/backend/src/repos/markers_repo.zig b/backend/src/repos/markers_repo.zig
new file mode 100644
index 0000000..232b8b6
--- /dev/null
+++ b/backend/src/repos/markers_repo.zig
@@ -0,0 +1,114 @@
+const std = @import("std");
+const zqlite = @import("zqlite");
+const nanoid = @import("../lib/nanoid.zig");
+
+pub const Marker = struct {
+ id: []const u8,
+ lat: f64,
+ lng: f64,
+ color: []const u8,
+ name: []const u8,
+ description: []const u8,
+ icon: []const u8,
+ radius: i64,
+};
+
+pub fn get_markers(allocator: std.mem.Allocator, conn: zqlite.Conn, map_id: []const u8) !std.ArrayList(Marker) {
+ const query =
+ \\SELECT id, lat, lng, color, name, description, icon, radius
+ \\FROM markers
+ \\WHERE map_id = ?
+ ;
+
+ var rows = try conn.rows(query, .{map_id});
+
+ defer rows.deinit();
+
+ var list = std.ArrayList(Marker).init(allocator);
+ while (rows.next()) |row| {
+ try list.append(Marker{
+ .id = try allocator.dupe(u8, row.text(0)),
+ .lat = row.float(1),
+ .lng = row.float(2),
+ .color = try allocator.dupe(u8, row.text(3)),
+ .name = try allocator.dupe(u8, row.text(4)),
+ .description = try allocator.dupe(u8, row.text(5)),
+ .icon = try allocator.dupe(u8, row.text(6)),
+ .radius = row.int(7),
+ });
+ }
+ if (rows.err) |err| return err;
+
+ return list;
+}
+
+pub const Payload = struct {
+ lat: f64,
+ lng: f64,
+ color: []const u8,
+ name: []const u8,
+ description: []const u8,
+ icon: []const u8,
+ radius: i64,
+};
+
+pub fn create(allocator: std.mem.Allocator, conn: zqlite.Conn, map_id: []const u8, p: Payload) !Marker {
+ const query =
+ \\INSERT INTO markers(id, map_id, lat, lng, color, name, description, icon, radius)
+ \\VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
+ ;
+
+ const id: []const u8 = &nanoid.generate(std.crypto.random);
+
+ try conn.exec(query, .{ id, map_id, p.lat, p.lng, p.color, p.name, p.description, p.icon, p.radius });
+
+ return Marker{
+ .id = try allocator.dupe(u8, id),
+ .lat = p.lat,
+ .lng = p.lng,
+ .color = p.color,
+ .name = p.name,
+ .description = p.description,
+ .icon = p.icon,
+ .radius = p.radius,
+ };
+}
+
+pub fn update(conn: zqlite.Conn, id: []const u8, p: Payload) !Marker {
+ const query =
+ \\UPDATE markers
+ \\SET lat = ?, lng = ?, color = ?, name = ?, description = ?, icon = ?, radius = ?, updated_at = datetime()
+ \\WHERE id = ?
+ ;
+
+ try conn.exec(query, .{ p.lat, p.lng, p.color, p.name, p.description, p.icon, p.radius, id });
+
+ return Marker{
+ .id = id,
+ .lat = p.lat,
+ .lng = p.lng,
+ .color = p.color,
+ .name = p.name,
+ .description = p.description,
+ .icon = p.icon,
+ .radius = p.radius,
+ };
+}
+
+pub fn delete(conn: zqlite.Conn, id: []const u8) !void {
+ const query =
+ \\DELETE FROM markers
+ \\WHERE id = ?
+ ;
+
+ try conn.exec(query, .{id});
+}
+
+pub fn delete_by_map_id(conn: zqlite.Conn, map_id: []const u8) !void {
+ const query =
+ \\DELETE FROM markers
+ \\WHERE map_id = ?
+ ;
+
+ try conn.exec(query, .{map_id});
+}
diff --git a/backend/src/repos/migrations/01.sql b/backend/src/repos/migrations/01.sql
new file mode 100644
index 0000000..cf4131d
--- /dev/null
+++ b/backend/src/repos/migrations/01.sql
@@ -0,0 +1,29 @@
+CREATE TABLE IF NOT EXISTS "users" (
+ "email" TEXT PRIMARY KEY,
+ "created_at" TEXT NOT NULL DEFAULT (datetime()),
+ "updated_at" TEXT NOT NULL DEFAULT (datetime()),
+ "password_hash" TEXT NOT NULL,
+ "name" TEXT NOT NULL,
+ "login_token" TEXT NULL
+) STRICT;
+
+CREATE TABLE IF NOT EXISTS "maps" (
+ "id" TEXT PRIMARY KEY,
+ "created_at" TEXT NOT NULL DEFAULT (datetime()),
+ "updated_at" TEXT NOT NULL DEFAULT (datetime()),
+ "name" TEXT NOT NULL
+) STRICT;
+
+CREATE TABLE IF NOT EXISTS "markers" (
+ "id" TEXT PRIMARY KEY,
+ "created_at" TEXT NOT NULL DEFAULT (datetime()),
+ "updated_at" TEXT NOT NULL DEFAULT (datetime()),
+ "map_id" TEXT NOT NULL REFERENCES maps(id),
+ "lat" REAL NOT NULL,
+ "lng" REAL NOT NULL,
+ "color" TEXT NOT NULL,
+ "name" TEXT NULL,
+ "description" TEXT NULL,
+ "icon" TEXT NULL,
+ "radius" INTEGER NULL
+) STRICT;
diff --git a/backend/src/repos/repos.zig b/backend/src/repos/repos.zig
new file mode 100644
index 0000000..860811d
--- /dev/null
+++ b/backend/src/repos/repos.zig
@@ -0,0 +1,62 @@
+const std = @import("std");
+const zqlite = @import("zqlite");
+
+pub fn init(allocator: std.mem.Allocator, path: [*:0]const u8) !zqlite.Conn {
+ const flags = zqlite.OpenFlags.Create | zqlite.OpenFlags.EXResCode;
+ const conn = try zqlite.open(path, flags);
+ try conn.execNoArgs("PRAGMA foreign_keys = ON");
+ try conn.execNoArgs("PRAGMA journal_mode = WAL");
+ try migrate(allocator, conn, .{@embedFile("migrations/01.sql")});
+ return conn;
+}
+
+fn migrate(allocator: std.mem.Allocator, conn: zqlite.Conn, migrations: [1][]const u8) !void {
+ var version: i64 = 0;
+ if (try conn.row("PRAGMA user_version", .{})) |row| {
+ defer row.deinit();
+ version = row.int(0);
+ }
+
+ // Start transaction
+ try conn.transaction();
+ errdefer conn.rollback();
+
+ for (migrations, 1..) |migration, i| {
+ const v: i64 = @intCast(i);
+ if (version < v) {
+ std.debug.print("Applying migration: {d}\n", .{i});
+
+ var it = std.mem.splitScalar(u8, migration, ';');
+ while (it.next()) |statement| {
+ if (!is_statement_empty(statement)) {
+ try conn.exec(statement, .{});
+ }
+ }
+
+ try set_version(allocator, conn, @intCast(i));
+ }
+ }
+
+ // Commit transaction
+ try conn.commit();
+}
+
+fn is_statement_empty(str: []const u8) bool {
+ for (str) |c| {
+ if (c == ' ') continue;
+ if (c == '\n') continue;
+ if (c == '\t') continue;
+ return false;
+ }
+ return true;
+}
+
+fn set_version(allocator: std.mem.Allocator, conn: zqlite.Conn, version: i64) !void {
+ const string = try std.fmt.allocPrint(
+ allocator,
+ "PRAGMA user_version = {d}",
+ .{version},
+ );
+ defer allocator.free(string);
+ try conn.exec(string, .{});
+}
diff --git a/backend/src/repos/users_repo.zig b/backend/src/repos/users_repo.zig
new file mode 100644
index 0000000..0f4ea82
--- /dev/null
+++ b/backend/src/repos/users_repo.zig
@@ -0,0 +1,90 @@
+const std = @import("std");
+const zqlite = @import("zqlite");
+const bcrypt = std.crypto.pwhash.bcrypt;
+const rand = std.crypto.random;
+
+const token_length: usize = 20;
+
+pub const User = struct {
+ email: []const u8,
+ name: []const u8,
+};
+
+pub fn get_user(allocator: std.mem.Allocator, conn: zqlite.Conn, login_token: []const u8) !?User {
+ const query =
+ \\SELECT email, name
+ \\FROM users
+ \\WHERE login_token = ?
+ ;
+
+ if (try conn.row(query, .{login_token})) |row| {
+ defer row.deinit();
+ return User{
+ .email = try allocator.dupe(u8, row.text(0)),
+ .name = try allocator.dupe(u8, row.text(1)),
+ };
+ } else {
+ return null;
+ }
+}
+
+pub fn check_password(allocator: std.mem.Allocator, conn: zqlite.Conn, email: []const u8, password: []const u8) !?User {
+ const query =
+ \\SELECT password_hash, email, name
+ \\FROM users
+ \\WHERE email = ?
+ ;
+
+ if (try conn.row(query, .{email})) |row| {
+ defer row.deinit();
+ const hash = row.text(0);
+ const verify_options = bcrypt.VerifyOptions{ .silently_truncate_password = false };
+ std.time.sleep(100000000 + rand.intRangeAtMost(u32, 0, 100000000));
+ bcrypt.strVerify(hash, password, verify_options) catch {
+ return null;
+ };
+ return User{
+ .email = try allocator.dupe(u8, row.text(1)),
+ .name = try allocator.dupe(u8, row.text(2)),
+ };
+ } else {
+ std.time.sleep(500000000 + rand.intRangeAtMost(u32, 0, 500000000));
+ return null;
+ }
+}
+
+pub fn generate_login_token(allocator: std.mem.Allocator, conn: zqlite.Conn, email: []const u8) ![]const u8 {
+ const query =
+ \\UPDATE users
+ \\SET login_token = ?, updated_at = datetime()
+ \\WHERE email = ?
+ ;
+
+ const token = try random_token(allocator);
+ try conn.exec(query, .{ token, email });
+ return token;
+}
+
+fn random_token(allocator: std.mem.Allocator) ![]const u8 {
+ // Generate random bytes
+ var bytes: [token_length]u8 = undefined;
+ rand.bytes(&bytes);
+
+ // Encode as base64
+ const Encoder = std.base64.standard.Encoder;
+ const encoded_length = Encoder.calcSize(token_length);
+ const token = try allocator.alloc(u8, encoded_length);
+ _ = Encoder.encode(token, &bytes);
+
+ return token;
+}
+
+pub fn remove_login_token(conn: zqlite.Conn, email: []const u8) !void {
+ const query =
+ \\ UPDATE users
+ \\ SET login_token = NULL, updated_at = datetime()
+ \\ WHERE email = ?
+ ;
+
+ try conn.exec(query, .{email});
+}
diff --git a/backend/src/services/auth_service.zig b/backend/src/services/auth_service.zig
new file mode 100644
index 0000000..1a39584
--- /dev/null
+++ b/backend/src/services/auth_service.zig
@@ -0,0 +1,31 @@
+const httpz = @import("httpz");
+
+const common = @import("common.zig");
+const users_repo = @import("../repos/users_repo.zig");
+
+const Login = struct { email: []const u8, password: []const u8 };
+
+pub fn login(env: *common.Env, req: *httpz.Request, res: *httpz.Response) !void {
+ const payload = try common.with_body(Login, req);
+
+ const user = try users_repo.check_password(res.arena, env.conn, payload.email, payload.password) orelse return common.ServiceError.Forbidden;
+ const login_token = try users_repo.generate_login_token(res.arena, env.conn, payload.email);
+ try res.setCookie("token", login_token, .{
+ .max_age = 31 * 24 * 60 * 60, // 31 days in seconds
+ .secure = env.secure_tokens,
+ .http_only = true,
+ .same_site = .strict,
+ });
+ try res.json(user, .{});
+}
+
+pub fn logout(env: *common.Env, _: *httpz.Request, res: *httpz.Response) !void {
+ const user = env.user orelse return common.ServiceError.NotFound;
+ try users_repo.remove_login_token(env.conn, user.email);
+ try res.setCookie("token", "", .{
+ .max_age = 0, // Expires immediately
+ .secure = env.secure_tokens,
+ .http_only = true,
+ .same_site = .strict,
+ });
+}
diff --git a/backend/src/services/common.zig b/backend/src/services/common.zig
new file mode 100644
index 0000000..42d18e9
--- /dev/null
+++ b/backend/src/services/common.zig
@@ -0,0 +1,30 @@
+const std = @import("std");
+const zqlite = @import("zqlite");
+const httpz = @import("httpz");
+
+const users_repo = @import("../repos/users_repo.zig");
+
+pub const Env = struct {
+ conn: zqlite.Conn,
+ secure_tokens: bool,
+ user: ?users_repo.User,
+};
+
+pub const ServiceError = error{
+ BadRequest,
+ NotFound,
+ Forbidden,
+};
+
+pub fn with_body(comptime T: type, req: *httpz.Request) !T {
+ if (req.body()) |body| {
+ if (std.json.parseFromSlice(T, req.arena, body, .{})) |parsed| {
+ defer parsed.deinit();
+ return parsed.value;
+ } else |_| {
+ return ServiceError.BadRequest;
+ }
+ } else {
+ return ServiceError.BadRequest;
+ }
+}
diff --git a/backend/src/services/handler.zig b/backend/src/services/handler.zig
new file mode 100644
index 0000000..e8c92d4
--- /dev/null
+++ b/backend/src/services/handler.zig
@@ -0,0 +1,69 @@
+const httpz = @import("httpz");
+const std = @import("std");
+const zqlite = @import("zqlite");
+
+const common = @import("common.zig");
+const static = @import("static.zig");
+const users_repo = @import("../repos/users_repo.zig");
+
+pub const RouteData = struct {
+ is_public: bool,
+};
+
+pub const Handler = struct {
+ conn: zqlite.Conn,
+ secure_tokens: bool,
+
+ pub fn dispatch(self: *Handler, action: httpz.Action(*common.Env), req: *httpz.Request, res: *httpz.Response) !void {
+ var user: ?users_repo.User = null;
+ if (!is_route_public(req)) {
+ const cookies = req.cookies();
+
+ const login_token = cookies.get("token") orelse return common.ServiceError.Forbidden;
+ user = try users_repo.get_user(res.arena, self.conn, login_token) orelse return common.ServiceError.Forbidden;
+ }
+
+ var env = common.Env{ .conn = self.conn, .secure_tokens = self.secure_tokens, .user = user };
+
+ try action(&env, req, res);
+ }
+
+ pub fn notFound(handler: *Handler, req: *httpz.Request, res: *httpz.Response) !void {
+ const path = req.url.path;
+ if (path.len >= 5 and std.mem.eql(u8, path[0..5], "/api/")) {
+ return common.ServiceError.NotFound;
+ } else {
+ // non API route, let client router take care of that
+ var env = common.Env{ .conn = handler.conn, .secure_tokens = handler.secure_tokens, .user = null };
+ try static.index(&env, req, res);
+ }
+ }
+
+ pub fn uncaughtError(_: *Handler, req: *httpz.Request, res: *httpz.Response, err: anyerror) void {
+ switch (err) {
+ common.ServiceError.BadRequest => error_response(res, 400, "Bad Request"),
+ common.ServiceError.NotFound => error_response(res, 404, "Not Found"),
+ common.ServiceError.Forbidden => error_response(res, 403, "Forbidden"),
+ else => {
+ std.debug.print("Internal Server Error at {s}: {}\n", .{ req.url.path, err });
+ error_response(res, 500, "Internal Server Error");
+ },
+ }
+ }
+};
+
+fn is_route_public(req: *httpz.Request) bool {
+ if (req.route_data) |rd| {
+ const route_data: *const RouteData = @ptrCast(@alignCast(rd));
+ return route_data.is_public;
+ } else {
+ return false;
+ }
+}
+
+fn error_response(res: *httpz.Response, code: u16, message: []const u8) void {
+ res.status = code;
+ res.json(.{ .message = message }, .{}) catch {
+ res.body = message;
+ };
+}
diff --git a/backend/src/services/maps_service.zig b/backend/src/services/maps_service.zig
new file mode 100644
index 0000000..d634383
--- /dev/null
+++ b/backend/src/services/maps_service.zig
@@ -0,0 +1,39 @@
+const httpz = @import("httpz");
+
+const maps_repo = @import("../repos/maps_repo.zig");
+const markers_repo = @import("../repos/markers_repo.zig");
+const common = @import("common.zig");
+
+pub fn list(env: *common.Env, _: *httpz.Request, res: *httpz.Response) !void {
+ const maps = try maps_repo.get_maps(res.arena, env.conn);
+ try res.json(maps.items, .{});
+}
+
+pub fn get(env: *common.Env, req: *httpz.Request, res: *httpz.Response) !void {
+ const id = req.param("id").?;
+ const map = try maps_repo.get_map(res.arena, env.conn, id);
+ try res.json(map, .{});
+}
+
+const CreateMap = struct { name: []const u8 };
+
+pub fn create(env: *common.Env, req: *httpz.Request, res: *httpz.Response) !void {
+ const payload = try common.with_body(CreateMap, req);
+ const map = try maps_repo.create(res.arena, env.conn, payload.name);
+ try res.json(map, .{});
+}
+
+const UpdateMap = struct { name: []const u8 };
+
+pub fn update(env: *common.Env, req: *httpz.Request, res: *httpz.Response) !void {
+ const id = req.param("id").?;
+ const payload = try common.with_body(UpdateMap, req);
+ const map = try maps_repo.update(env.conn, id, payload.name);
+ try res.json(map, .{});
+}
+
+pub fn delete(env: *common.Env, req: *httpz.Request, _: *httpz.Response) !void {
+ const id = req.param("id").?;
+ try markers_repo.delete_by_map_id(env.conn, id);
+ try maps_repo.delete(env.conn, id);
+}
diff --git a/backend/src/services/markers_service.zig b/backend/src/services/markers_service.zig
new file mode 100644
index 0000000..9e69682
--- /dev/null
+++ b/backend/src/services/markers_service.zig
@@ -0,0 +1,31 @@
+const httpz = @import("httpz");
+
+const markers_repo = @import("../repos/markers_repo.zig");
+const common = @import("common.zig");
+
+pub fn list_by_map(env: *common.Env, req: *httpz.Request, res: *httpz.Response) !void {
+ const query = try req.query();
+ const map_id = query.get("map").?;
+ const maps = try markers_repo.get_markers(res.arena, env.conn, map_id);
+ try res.json(maps.items, .{});
+}
+
+pub fn create(env: *common.Env, req: *httpz.Request, res: *httpz.Response) !void {
+ const query = try req.query();
+ const map_id = query.get("map").?;
+ const payload = try common.with_body(markers_repo.Payload, req);
+ const marker = try markers_repo.create(res.arena, env.conn, map_id, payload);
+ try res.json(marker, .{});
+}
+
+pub fn update(env: *common.Env, req: *httpz.Request, res: *httpz.Response) !void {
+ const id = req.param("id").?;
+ const payload = try common.with_body(markers_repo.Payload, req);
+ const map = try markers_repo.update(env.conn, id, payload);
+ try res.json(map, .{});
+}
+
+pub fn delete(env: *common.Env, req: *httpz.Request, _: *httpz.Response) !void {
+ const id = req.param("id").?;
+ try markers_repo.delete(env.conn, id);
+}
diff --git a/backend/src/services/static.zig b/backend/src/services/static.zig
new file mode 100644
index 0000000..f236737
--- /dev/null
+++ b/backend/src/services/static.zig
@@ -0,0 +1,32 @@
+const httpz = @import("httpz");
+const std = @import("std");
+
+const common = @import("common.zig");
+
+pub fn index(_: *common.Env, _: *httpz.Request, res: *httpz.Response) !void {
+ try static_file(res, "public/index.html", httpz.ContentType.HTML);
+}
+
+pub fn main_css(_: *common.Env, _: *httpz.Request, res: *httpz.Response) !void {
+ try static_file(res, "public/main.css", httpz.ContentType.CSS);
+}
+
+pub fn main_js(_: *common.Env, _: *httpz.Request, res: *httpz.Response) !void {
+ try static_file(res, "public/main.js", httpz.ContentType.JS);
+}
+
+pub fn icon_png(_: *common.Env, _: *httpz.Request, res: *httpz.Response) !void {
+ try static_file(res, "public/icon.png", httpz.ContentType.PNG);
+}
+
+fn static_file(res: *httpz.Response, path: []const u8, content_type: httpz.ContentType) !void {
+ const file = try std.fs.cwd().openFile(path, .{});
+ defer file.close();
+
+ const stat = try file.stat();
+ const buf: []u8 = try file.readToEndAlloc(res.arena, stat.size);
+ res.body = buf;
+
+ res.content_type = content_type;
+ res.header("cache-control", "no-cache, no-store, must-revalidate");
+}
diff --git a/backend/src/services/users_service.zig b/backend/src/services/users_service.zig
new file mode 100644
index 0000000..8547437
--- /dev/null
+++ b/backend/src/services/users_service.zig
@@ -0,0 +1,8 @@
+const httpz = @import("httpz");
+
+const common = @import("common.zig");
+
+pub fn get_user(env: *common.Env, _: *httpz.Request, res: *httpz.Response) !void {
+ const user = env.user orelse return common.ServiceError.NotFound;
+ try res.json(user, .{});
+}