From 572f4be896756a6c0da7caecbc6fb69340724676 Mon Sep 17 00:00:00 2001 From: Szymon Nowakowski Date: Tue, 12 May 2026 23:11:15 +0200 Subject: [PATCH] myid port (wip) --- packages/myid/build.zig | 8 + packages/myid/build.zig.zon | 3 + packages/myid/src/data.zig | 259 ++++++++++++++++++++++ packages/myid/src/default.zig | 2 - packages/myid/src/http.zig | 206 ----------------- packages/myid/src/http/Parser.zig | 356 ------------------------------ packages/myid/src/id.zig | 6 + packages/myid/src/root.zig | 234 +------------------- packages/myid/src/uuid.zig | 41 ---- packages/web/build.zig | 2 +- 10 files changed, 278 insertions(+), 839 deletions(-) create mode 100644 packages/myid/src/data.zig delete mode 100644 packages/myid/src/default.zig delete mode 100644 packages/myid/src/http.zig delete mode 100644 packages/myid/src/http/Parser.zig create mode 100644 packages/myid/src/id.zig delete mode 100644 packages/myid/src/uuid.zig diff --git a/packages/myid/build.zig b/packages/myid/build.zig index 46b505f..c82532f 100644 --- a/packages/myid/build.zig +++ b/packages/myid/build.zig @@ -11,11 +11,19 @@ pub fn build(b: *std.Build) void { const sqlite_mod = sqlite_dep.module("sqlite"); + const web_dep = b.dependency("web", .{ + .target = target, + .optimize = optimize, + }); + + const web_mod = web_dep.module("web"); + const myid_mod = b.addModule("myid", .{ .root_source_file = b.path("src/root.zig"), .target = target, .imports = &.{ .{ .name = "sqlite", .module = sqlite_mod }, + .{ .name = "web", .module = web_mod }, }, }); diff --git a/packages/myid/build.zig.zon b/packages/myid/build.zig.zon index d54074d..d6b3b43 100644 --- a/packages/myid/build.zig.zon +++ b/packages/myid/build.zig.zon @@ -13,5 +13,8 @@ .url = "git+https://github.com/vrischmann/zig-sqlite#6d90ee900d186a7fbb6066f28ee13beeaf8be345", .hash = "sqlite-3.48.0-F2R_a5yODgDFvwwsytm7ZONcSqYBo3qv1PmXOtw3tqLA", }, + .web = .{ + .path = "../web", + }, }, } diff --git a/packages/myid/src/data.zig b/packages/myid/src/data.zig new file mode 100644 index 0000000..7b979af --- /dev/null +++ b/packages/myid/src/data.zig @@ -0,0 +1,259 @@ +const std = @import("std"); +const sqlite = @import("sqlite"); +const web = @import("web"); + +const id = @import("id.zig"); + +pub const callback_regex = "^https?://([a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\\.)+[a-zA-Z]{2,}/"; + +pub const App = struct { + aid: id.App, + name: []const u8, + callbacks: []const []const u8, +}; + +pub const AppWithSecret = struct { + aid: id.App, + name: []const u8, + callbacks: []const []const u8, + secret: []const u8, +}; + +pub const CreateAppData = struct { + name: []const u8, + callback: ?[]const []const u8, +}; + +pub const CreateAppResult = struct { + aid: id.App, + plainSecret: []const u8, +}; + +pub const User = struct { + uid: id.User, + name: []const u8, + email: []const u8, + admin: bool, +}; + +pub const UserWithPassword = struct { + uid: id.User, + name: []const u8, + email: []const u8, + admin: bool, + passworD: []const u8, +}; + +pub const InviteUserData = struct { + name: []const u8, + email: []const u8, + admin: bool, +}; + +pub const CreateUserData = struct { + name: []const u8, + email: []const u8, + plainPassword: []const u8, + admin: bool, +}; + +pub const All = struct { + users: []const User, + apps: []const App, +}; + +pub const Database = struct { + db: sqlite.Db, + allocator: std.mem.Allocator, + + pub fn init(path: [:0]const u8, allocator: std.mem.Allocator) !Database { + var db = try sqlite.Db.init(.{ + .mode = .{ .File = path }, + .open_flags = .{ + .create = true, + .write = true, + }, + .threading_mode = .MultiThread, + }); + errdefer db.deinit(); + + _ = try db.one(void, "PRAGMA journal_mode = WAL", .{}, .{}); + _ = try db.one(void, "PRAGMA foreign_keys = ON", .{}, .{}); + + return .{ + .db = db, + .allocator = allocator, + }; + } + + pub fn deinit(self: *Database) void { + self.db.deinit(); + self.* = undefined; + } + + // --- MIGRATION ----------------------------------------------------------- + + fn getUserVersion(self: *Database) !i32 { + const version = try self.db.one(i32, "PRAGMA user_version", .{}, .{}); + return version.?; + } + + fn setUserVersion(self: *Database, version: i32) !void { + var buf: [100]u8 = undefined; + const query = std.fmt.bufPrint(&buf, "PRAGMA user_version = {d}", .{version}) catch unreachable; + _ = try self.db.oneDynamic(void, query, .{}, .{}); + } + + pub fn migrate(self: *Database) !void { + var user_version = try self.getUserVersion(); + + if (user_version == 0) { + _ = try self.db.exec( + \\CREATE TABLE users ( + \\ uid BLOB NOT NULL, + \\ name TEXT NOT NULL, + \\ email TEXT NOT NULL UNIQUE, + \\ password TEXT NOT NULL, + \\ PRIMARY KEY (uid) + \\) + , .{}, .{}); + _ = try self.db.exec( + \\CREATE TABLE apps ( + \\ aid BLOB NOT NULL, + \\ name TEXT NOT NULL, + \\ secret TEXT NOT NULL, + \\ PRIMARY KEY (aid) + \\) + , .{}, .{}); + _ = try self.db.exec( + \\CREATE TABLE app_callbacks ( + \\ aid BLOB NOT NULL, + \\ callback TEXT NOT NULL, + \\ PRIMARY KEY (aid, callback), + \\ FOREIGN KEY (aid) REFERENCES apps (aid) + \\ ON UPDATE CASCADE + \\ ON DELETE CASCADE + \\) + , .{}, .{}); + user_version += 1; + try self.setUserVersion(user_version); + } + } + + // --- USERS --------------------------------------------------------------- + + pub fn createUser(self: *Database, name: []const u8, email: []const u8, plain_password: []const u8) !id.User { + const uid = id.User.init(web.UUID.v7()); + var password_buf: [1000]u8 = undefined; + const password = try std.crypto.pwhash.argon2.strHash(plain_password, .{ + .allocator = self.allocator, + .mode = .argon2id, + .params = .owasp_2id, + }, &password_buf); + + try self.db.exec("INSERT INTO users (uid, name, email, password) VALUES (?, ?, ?, ?)", .{}, .{ + .uid = uid.bytes, + .name = name, + .email = email, + .password = password, + }); + + return uid; + } + + // --- APPS ---------------------------------------------------------------- + + pub fn createApp(self: *Database, name: []const u8, callbacks: []const []const u8) !CreateAppResult { + _ = try self.db.exec("BEGIN", .{}, .{}); + errdefer self.db.exec("ROLLBACK", .{}, .{}) catch unreachable; + + const secret_bytes = blk: { + var bytes: [24]u8 = undefined; + std.crypto.random.bytes(&bytes); + break :blk bytes; + }; + + var plain_secret_buf: [32]u8 = undefined; + const plain_secret = std.base64.url_safe_no_pad.Encoder.encode(&plain_secret_buf, &secret_bytes); + std.debug.assert(plain_secret_buf.len == plain_secret.len); + + const aid = id.App.init(web.UUID.v7()); + var secret_buf: [1000]u8 = undefined; + const secret = try std.crypto.pwhash.argon2.strHash(plain_secret, .{ + .allocator = self.allocator, + .mode = .argon2id, + .params = .owasp_2id, + }, &secret_buf); + + try self.db.exec("INSERT INTO apps (aid, name, secret) VALUES (?, ?, ?)", .{}, .{ + .aid = aid.bytes, + .name = name, + .secret = secret, + }); + + var insert_callback = try self.db.prepare("INSERT INTO app_callbacks (aid, callback) VALUES (?, ?)"); + defer insert_callback.deinit(); + for (callbacks) |callback| { + try insert_callback.exec(.{}, .{ .aid = aid.bytes, .callback = callback }); + insert_callback.reset(); + } + + try self.db.exec("COMMIT", .{}, .{}); + return .{ + .aid = aid, + .plain_secret = plain_secret_buf, + }; + } +}; + +test "user version" { + var db = try Database.init(":memory:", std.testing.allocator); + defer db.deinit(); + + try std.testing.expectEqual(0, try db.getUserVersion()); + try db.setUserVersion(1); + try std.testing.expectEqual(1, try db.getUserVersion()); +} + +test "migrate" { + var db = try Database.init(":memory:", std.testing.allocator); + defer db.deinit(); + + try db.migrate(); +} + +test "create user" { + var db = try Database.init(":memory:", std.testing.allocator); + defer db.deinit(); + + try db.migrate(); + + const uid = try db.createUser("admin", "admin@test.invalid", "admin"); + + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + const maybe_user = try db.db.oneAlloc(struct { + name: []const u8, + email: []const u8, + password: []const u8, + }, arena.allocator(), "SELECT name, email, password FROM users WHERE uid = ?", .{}, .{ + .uid = uid.bytes, + }); + defer arena.deinit(); + + try std.testing.expect(maybe_user != null); + if (maybe_user) |user| { + try std.testing.expectEqualSlices(u8, "admin", user.name); + } +} + +test "create app" { + var db = try Database.init(":memory:", std.testing.allocator); + defer db.deinit(); + + try db.migrate(); + + _ = try db.createApp("app", &.{ + "http://localhost:3000/callback", + "https://example.com/callback", + }); +} diff --git a/packages/myid/src/default.zig b/packages/myid/src/default.zig deleted file mode 100644 index 56a6e70..0000000 --- a/packages/myid/src/default.zig +++ /dev/null @@ -1,2 +0,0 @@ -pub const database_path = "db.sqlite3"; -pub const socket_path = "myid.sock"; diff --git a/packages/myid/src/http.zig b/packages/myid/src/http.zig deleted file mode 100644 index 6623a23..0000000 --- a/packages/myid/src/http.zig +++ /dev/null @@ -1,206 +0,0 @@ -const std = @import("std"); -const main = @import("main.zig"); - -const Parser = @import("http/Parser.zig"); - -threadlocal var read_buffer: [2 * 1024 * 1024]u8 = undefined; -threadlocal var write_buffer: [2 * 1024 * 1024]u8 = undefined; - -const log = std.log.scoped(.http); - -const status = struct { - pub const ok = "HTTP/1.1 200 OK\r\n"; - pub const created = "HTTP/1.1 201 Created\r\n"; - pub const accepted = "HTTP/1.1 202 Accepted\r\n"; - pub const non_authoritative_information = "HTTP/1.1 203 Non-Authoritative Information\r\n"; - pub const no_content = "HTTP/1.1 204 No Content\r\n"; - pub const reset_content = "HTTP/1.1 205 Reset Content\r\n"; - pub const partial_content = "HTTP/1.1 206 Partial Content\r\n"; - pub const multi_status = "HTTP/1.1 207 Multi-Status\r\n"; - pub const already_reported = "HTTP/1.1 208 Already Reported\r\n"; - - pub const multiple_choices = "HTTP/1.1 300 Multiple Choices\r\n"; - pub const moved_permanently = "HTTP/1.1 301 Moved Permanently\r\n"; - pub const found = "HTTP/1.1 302 Found\r\n"; - pub const see_other = "HTTP/1.1 303 See Other\r\n"; - pub const not_modified = "HTTP/1.1 304 Not Modified\r\n"; - pub const temporary_redirect = "HTTP/1.1 307 Temporary Redirect\r\n"; - pub const permanent_redirect = "HTTP/1.1 308 Permanent Redirect\r\n"; - - pub const bad_request = "HTTP/1.1 400 Bad Request\r\n"; - pub const unauthorized = "HTTP/1.1 401 Unauthorized\r\n"; - pub const payment_required = "HTTP/1.1 402 Payment Required\r\n"; - pub const forbidden = "HTTP/1.1 403 Forbidden\r\n"; - pub const not_found = "HTTP/1.1 404 Not Found\r\n"; - pub const method_not_allowed = "HTTP/1.1 405 Method Not Allowed\r\n"; - pub const not_acceptable = "HTTP/1.1 406 Not Acceptable\r\n"; - pub const proxy_authentication_required = "HTTP/1.1 407 Proxy Authentication Required\r\n"; - pub const request_timeout = "HTTP/1.1 408 Request Timeout\r\n"; - pub const conflict = "HTTP/1.1 409 Conflict\r\n"; - pub const gone = "HTTP/1.1 410 Gone\r\n"; - pub const length_required = "HTTP/1.1 411 Length Required\r\n"; - pub const precondition_failed = "HTTP/1.1 412 Precondition Failed\r\n"; - pub const content_too_large = "HTTP/1.1 413 Content Too Large\r\n"; - pub const uri_too_long = "HTTP/1.1 414 URI Too Long\r\n"; - pub const unsupported_media_type = "HTTP/1.1 415 Unsupported Media Type\r\n"; - pub const range_not_satisfiable = "HTTP/1.1 416 Range Not Satisfiable\r\n"; - pub const expectation_failed = "HTTP/1.1 417 Expectation Failed\r\n"; - pub const im_a_teapot = "HTTP/1.1 418 I'm a teapot\r\n"; - pub const misdirected_request = "HTTP/1.1 421 Misdirected Request\r\n"; - pub const unprocessable_content = "HTTP/1.1 422 Unprocessable Content\r\n"; - pub const locked = "HTTP/1.1 423 Locked\r\n"; - pub const failed_dependency = "HTTP/1.1 424 Failed Dependency\r\n"; - pub const upgrade_required = "HTTP/1.1 426 Upgrade Required\r\n"; - pub const precondition_required = "HTTP/1.1 428 Precondition Required\r\n"; - pub const too_many_requests = "HTTP/1.1 429 Too Many Requests\r\n"; - pub const request_header_fields_too_large = "HTTP/1.1 431 Request Header Fields Too Large\r\n"; - pub const unavailable_for_legal_reasons = "HTTP/1.1 451 Unavailable For Legal Reasons\r\n"; - - pub const internal_server_error = "HTTP/1.1 500 Internal Server Error\r\n"; - pub const not_implemented = "HTTP/1.1 501 Not Implemented\r\n"; - pub const bad_gateway = "HTTP/1.1 502 Bad Gateway\r\n"; - pub const service_unavailable = "HTTP/1.1 503 Service Unavailable\r\n"; - pub const gateway_timeout = "HTTP/1.1 504 Gateway Timeout\r\n"; - pub const http_version_not_supported = "HTTP/1.1 505 HTTP Version Not Supported\r\n"; - pub const variant_also_negotiates = "HTTP/1.1 506 Variant Also Negotiates\r\n"; - pub const insufficient_storage = "HTTP/1.1 507 Insufficient Storage\r\n"; - pub const loop_detected = "HTTP/1.1 508 Loop Detected\r\n"; - pub const not_extended = "HTTP/1.1 510 Not Extended\r\n"; - pub const network_authentication_required = "HTTP/1.1 511 Network Authentication Required\r\n"; -}; - -const ResponseEmptyOptions = struct { - status_text: []const u8 = status.ok, -}; - -const ResponseOptions = struct { - status_text: []const u8 = status.ok, - media_type: []const u8 = "text/plain; charset=utf-8", - response_body: []const u8, -}; - -fn makeResponseEmpty(options: ResponseEmptyOptions) ![]const u8 { - var fbs = std.io.fixedBufferStream(&write_buffer); - const writer = fbs.writer(); - - try writer.print("{s}", .{options.status_text}); - try writer.print("\r\n", .{}); - - return fbs.getWritten(); -} - -fn makeResponseClose(options: ResponseEmptyOptions) ![]const u8 { - var fbs = std.io.fixedBufferStream(&write_buffer); - const writer = fbs.writer(); - - try writer.print("{s}", .{options.status_text}); - try writer.print("Connection: close\r\n", .{}); - try writer.print("\r\n", .{}); - - return fbs.getWritten(); -} - -fn makeResponse(options: ResponseOptions) ![]const u8 { - var fbs = std.io.fixedBufferStream(&write_buffer); - const writer = fbs.writer(); - - try writer.print("{s}", .{options.status_text}); - try writer.print("Content-Type: {s}\r\n", .{options.media_type}); - try writer.print("Content-Length: {d}\r\n", .{options.response_body.len}); - try writer.print("\r\n", .{}); - try writer.print("{s}", .{options.response_body}); - - return fbs.getWritten(); -} - -pub fn process(conn: std.net.Server.Connection) !void { - defer conn.stream.close(); - - var leftover_bytes: usize = 0; - - while (true) { - const start = try std.time.Instant.now(); - - var route: Parser.Route = undefined; - - var parser = Parser.init(.{ - .self = &route, - .route = routeCallback, - }); - var total_bytes_read: usize = 0; - - while (true) { - var bytes_read: usize = undefined; - var chars: []const u8 = undefined; - - if (leftover_bytes > 0) { - bytes_read = leftover_bytes; - chars = read_buffer[0..leftover_bytes]; - leftover_bytes = 0; - } else { - bytes_read = try conn.stream.read(read_buffer[total_bytes_read..]); - chars = read_buffer[total_bytes_read .. total_bytes_read + bytes_read]; - } - - total_bytes_read += bytes_read; - - const res = parser.consume(chars) catch |err| switch (err) { - error.MethodNotSupported => { - const response = try makeResponseClose(.{ .status_text = status.method_not_allowed }); - try conn.stream.writeAll(response); - return; - }, - error.HttpVersionNotSupported => { - const response = try makeResponseClose(.{ .status_text = status.http_version_not_supported }); - try conn.stream.writeAll(response); - return; - }, - error.MissingLineFeed => { - const response = try makeResponseClose(.{ .status_text = status.bad_request }); - try conn.stream.writeAll(response); - return; - }, - error.InvalidContentLength => { - const response = try makeResponseClose(.{ .status_text = status.bad_request }); - try conn.stream.writeAll(response); - return; - }, - }; - - if (total_bytes_read >= read_buffer.len and !res.done) { - if (parser.state == .body) { - const response = try makeResponseClose(.{ .status_text = status.content_too_large }); - try conn.stream.writeAll(response); - return; - } else { - const response = try makeResponseClose(.{ .status_text = status.request_header_fields_too_large }); - try conn.stream.writeAll(response); - return; - } - } - - if (res.done) { - leftover_bytes = bytes_read - res.consumed; - break; - } - } - - const response = try makeResponse(.{ .response_body = "PONG\n" }); - - try conn.stream.writeAll(response); - - if (leftover_bytes > 0) { - @memmove(&read_buffer, read_buffer[total_bytes_read - leftover_bytes .. total_bytes_read]); - } - - const end = try std.time.Instant.now(); - const time_ns = end.since(start); - const time_us = @divFloor(time_ns, std.time.ns_per_us); - - log.info("{s} {s} ({} μs)", .{ @tagName(route.method), route.pathname, time_us }); - } -} - -fn routeCallback(self: ?*anyopaque, route: Parser.Route) void { - @as(*Parser.Route, @alignCast(@ptrCast(self))).* = route; -} diff --git a/packages/myid/src/http/Parser.zig b/packages/myid/src/http/Parser.zig deleted file mode 100644 index cdb36c6..0000000 --- a/packages/myid/src/http/Parser.zig +++ /dev/null @@ -1,356 +0,0 @@ -const std = @import("std"); - -const Parser = @This(); - -const Callbacks = struct { - self: ?*anyopaque = null, - - route: ?*const fn (self: ?*anyopaque, route: Route) void = null, - header: ?*const fn (self: ?*anyopaque, name: []const u8, value: []const u8) void = null, - body: ?*const fn (self: ?*anyopaque, body: []const u8) void = null, - - pub const init: Callbacks = .{}; -}; - -const Error = error{ - MethodNotSupported, - HttpVersionNotSupported, - MissingLineFeed, - InvalidContentLength, -}; - -const State = union(enum) { - pub fn methodComplete(method: Method) State { - return .{ - .method_complete = .{ - .method = method, - }, - }; - } - - pub fn pathname(method: Method, p: []const u8) State { - return .{ - .pathname_state = .{ - .method = method, - .pathname = p, - }, - }; - } - - pub fn headerValue(name: []const u8, value: []const u8) State { - return .{ - .header_value = .{ - .name = name, - .value = value, - }, - }; - } - - init: void, - method_d: void, - method_g: void, - method_h: void, - method_p: void, - method_de: void, - method_ge: void, - method_he: void, - method_pa: void, - method_po: void, - method_pu: void, - method_del: void, - method_hea: void, - method_pat: void, - method_pos: void, - method_dele: void, - method_patc: void, - method_delet: void, - method_complete: struct { method: Method }, - pathname_state: struct { method: Method, pathname: []const u8 }, - pathname_complete: void, - version_h: void, - version_ht: void, - version_htt: void, - version_http: void, - @"version_http/@": void, - @"version_http/1@": void, - @"version_http/1.@": void, - version_complete: void, - start_line_end: void, - header_name_start: void, - header_name: []const u8, - header_value: struct { name: []const u8, value: []const u8 }, - header_line_end: void, - headers_end: void, - body: []const u8, -}; - -const ConsumeResult = struct { - consumed: usize, - done: bool, -}; - -const ConsumeCharResult = enum { - not_done, - done, -}; - -pub const Method = enum { - DELETE, - GET, - HEAD, - PATCH, - POST, - PUT, -}; - -pub const Route = struct { - method: Method, - pathname: []const u8, -}; - -callbacks: Callbacks, -state: State, -current_header_is_content_length: bool, -content_length: usize, - -pub fn init(callbacks: Callbacks) Parser { - return .{ - .callbacks = callbacks, - .state = .init, - .current_header_is_content_length = false, - .content_length = 0, - }; -} - -pub fn consume(self: *Parser, chars: []const u8) Error!ConsumeResult { - var i: usize = 0; - while (i < chars.len) { - switch (self.state) { - .body => |body| { - const to_consume = @min(chars.len - i, self.content_length - body.len); - const new_body = body.ptr[0 .. body.len + to_consume]; - self.state = .{ .body = new_body }; - i += to_consume; - - const done = new_body.len >= self.content_length; - - if (done) { - if (self.callbacks.body) |bodyCallback| { - bodyCallback(self.callbacks.self, new_body); - } - } - - return .{ - .consumed = i, - .done = done, - }; - }, - else => { - const res = try self.consumeChar(&chars[i]); - i += 1; - if (res == .done) return .{ - .consumed = i, - .done = true, - }; - }, - } - } - - return .{ - .consumed = chars.len, - .done = false, - }; -} - -pub fn consumeChar(self: *Parser, c_ptr: *const u8) Error!ConsumeCharResult { - const c = c_ptr.*; - const c_slice = @as([*]const u8, @ptrCast(c_ptr))[0..1]; - switch (self.state) { - .init => switch (c) { - 'D' => self.state = .method_d, - 'G' => self.state = .method_g, - 'H' => self.state = .method_h, - 'P' => self.state = .method_p, - else => return error.MethodNotSupported, - }, - .method_d => switch (c) { - 'E' => self.state = .method_de, - else => return error.MethodNotSupported, - }, - .method_g => switch (c) { - 'E' => self.state = .method_ge, - else => return error.MethodNotSupported, - }, - .method_h => switch (c) { - 'E' => self.state = .method_he, - else => return error.MethodNotSupported, - }, - .method_p => switch (c) { - 'A' => self.state = .method_pa, - 'O' => self.state = .method_po, - 'U' => self.state = .method_pu, - else => return error.MethodNotSupported, - }, - .method_de => switch (c) { - 'L' => self.state = .method_del, - else => return error.MethodNotSupported, - }, - .method_ge => switch (c) { - 'T' => self.state = .methodComplete(.GET), - else => return error.MethodNotSupported, - }, - .method_he => switch (c) { - 'A' => self.state = .method_hea, - else => return error.MethodNotSupported, - }, - .method_pa => switch (c) { - 'T' => self.state = .method_pat, - else => return error.MethodNotSupported, - }, - .method_po => switch (c) { - 'S' => self.state = .method_pos, - else => return error.MethodNotSupported, - }, - .method_pu => switch (c) { - 'T' => self.state = .methodComplete(.PUT), - else => return error.MethodNotSupported, - }, - .method_del => switch (c) { - 'E' => self.state = .method_dele, - else => return error.MethodNotSupported, - }, - .method_hea => switch (c) { - 'D' => self.state = .methodComplete(.HEAD), - else => return error.MethodNotSupported, - }, - .method_pat => switch (c) { - 'C' => self.state = .method_patc, - else => return error.MethodNotSupported, - }, - .method_pos => switch (c) { - 'T' => self.state = .methodComplete(.POST), - else => return error.MethodNotSupported, - }, - .method_dele => switch (c) { - 'T' => self.state = .method_delet, - else => return error.MethodNotSupported, - }, - .method_patc => switch (c) { - 'H' => self.state = .methodComplete(.PATCH), - else => return error.MethodNotSupported, - }, - .method_delet => switch (c) { - 'E' => self.state = .methodComplete(.DELETE), - else => return error.MethodNotSupported, - }, - .method_complete => |s| switch (c) { - ' ' => self.state = .pathname(s.method, @as([*]const u8, @ptrCast(c_ptr))[1..1]), - else => return error.MethodNotSupported, - }, - .pathname_state => |s| switch (c) { - ' ' => { - self.state = .pathname_complete; - if (self.callbacks.route) |routeCallback| { - routeCallback(self.callbacks.self, .{ - .method = s.method, - .pathname = s.pathname, - }); - } - }, - else => self.state = .pathname(s.method, s.pathname.ptr[0 .. s.pathname.len + 1]), - }, - .pathname_complete => switch (c) { - 'H' => self.state = .version_h, - else => return error.HttpVersionNotSupported, - }, - .version_h => switch (c) { - 'T' => self.state = .version_ht, - else => return error.HttpVersionNotSupported, - }, - .version_ht => switch (c) { - 'T' => self.state = .version_htt, - else => return error.HttpVersionNotSupported, - }, - .version_htt => switch (c) { - 'P' => self.state = .version_http, - else => return error.HttpVersionNotSupported, - }, - .version_http => switch (c) { - '/' => self.state = .@"version_http/@", - else => return error.HttpVersionNotSupported, - }, - .@"version_http/@" => switch (c) { - '1' => self.state = .@"version_http/1@", - else => return error.HttpVersionNotSupported, - }, - .@"version_http/1@" => switch (c) { - '.' => self.state = .@"version_http/1.@", - else => return error.HttpVersionNotSupported, - }, - .@"version_http/1.@" => switch (c) { - '1' => self.state = .version_complete, - else => return error.HttpVersionNotSupported, - }, - .version_complete => switch (c) { - '\r' => self.state = .start_line_end, - else => return error.HttpVersionNotSupported, - }, - .start_line_end => switch (c) { - '\n' => self.state = .header_name_start, - else => return error.MissingLineFeed, - }, - .header_name_start => switch (c) { - '\r' => self.state = .headers_end, - else => self.state = .{ .header_name = c_slice }, - }, - .header_name => |name| switch (c) { - ':' => { - self.state = .headerValue(name, @as([*]const u8, @ptrCast(c_ptr))[1..1]); - self.current_header_is_content_length = std.ascii.eqlIgnoreCase(name, "Content-Length"); - }, - else => self.state = .{ .header_name = name.ptr[0 .. name.len + 1] }, - }, - .header_value => |s| switch (c) { - '\r' => { - self.state = .header_line_end; - const value_trimmed = std.mem.trim(u8, s.value, " \t"); - if (self.current_header_is_content_length) { - self.content_length = std.fmt.parseInt(usize, value_trimmed, 10) catch return error.InvalidContentLength; - self.current_header_is_content_length = false; - } - if (self.callbacks.header) |headerCallback| { - headerCallback(self.callbacks.self, s.name, value_trimmed); - } - }, - else => self.state = .headerValue(s.name, s.value.ptr[0 .. s.value.len + 1]), - }, - .header_line_end => switch (c) { - '\n' => self.state = .header_name_start, - else => return error.MissingLineFeed, - }, - .headers_end => switch (c) { - '\n' => { - if (self.content_length == 0) { - if (self.callbacks.body) |bodyCallback| { - bodyCallback(self.callbacks.self, &.{}); - } - return .done; - } - self.state = .{ .body = @as([*]const u8, @ptrCast(c_ptr))[1..1] }; - }, - else => return error.MissingLineFeed, - }, - .body => |body| { - const new_body = body.ptr[0 .. body.len + 1]; - self.state = .{ .body = new_body }; - if (new_body.len >= self.content_length) { - if (self.callbacks.body) |bodyCallback| { - bodyCallback(self.callbacks.self, new_body); - } - return .done; - } - }, - } - - return .not_done; -} diff --git a/packages/myid/src/id.zig b/packages/myid/src/id.zig new file mode 100644 index 0000000..cf95b82 --- /dev/null +++ b/packages/myid/src/id.zig @@ -0,0 +1,6 @@ +const std = @import("std"); +const web = @import("web"); + +pub const App = web.Id(.app); +pub const Session = web.Id(.session); +pub const User = web.Id(.user); diff --git a/packages/myid/src/root.zig b/packages/myid/src/root.zig index 1f91fce..e14057f 100644 --- a/packages/myid/src/root.zig +++ b/packages/myid/src/root.zig @@ -1,235 +1,3 @@ const std = @import("std"); const sqlite = @import("sqlite"); -const uuid = @import("uuid.zig"); - -fn Id(comptime _tag: @Type(.enum_literal)) type { - return struct { - pub const tag = _tag; - - bytes: [16]u8, - - pub fn new() @This() { - return .{ .bytes = uuid.uuid_v7() }; - } - - pub fn eql(a: @This(), b: @This()) bool { - return std.mem.eql(u8, &a.bytes, &b.bytes); - } - - pub fn decode(encoded: *const [22]u8) !@This() { - var bytes: [16]u8 = undefined; - try std.base64.url_safe_no_pad.Decoder.decode(&bytes, encoded); - return .{ .bytes = bytes }; - } - - pub fn encode(self: @This()) [22]u8 { - var text: [22]u8 = undefined; - std.base64.url_safe_no_pad.Encoder.encode(&text, self.bytes); - return text; - } - }; -} - -pub const AppId = Id(.app_id); -pub const UserId = Id(.user_id); - -pub const CreateAppResult = struct { - aid: AppId, - plain_secret: [32]u8, -}; - -pub const Database = struct { - db: sqlite.Db, - allocator: std.mem.Allocator, - - pub fn init(path: [:0]const u8, allocator: std.mem.Allocator) !Database { - var db = try sqlite.Db.init(.{ - .mode = .{ .File = path }, - .open_flags = .{ - .create = true, - .write = true, - }, - .threading_mode = .MultiThread, - }); - errdefer db.deinit(); - - _ = try db.one(void, "PRAGMA journal_mode = WAL", .{}, .{}); - _ = try db.one(void, "PRAGMA foreign_keys = ON", .{}, .{}); - - return .{ - .db = db, - .allocator = allocator, - }; - } - - pub fn deinit(self: *Database) void { - self.db.deinit(); - self.* = undefined; - } - - // --- MIGRATION ----------------------------------------------------------- - - fn getUserVersion(self: *Database) !i32 { - const version = try self.db.one(i32, "PRAGMA user_version", .{}, .{}); - return version.?; - } - - fn setUserVersion(self: *Database, version: i32) !void { - var buf: [100]u8 = undefined; - const query = std.fmt.bufPrint(&buf, "PRAGMA user_version = {d}", .{version}) catch unreachable; - _ = try self.db.oneDynamic(void, query, .{}, .{}); - } - - pub fn migrate(self: *Database) !void { - var user_version = try self.getUserVersion(); - - if (user_version == 0) { - _ = try self.db.exec( - \\CREATE TABLE users ( - \\ uid BLOB NOT NULL, - \\ name TEXT NOT NULL, - \\ email TEXT NOT NULL UNIQUE, - \\ password TEXT NOT NULL, - \\ PRIMARY KEY (uid) - \\) - , .{}, .{}); - _ = try self.db.exec( - \\CREATE TABLE apps ( - \\ aid BLOB NOT NULL, - \\ name TEXT NOT NULL, - \\ secret TEXT NOT NULL, - \\ PRIMARY KEY (aid) - \\) - , .{}, .{}); - _ = try self.db.exec( - \\CREATE TABLE app_callbacks ( - \\ aid BLOB NOT NULL, - \\ callback TEXT NOT NULL, - \\ PRIMARY KEY (aid, callback), - \\ FOREIGN KEY (aid) REFERENCES apps (aid) - \\ ON UPDATE CASCADE - \\ ON DELETE CASCADE - \\) - , .{}, .{}); - user_version += 1; - try self.setUserVersion(user_version); - } - } - - // --- USERS --------------------------------------------------------------- - - pub fn createUser(self: *Database, name: []const u8, email: []const u8, plain_password: []const u8) !UserId { - const uid = UserId.new(); - var password_buf: [1000]u8 = undefined; - const password = try std.crypto.pwhash.argon2.strHash(plain_password, .{ - .allocator = self.allocator, - .mode = .argon2id, - .params = .owasp_2id, - }, &password_buf); - - try self.db.exec("INSERT INTO users (uid, name, email, password) VALUES (?, ?, ?, ?)", .{}, .{ - .uid = uid.bytes, - .name = name, - .email = email, - .password = password, - }); - - return uid; - } - - // --- APPS ---------------------------------------------------------------- - - pub fn createApp(self: *Database, name: []const u8, callbacks: []const []const u8) !CreateAppResult { - _ = try self.db.exec("BEGIN", .{}, .{}); - errdefer self.db.exec("ROLLBACK", .{}, .{}) catch unreachable; - - const secret_bytes = blk: { - var bytes: [24]u8 = undefined; - std.crypto.random.bytes(&bytes); - break :blk bytes; - }; - - var plain_secret_buf: [32]u8 = undefined; - const plain_secret = std.base64.url_safe_no_pad.Encoder.encode(&plain_secret_buf, &secret_bytes); - std.debug.assert(plain_secret_buf.len == plain_secret.len); - - const aid = AppId.new(); - var secret_buf: [1000]u8 = undefined; - const secret = try std.crypto.pwhash.argon2.strHash(plain_secret, .{ - .allocator = self.allocator, - .mode = .argon2id, - .params = .owasp_2id, - }, &secret_buf); - - try self.db.exec("INSERT INTO apps (aid, name, secret) VALUES (?, ?, ?)", .{}, .{ - .aid = aid.bytes, - .name = name, - .secret = secret, - }); - - var insert_callback = try self.db.prepare("INSERT INTO app_callbacks (aid, callback) VALUES (?, ?)"); - defer insert_callback.deinit(); - for (callbacks) |callback| { - try insert_callback.exec(.{}, .{ .aid = aid.bytes, .callback = callback }); - insert_callback.reset(); - } - - try self.db.exec("COMMIT", .{}, .{}); - return .{ - .aid = aid, - .plain_secret = plain_secret_buf, - }; - } -}; - -test "user version" { - var db = try Database.init(":memory:", std.testing.allocator); - defer db.deinit(); - - try std.testing.expectEqual(0, try db.getUserVersion()); - try db.setUserVersion(1); - try std.testing.expectEqual(1, try db.getUserVersion()); -} - -test "migrate" { - var db = try Database.init(":memory:", std.testing.allocator); - defer db.deinit(); - - try db.migrate(); -} - -test "create user" { - var db = try Database.init(":memory:", std.testing.allocator); - defer db.deinit(); - - try db.migrate(); - - const uid = try db.createUser("admin", "admin@test.invalid", "admin"); - - var arena = std.heap.ArenaAllocator.init(std.testing.allocator); - const maybe_user = try db.db.oneAlloc(struct { - name: []const u8, - email: []const u8, - password: []const u8, - }, arena.allocator(), "SELECT name, email, password FROM users WHERE uid = ?", .{}, .{ - .uid = uid.bytes, - }); - defer arena.deinit(); - - try std.testing.expect(maybe_user != null); - if (maybe_user) |user| { - try std.testing.expectEqualSlices(u8, "admin", user.name); - } -} - -test "create app" { - var db = try Database.init(":memory:", std.testing.allocator); - defer db.deinit(); - - try db.migrate(); - - _ = try db.createApp("app", &.{ - "http://localhost:3000/callback", - "https://example.com/callback", - }); -} +const web = @import("web"); diff --git a/packages/myid/src/uuid.zig b/packages/myid/src/uuid.zig deleted file mode 100644 index 13a3dc1..0000000 --- a/packages/myid/src/uuid.zig +++ /dev/null @@ -1,41 +0,0 @@ -const std = @import("std"); - -var lock: std.Thread.Mutex = .{}; -var last_timestamp: std.atomic.Value(u64) = .{ .raw = 0 }; -var counter: std.atomic.Value(u32) = .{ .raw = 0 }; - -fn getCount(timestamp: u64) u32 { - lock.lock(); - defer lock.unlock(); - - if (last_timestamp.swap(timestamp, .monotonic) != timestamp) { - counter.store(0, .monotonic); - } - - return counter.fetchAdd(1, .monotonic) % 4096; -} - -pub fn uuid_v7() [16]u8 { - const timestamp: u64 = @intCast(@max(0, std.time.milliTimestamp())); - const count = getCount(timestamp); - const random = blk: { - var bytes: [8]u8 = undefined; - std.crypto.random.bytes(&bytes); - break :blk bytes; - }; - - var res: [16]u8 = undefined; - - res[0] = @truncate(timestamp >> 40); - res[1] = @truncate(timestamp >> 32); - res[2] = @truncate(timestamp >> 24); - res[3] = @truncate(timestamp >> 16); - res[4] = @truncate(timestamp >> 8); - res[5] = @truncate(timestamp); - res[6] = (@as(u8, 7) << 4) | @as(u8, @truncate((count >> 8) & 0x0F)); - res[7] = @truncate(count); - res[8] = 0x80 | (random[0] & 0x3F); - @memcpy(res[9..16], random[1..8]); - - return res; -} diff --git a/packages/web/build.zig b/packages/web/build.zig index 03bba49..2910c53 100644 --- a/packages/web/build.zig +++ b/packages/web/build.zig @@ -4,7 +4,7 @@ pub fn build(b: *std.Build) void { const target = b.standardTargetOptions(.{}); const optimize = b.standardOptimizeOption(.{}); - const module = b.addModule("http", .{ + const module = b.addModule("web", .{ .root_source_file = b.path("src/root.zig"), .target = target, .optimize = optimize,