diff options
Diffstat (limited to 'backend/src')
-rw-r--r-- | backend/src/lib/nanoid.zig | 287 | ||||
-rw-r--r-- | backend/src/main.zig | 65 | ||||
-rw-r--r-- | backend/src/repos/maps_repo.zig | 84 | ||||
-rw-r--r-- | backend/src/repos/markers_repo.zig | 114 | ||||
-rw-r--r-- | backend/src/repos/migrations/01.sql | 29 | ||||
-rw-r--r-- | backend/src/repos/repos.zig | 62 | ||||
-rw-r--r-- | backend/src/repos/users_repo.zig | 90 | ||||
-rw-r--r-- | backend/src/services/auth_service.zig | 31 | ||||
-rw-r--r-- | backend/src/services/common.zig | 30 | ||||
-rw-r--r-- | backend/src/services/handler.zig | 69 | ||||
-rw-r--r-- | backend/src/services/maps_service.zig | 39 | ||||
-rw-r--r-- | backend/src/services/markers_service.zig | 31 | ||||
-rw-r--r-- | backend/src/services/static.zig | 32 | ||||
-rw-r--r-- | backend/src/services/users_service.zig | 8 |
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, .{}); +} |