From 632eef6424d8dc8d40c2906177892697679e7b85 Mon Sep 17 00:00:00 2001 From: Joris Date: Sat, 19 Apr 2025 12:36:38 +0200 Subject: Add ZIG server --- .gitignore | 6 +- .tmuxinator.yml | 19 + Makefile | 9 - README.md | 28 +- backend/bin/dev-server | 20 + backend/build.zig | 49 + backend/build.zig.zon | 14 + backend/fixtures.sql | 17 + backend/src/lib/nanoid.zig | 287 + backend/src/main.zig | 65 + backend/src/repos/maps_repo.zig | 84 + backend/src/repos/markers_repo.zig | 114 + backend/src/repos/migrations/01.sql | 29 + backend/src/repos/repos.zig | 62 + backend/src/repos/users_repo.zig | 90 + backend/src/services/auth_service.zig | 31 + backend/src/services/common.zig | 30 + backend/src/services/handler.zig | 69 + backend/src/services/maps_service.zig | 39 + backend/src/services/markers_service.zig | 31 + backend/src/services/static.zig | 32 + backend/src/services/users_service.zig | 8 + bin/dev | 22 + bin/dev-server | 16 - flake.lock | 73 +- flake.nix | 63 +- frontend/Makefile | 16 + frontend/bin/compile-sass | 3 + frontend/bin/compile-ts | 6 + frontend/bin/dev-server | 23 + frontend/html/index.html | 11 + frontend/images/icon.png | Bin 0 -> 4525 bytes frontend/styles/colors.sass | 7 + frontend/styles/form.sass | 123 + frontend/styles/leaflet.css | 656 ++ frontend/styles/lib/icons.sass | 15 + frontend/styles/main.sass | 71 + frontend/styles/pages/login.sass | 12 + frontend/styles/pages/map.sass | 84 + frontend/styles/pages/maps.sass | 2 + frontend/styles/ui/layout.sass | 25 + frontend/styles/ui/modal.sass | 57 + frontend/ts/src/lib/color.ts | 36 + frontend/ts/src/lib/form.ts | 175 + frontend/ts/src/lib/icons.ts | 94 + frontend/ts/src/lib/leaflet.d.ts | 93 + frontend/ts/src/lib/leaflet.js | 14421 +++++++++++++++++++++++++++++ frontend/ts/src/lib/loadable.ts | 73 + frontend/ts/src/lib/router.ts | 25 + frontend/ts/src/lib/rx.ts | 781 ++ frontend/ts/src/main.ts | 79 + frontend/ts/src/models/map.ts | 4 + frontend/ts/src/models/marker.ts | 18 + frontend/ts/src/models/user.ts | 4 + frontend/ts/src/pages/layout.ts | 37 + frontend/ts/src/pages/login.ts | 67 + frontend/ts/src/pages/map.ts | 231 + frontend/ts/src/pages/map/footer.ts | 169 + frontend/ts/src/pages/map/marker.ts | 129 + frontend/ts/src/pages/map/markerForm.ts | 172 + frontend/ts/src/pages/maps.ts | 94 + frontend/ts/src/pages/notFound.ts | 5 + frontend/ts/src/request.ts | 63 + frontend/ts/src/route.ts | 59 + frontend/ts/src/ui/layout.ts | 34 + frontend/ts/src/ui/modal.ts | 67 + frontend/ts/tsconfig.json | 12 + public/icon.png | Bin 4525 -> 0 bytes public/index.html | 15 - public/leaflet/leaflet.css | 656 -- public/leaflet/leaflet.js | 6 - public/main.css | 307 - src/lib/autoComplete.ts | 115 - src/lib/base.ts | 32 - src/lib/button.ts | 29 - src/lib/color.ts | 36 - src/lib/contextMenu.ts | 35 - src/lib/dom.ts | 6 - src/lib/form.ts | 54 - src/lib/h.ts | 31 - src/lib/icons.ts | 66 - src/lib/layout.ts | 15 - src/lib/modal.ts | 28 - src/main.ts | 3 - src/map.ts | 131 - src/marker.ts | 171 - src/markerForm.ts | 116 - src/serialization.ts | 44 - src/serialization/utils.ts | 9 - src/serialization/v0.ts | 122 - src/state.ts | 65 - src/types/leaflet.d.ts | 95 - tsconfig.json | 13 - 93 files changed, 19302 insertions(+), 2258 deletions(-) create mode 100644 .tmuxinator.yml delete mode 100644 Makefile create mode 100755 backend/bin/dev-server create mode 100644 backend/build.zig create mode 100644 backend/build.zig.zon create mode 100644 backend/fixtures.sql create mode 100644 backend/src/lib/nanoid.zig create mode 100644 backend/src/main.zig create mode 100644 backend/src/repos/maps_repo.zig create mode 100644 backend/src/repos/markers_repo.zig create mode 100644 backend/src/repos/migrations/01.sql create mode 100644 backend/src/repos/repos.zig create mode 100644 backend/src/repos/users_repo.zig create mode 100644 backend/src/services/auth_service.zig create mode 100644 backend/src/services/common.zig create mode 100644 backend/src/services/handler.zig create mode 100644 backend/src/services/maps_service.zig create mode 100644 backend/src/services/markers_service.zig create mode 100644 backend/src/services/static.zig create mode 100644 backend/src/services/users_service.zig create mode 100755 bin/dev delete mode 100755 bin/dev-server create mode 100644 frontend/Makefile create mode 100755 frontend/bin/compile-sass create mode 100755 frontend/bin/compile-ts create mode 100755 frontend/bin/dev-server create mode 100644 frontend/html/index.html create mode 100644 frontend/images/icon.png create mode 100644 frontend/styles/colors.sass create mode 100644 frontend/styles/form.sass create mode 100644 frontend/styles/leaflet.css create mode 100644 frontend/styles/lib/icons.sass create mode 100644 frontend/styles/main.sass create mode 100644 frontend/styles/pages/login.sass create mode 100644 frontend/styles/pages/map.sass create mode 100644 frontend/styles/pages/maps.sass create mode 100644 frontend/styles/ui/layout.sass create mode 100644 frontend/styles/ui/modal.sass create mode 100644 frontend/ts/src/lib/color.ts create mode 100644 frontend/ts/src/lib/form.ts create mode 100644 frontend/ts/src/lib/icons.ts create mode 100644 frontend/ts/src/lib/leaflet.d.ts create mode 100644 frontend/ts/src/lib/leaflet.js create mode 100644 frontend/ts/src/lib/loadable.ts create mode 100644 frontend/ts/src/lib/router.ts create mode 100644 frontend/ts/src/lib/rx.ts create mode 100644 frontend/ts/src/main.ts create mode 100644 frontend/ts/src/models/map.ts create mode 100644 frontend/ts/src/models/marker.ts create mode 100644 frontend/ts/src/models/user.ts create mode 100644 frontend/ts/src/pages/layout.ts create mode 100644 frontend/ts/src/pages/login.ts create mode 100644 frontend/ts/src/pages/map.ts create mode 100644 frontend/ts/src/pages/map/footer.ts create mode 100644 frontend/ts/src/pages/map/marker.ts create mode 100644 frontend/ts/src/pages/map/markerForm.ts create mode 100644 frontend/ts/src/pages/maps.ts create mode 100644 frontend/ts/src/pages/notFound.ts create mode 100644 frontend/ts/src/request.ts create mode 100644 frontend/ts/src/route.ts create mode 100644 frontend/ts/src/ui/layout.ts create mode 100644 frontend/ts/src/ui/modal.ts create mode 100644 frontend/ts/tsconfig.json delete mode 100644 public/icon.png delete mode 100644 public/index.html delete mode 100644 public/leaflet/leaflet.css delete mode 100644 public/leaflet/leaflet.js delete mode 100644 public/main.css delete mode 100644 src/lib/autoComplete.ts delete mode 100644 src/lib/base.ts delete mode 100644 src/lib/button.ts delete mode 100644 src/lib/color.ts delete mode 100644 src/lib/contextMenu.ts delete mode 100644 src/lib/dom.ts delete mode 100644 src/lib/form.ts delete mode 100644 src/lib/h.ts delete mode 100644 src/lib/icons.ts delete mode 100644 src/lib/layout.ts delete mode 100644 src/lib/modal.ts delete mode 100644 src/main.ts delete mode 100644 src/map.ts delete mode 100644 src/marker.ts delete mode 100644 src/markerForm.ts delete mode 100644 src/serialization.ts delete mode 100644 src/serialization/utils.ts delete mode 100644 src/serialization/v0.ts delete mode 100644 src/state.ts delete mode 100644 src/types/leaflet.d.ts delete mode 100644 tsconfig.json diff --git a/.gitignore b/.gitignore index b62f3b0..f7b7942 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,5 @@ -/public/main.js +.zig-cache +zig-out +backend/public +.sass-cache +db.sqlite3* diff --git a/.tmuxinator.yml b/.tmuxinator.yml new file mode 100644 index 0000000..83fe649 --- /dev/null +++ b/.tmuxinator.yml @@ -0,0 +1,19 @@ +name: maps + +windows: + - editor: + layout: main-vertical + panes: + - editor: + - nix develop + - nvim -c :Git -c :on + - backend: + - sleep 1 + - nix develop + - cd backend + - bin/dev-server maps-backend + - frontend: + - sleep 2 + - nix develop + - cd frontend + - bin/dev-server maps-frontend diff --git a/Makefile b/Makefile deleted file mode 100644 index 593752d..0000000 --- a/Makefile +++ /dev/null @@ -1,9 +0,0 @@ -build: - @esbuild \ - --bundle src/main.ts \ - --minify \ - --target=es2017 \ - --outdir=public - -clean: - @rm -f public/main.js diff --git a/README.md b/README.md index 880f174..df7eab6 100644 --- a/README.md +++ b/README.md @@ -1,15 +1,29 @@ -# Map +# Maps -Add markers on a map and save state in URL. +Manage maps of markers. -# Getting started +## Getting started -Enter nix shell: +Get into nix-shell: nix develop -Then start the dev server: +Start the server: - bin/dev-server + bin/dev start -Finally, open your browser at `http://localhost:8000`. +Add fixtures: + + sqlite3 db.sqlite3 < fixtures.sql + +Credentials are: + + - john@mail.com / password + - lisa@mail.com / password + +## Improvements + +- mobile view +- show last updated maps first +- share with public link as readonly (allow to revoke) +- archive instead of delete diff --git a/backend/bin/dev-server b/backend/bin/dev-server new file mode 100755 index 0000000..680e736 --- /dev/null +++ b/backend/bin/dev-server @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Killing watchexec ourselves, it may not be done otherwise. +function finish { + if [ -n "${LIVE_SERVER_PID:-}" ]; then + kill "$LIVE_SERVER_PID" > /dev/null 2>&1 + fi +} + +trap finish EXIT + +watchexec \ + --watch src \ + --clear clear \ + --restart \ + zig build run & +LIVE_SERVER_PID="$!" + +while true; do sleep 1; done diff --git a/backend/build.zig b/backend/build.zig new file mode 100644 index 0000000..3fd7c48 --- /dev/null +++ b/backend/build.zig @@ -0,0 +1,49 @@ +const std = @import("std"); + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); + + const optimize = b.standardOptimizeOption(.{}); + + const exe = b.addExecutable(.{ + .name = "backend", + .root_source_file = b.path("src/main.zig"), + .target = target, + .optimize = optimize, + }); + + const httpz = b.dependency("httpz", .{ + .target = target, + .optimize = optimize, + }); + exe.root_module.addImport("httpz", httpz.module("httpz")); + + const zqlite = b.dependency("zqlite", .{ + .target = target, + .optimize = optimize, + }); + exe.linkLibC(); + exe.linkSystemLibrary("sqlite3"); + exe.root_module.addImport("zqlite", zqlite.module("zqlite")); + + b.installArtifact(exe); + + const run_cmd = b.addRunArtifact(exe); + run_cmd.step.dependOn(b.getInstallStep()); + if (b.args) |args| { + run_cmd.addArgs(args); + } + + const run_step = b.step("run", "Run the app"); + run_step.dependOn(&run_cmd.step); + + const exe_unit_tests = b.addTest(.{ + .root_source_file = b.path("src/main.zig"), + .target = target, + .optimize = optimize, + }); + const run_exe_unit_tests = b.addRunArtifact(exe_unit_tests); + + const test_step = b.step("test", "Run unit tests"); + test_step.dependOn(&run_exe_unit_tests.step); +} diff --git a/backend/build.zig.zon b/backend/build.zig.zon new file mode 100644 index 0000000..f4a0e01 --- /dev/null +++ b/backend/build.zig.zon @@ -0,0 +1,14 @@ +.{ .name = .backend, .version = "0.0.0", .fingerprint = 0x8fe5c971579344fd, .minimum_zig_version = "0.14.0-dev.3451+d8d2aa9af", .dependencies = .{ + .httpz = .{ + .url = "git+https://github.com/karlseguin/http.zig?ref=master#458281c7d84066d4186bd642c03260ed542e22e5", + .hash = "httpz-0.0.0-PNVzrJSuBgCAq2ufQxEcgDV4npCsGEJWH7qMi5PL2669", + }, + .zqlite = .{ + .url = "git+https://github.com/karlseguin/zqlite.zig?ref=master#d146898482a4c4bdcba487496f67a24afa12894d", + .hash = "zqlite-0.0.0-RWLaYxp7lQBh-o0STqzS1uZZb0tLTCGBDgzpG_dGjd2J", + }, +}, .paths = .{ + "build.zig", + "build.zig.zon", + "src", +} } diff --git a/backend/fixtures.sql b/backend/fixtures.sql new file mode 100644 index 0000000..0a2dedb --- /dev/null +++ b/backend/fixtures.sql @@ -0,0 +1,17 @@ +INSERT INTO + users(email, name, password_hash) +VALUES + ('john@mail.com', 'John', '$2b$10$Qy8lqrTqHdzwLZwsqvO09eMwehA.vti.AGwPVj/pZYL94Ni6zozT2'), + ('lisa@mail.com', 'Lisa', '$2b$10$Qy8lqrTqHdzwLZwsqvO09eMwehA.vti.AGwPVj/pZYL94Ni6zozT2'); + +INSERT INTO + maps(id, name) +VALUES + ('SZj90bEkbSmjEDv7l6BD-', 'France'); + +INSERT INTO + markers(id, map_id, name, lat, lng, color) +VALUES + ('k3OcuT_7PwNyIxW7etb4Z', 'SZj90bEkbSmjEDv7l6BD-', 'Paris', 48.857487002645485, 2.3510742187500004, '#3584e4'), + ('ilaX_3aVFfgFTc-sRO4F7', 'SZj90bEkbSmjEDv7l6BD-', 'Bordeaux', 44.855868807357275, -0.5932617187500001, '#3584e4'), + ('Ciob_GpDrhlFmFnV2KkMh', 'SZj90bEkbSmjEDv7l6BD-', 'Marseille', 43.30119623257966, 5.394287109375, '#3584e4'); 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, .{}); +} diff --git a/bin/dev b/bin/dev new file mode 100755 index 0000000..5b0a3b9 --- /dev/null +++ b/bin/dev @@ -0,0 +1,22 @@ +#!/usr/bin/env bash +set -euo pipefail + +cd "$(dirname $0)/.." +PROJECT="maps" + +if [ "${1:-}" = "start" ]; then + + tmuxinator stop "$PROJECT" 2>/dev/null || true + tmuxinator start + +elif [ "${1:-}" = "stop" ]; then + + fuser -k 3000/tcp || true + tmuxinator stop "$PROJECT" + +else + + echo "Usage: $0 start|stop" + exit 1 + +fi diff --git a/bin/dev-server b/bin/dev-server deleted file mode 100755 index 82686ae..0000000 --- a/bin/dev-server +++ /dev/null @@ -1,16 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -# Run server - -python -m http.server --directory public 8000 & -trap "fuser -k 8000/tcp" EXIT - -# Watch TypeScript - -CHECK="echo Checking TypeScript… && tsc --checkJs" -BUILD="esbuild --bundle src/main.ts --target=es2017 --outdir=public" -watchexec \ - --clear \ - --watch src \ - -- "$CHECK && $BUILD" diff --git a/flake.lock b/flake.lock index bc595cc..21f2804 100644 --- a/flake.lock +++ b/flake.lock @@ -1,12 +1,31 @@ { "nodes": { + "flake-compat": { + "flake": false, + "locked": { + "lastModified": 1696426674, + "narHash": "sha256-kvjfFW7WAETZlt09AgDn1MrtKzP7t90Vf7vypd3OL1U=", + "owner": "edolstra", + "repo": "flake-compat", + "rev": "0f9255e01c2351cc7d116c072cb317785dd33b33", + "type": "github" + }, + "original": { + "owner": "edolstra", + "repo": "flake-compat", + "type": "github" + } + }, "flake-utils": { + "inputs": { + "systems": "systems" + }, "locked": { - "lastModified": 1656928814, - "narHash": "sha256-RIFfgBuKz6Hp89yRr7+NR5tzIAbn52h8vT6vXkYjZoM=", + "lastModified": 1731533236, + "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", "owner": "numtide", "repo": "flake-utils", - "rev": "7e2a3b3dfd9af950a856d66b0a7d01e3c18aa249", + "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", "type": "github" }, "original": { @@ -17,11 +36,11 @@ }, "nixpkgs": { "locked": { - "lastModified": 1659715246, - "narHash": "sha256-nWZOGZR4Cl4MdetOdnn8GOLxXw5L/V+unWV0KF5SqD0=", + "lastModified": 1741258105, + "narHash": "sha256-dYL3Mu7szy6mtcdhx7yu+hIpDKJIR4wRfa0YAmpdizc=", "owner": "nixos", "repo": "nixpkgs", - "rev": "ab403f07d359394c175218f66eac826e91b56565", + "rev": "1bf64f7f98c982885dc479fe76780a3ae60e5f7b", "type": "github" }, "original": { @@ -33,7 +52,47 @@ "root": { "inputs": { "flake-utils": "flake-utils", - "nixpkgs": "nixpkgs" + "nixpkgs": "nixpkgs", + "zig": "zig" + } + }, + "systems": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" + } + }, + "zig": { + "inputs": { + "flake-compat": "flake-compat", + "flake-utils": [ + "flake-utils" + ], + "nixpkgs": [ + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1741221068, + "narHash": "sha256-sV/DSudiZSSaamUjANqGpHRUaVKhkraMGuXXP68WCHE=", + "owner": "mitchellh", + "repo": "zig-overlay", + "rev": "890dc8f72daea3f146babead98dd4fff614ddea7", + "type": "github" + }, + "original": { + "owner": "mitchellh", + "repo": "zig-overlay", + "type": "github" } } }, diff --git a/flake.nix b/flake.nix index 325ed60..6cc8636 100644 --- a/flake.nix +++ b/flake.nix @@ -1,21 +1,48 @@ { - inputs = { - nixpkgs.url = "github:nixos/nixpkgs"; - flake-utils.url = "github:numtide/flake-utils"; - }; + inputs = { + nixpkgs.url = "github:nixos/nixpkgs"; + flake-utils.url = "github:numtide/flake-utils"; + zig = { + url = "github:mitchellh/zig-overlay"; + inputs.nixpkgs.follows = "nixpkgs"; + inputs.flake-utils.follows = "flake-utils"; + }; + }; - outputs = { self, nixpkgs, flake-utils }: - flake-utils.lib.eachDefaultSystem - (system: - let pkgs = nixpkgs.legacyPackages.${system}; - in { devShell = pkgs.mkShell { - buildInputs = with pkgs; [ - nodePackages.typescript - python3 - psmisc # fuser - esbuild - watchexec - ]; - }; } - ); + outputs = { self, nixpkgs, flake-utils, zig, ... }: + flake-utils.lib.eachDefaultSystem (system: + let + overlays = [ + (final: prev: { + zigpkgs = zig.packages.${prev.system}; + }) + ]; + pkgs = import nixpkgs { + inherit system overlays; + }; + in + with pkgs; + { + devShell = mkShell { + buildInputs = [ + # Common + watchexec + + # Frontend + esbuild + nodePackages.typescript + dart-sass + + # Backend + psmisc # fuser + sqlite + zigpkgs.master + + # Tooling + tmux + tmuxinator + ]; + }; + } + ); } diff --git a/frontend/Makefile b/frontend/Makefile new file mode 100644 index 0000000..53c25e4 --- /dev/null +++ b/frontend/Makefile @@ -0,0 +1,16 @@ +build: ../backend/public/index.html ../backend/public/main.css ../backend/public/main.js ../backend/public/icon.png + +../backend/public/index.html: html/index.html + cp $^ $@ + +../backend/public/main.css: $(shell find styles) + bin/compile-sass + +../backend/public/main.js: $(shell find ts) + bin/compile-ts + +../backend/public/icon.png: images/icon.png + cp $^ $@ + +clean: + rm -f ../backend/public/* diff --git a/frontend/bin/compile-sass b/frontend/bin/compile-sass new file mode 100755 index 0000000..79d5415 --- /dev/null +++ b/frontend/bin/compile-sass @@ -0,0 +1,3 @@ +#!/usr/bin/env bash +set -euo pipefail +sass --no-error-css styles/main.sass ../backend/public/main.css diff --git a/frontend/bin/compile-ts b/frontend/bin/compile-ts new file mode 100755 index 0000000..0e1d62a --- /dev/null +++ b/frontend/bin/compile-ts @@ -0,0 +1,6 @@ +#!/usr/bin/env bash +set -euo pipefail + +cd ts +tsc --noEmit +esbuild --bundle src/main.ts > ../../backend/public/main.js diff --git a/frontend/bin/dev-server b/frontend/bin/dev-server new file mode 100755 index 0000000..4819033 --- /dev/null +++ b/frontend/bin/dev-server @@ -0,0 +1,23 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Killing watchexec ourselves, it may not be done otherwise. +function finish { + if [ -n "${LIVE_SERVER_PID:-}" ]; then + kill "$LIVE_SERVER_PID" > /dev/null 2>&1 + fi +} + +trap finish EXIT + +watchexec \ + --clear \ + --watch ts \ + --watch html \ + --watch images \ + --watch styles \ + --debounce 100ms \ + -- make & +LIVE_SERVER_PID="$!" + +while true; do sleep 1; done diff --git a/frontend/html/index.html b/frontend/html/index.html new file mode 100644 index 0000000..68fdca5 --- /dev/null +++ b/frontend/html/index.html @@ -0,0 +1,11 @@ + + + + +