aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorNathan Reiner <nathan@nathanreiner.xyz>2025-11-14 21:55:59 +0100
committerNathan Reiner <nathan@nathanreiner.xyz>2025-11-14 21:55:59 +0100
commit3f18f02d07802d1fc705a500e5978a9b3cb2e751 (patch)
tree283970a2f5a693706456b853c550eeaa669b5d72 /src
parent351ad457f0ff95e20301a146b8c88a8f0f659aa1 (diff)
implement login
Diffstat (limited to 'src')
-rw-r--r--src/config.zig1
-rw-r--r--src/main.zig14
-rw-r--r--src/mime.zig (renamed from src/http.zig)17
-rw-r--r--src/prompt.zig44
-rw-r--r--src/root.zig1
-rw-r--r--src/routes/api/auth/first-login.zig14
-rw-r--r--src/routes/api/auth/login.zig25
-rw-r--r--src/routes/api/auth/root.zig4
-rw-r--r--src/routes/api/root.zig1
-rw-r--r--src/routes/context.zig14
-rw-r--r--src/routes/fallback.zig35
-rw-r--r--src/routes/handler-info.zig113
-rw-r--r--src/routes/login.zig17
-rw-r--r--src/routes/root.zig55
-rw-r--r--src/storage/root.zig57
-rw-r--r--src/storage/user.zig133
16 files changed, 446 insertions, 99 deletions
diff --git a/src/config.zig b/src/config.zig
new file mode 100644
index 0000000..8d4ab29
--- /dev/null
+++ b/src/config.zig
@@ -0,0 +1 @@
+pub const storage_path = "./storage/";
diff --git a/src/main.zig b/src/main.zig
index be43352..40f4016 100644
--- a/src/main.zig
+++ b/src/main.zig
@@ -8,6 +8,7 @@ pub const std_options = std.Options {
const log = std.log.scoped(.main);
var net_server: std.net.Server = undefined;
+var storage: memora.Storage = undefined;
fn signal_handler(signo: i32) callconv(.c) void {
if (signo == std.os.linux.SIG.INT) {
@@ -28,17 +29,18 @@ fn register_sigaction() void {
}
pub fn main() !void {
- register_sigaction();
-
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
defer std.debug.assert(gpa.deinit() == .ok);
-
const allocator = gpa.allocator();
+ storage = try .init(allocator);
+
const address = try std.net.Address.parseIpAndPort("0.0.0.0:8080");
net_server = try address.listen(.{ .reuse_address = true });
defer net_server.deinit();
+ register_sigaction();
+
log.info("listening on {f}", .{address});
while (true) {
@@ -58,6 +60,10 @@ pub fn main() !void {
log.info("{} {s}", .{request.head.method, request.head.target});
const handler_info = memora.routes.get(request.head.target);
- try handler_info.handle(&request, allocator);
+ try handler_info.handle(
+ &request,
+ &storage,
+ allocator
+ );
}
}
diff --git a/src/http.zig b/src/mime.zig
index ad4ecb6..0082f80 100644
--- a/src/http.zig
+++ b/src/mime.zig
@@ -1,22 +1,9 @@
const std = @import("std");
-pub fn respond_file(
- request: *std.http.Server.Request,
- file: std.fs.File,
- file_name: []const u8,
- allocator: std.mem.Allocator,
-) !void {
- const buffer = try file.readToEndAlloc(allocator, std.math.maxInt(usize));
- defer allocator.free(buffer);
-
+pub fn get_type(file_name: []const u8) []const u8 {
const suffix_pos = std.mem.lastIndexOfScalar(u8, file_name, '.') orelse file_name.len;
const suffix = file_name[suffix_pos..];
-
- try request.respond(buffer, .{
- .extra_headers = &.{
- .{ .name = "Content-Type", .value = content_types.get(suffix) orelse "text/plain" },
- }
- });
+ return content_types.get(suffix) orelse "text/plain";
}
pub const content_types = std.StaticStringMap([]const u8).initComptime(.{
diff --git a/src/prompt.zig b/src/prompt.zig
new file mode 100644
index 0000000..9fa9a9a
--- /dev/null
+++ b/src/prompt.zig
@@ -0,0 +1,44 @@
+const std = @import("std");
+const ansi = @import("ansi.zig");
+
+var output_buffer: [1024]u8 = undefined;
+var writer_stderr = std.fs.File.stderr().writer(&output_buffer);
+const stderr = &writer_stderr.interface;
+
+var input_buffer: [1024]u8 = undefined;
+var reader_stdin = std.fs.File.stdin().reader(&input_buffer);
+const stdin = &reader_stdin.interface;
+
+pub fn read(
+ prompt: []const u8,
+ allocator: std.mem.Allocator,
+) ![]u8 {
+ try stderr.print(
+ "{f}{f}{f} ",
+ .{
+ ansi.Styled {
+ .text = " ",
+ .bg = .blue,
+ .bold = true,
+ },
+ ansi.Styled {
+ .text = prompt,
+ .bg = .blue,
+ .bold = true,
+ },
+ ansi.Styled {
+ .text = ": ",
+ .bg = .blue,
+ .bold = true,
+ },
+ },
+ );
+
+ try stderr.flush();
+
+ var output = std.Io.Writer.Allocating.init(allocator);
+ errdefer output.deinit();
+ _ = try stdin.streamDelimiter(&output.writer, '\n');
+ stdin.toss(1);
+ return output.toOwnedSlice();
+}
diff --git a/src/root.zig b/src/root.zig
index 87f1892..301664e 100644
--- a/src/root.zig
+++ b/src/root.zig
@@ -2,4 +2,5 @@
const std = @import("std");
pub const log = @import("log.zig");
pub const routes = @import("routes/root.zig");
+pub const Storage = @import("storage/root.zig");
diff --git a/src/routes/api/auth/first-login.zig b/src/routes/api/auth/first-login.zig
new file mode 100644
index 0000000..2fb8c02
--- /dev/null
+++ b/src/routes/api/auth/first-login.zig
@@ -0,0 +1,14 @@
+const std = @import("std");
+const Context = @import("../../context.zig");
+
+pub const needs_auth = true;
+pub const method = .POST;
+
+const Result = struct {
+ is_first: bool,
+};
+
+pub fn handler(ctx: *Context) anyerror!Result {
+ _ = ctx;
+ return .{ .is_first = false };
+}
diff --git a/src/routes/api/auth/login.zig b/src/routes/api/auth/login.zig
new file mode 100644
index 0000000..c3f2bef
--- /dev/null
+++ b/src/routes/api/auth/login.zig
@@ -0,0 +1,25 @@
+const std = @import("std");
+
+const Context = @import("../../context.zig");
+const Storage = @import("../../../storage/root.zig");
+
+pub const needs_auth = false;
+pub const method = .POST;
+
+const Body = struct {
+ user: []const u8,
+ password: []const u8,
+};
+
+const Result = struct {
+ success: bool,
+};
+
+pub fn handler(ctx: *Context, body: Body) anyerror!Result {
+ var user = Storage.User.open(ctx.storage, body.user, ctx.allocator) catch return .{
+ .success = false
+ };
+ defer user.deinit();
+
+ return .{ .success = user.check_password(body.password) };
+}
diff --git a/src/routes/api/auth/root.zig b/src/routes/api/auth/root.zig
new file mode 100644
index 0000000..785271e
--- /dev/null
+++ b/src/routes/api/auth/root.zig
@@ -0,0 +1,4 @@
+const HandlerInfo = @import("../../handler-info.zig");
+
+pub const login: HandlerInfo = .from_type(@import("login.zig"));
+pub const first_login: HandlerInfo = .from_type(@import("first-login.zig"));
diff --git a/src/routes/api/root.zig b/src/routes/api/root.zig
new file mode 100644
index 0000000..19c4074
--- /dev/null
+++ b/src/routes/api/root.zig
@@ -0,0 +1 @@
+pub const auth = @import("auth/root.zig");
diff --git a/src/routes/context.zig b/src/routes/context.zig
new file mode 100644
index 0000000..bc3e710
--- /dev/null
+++ b/src/routes/context.zig
@@ -0,0 +1,14 @@
+const std = @import("std");
+const Storage = @import("../storage/root.zig");
+
+const Self = @This();
+
+allocator: std.mem.Allocator,
+request: *std.http.Server.Request,
+storage: *Storage,
+user: ?[]const u8 = null,
+response: struct {
+ headers: struct {
+ content_type: []const u8 = "application/json",
+ } = .{},
+} = .{},
diff --git a/src/routes/fallback.zig b/src/routes/fallback.zig
index 78d778b..25e1927 100644
--- a/src/routes/fallback.zig
+++ b/src/routes/fallback.zig
@@ -1,32 +1,37 @@
const std = @import("std");
-const http = @import("../http.zig");
+const mime = @import("../mime.zig");
+const Context = @import("context.zig");
const log = std.log.scoped(.fallback);
-pub fn handler(
- request: *std.http.Server.Request,
- allocator: std.mem.Allocator,
-) anyerror!void {
+pub const needs_auth = false;
+pub const method = .GET;
+
+pub fn handler(ctx: *Context) anyerror![]const u8 {
var static = try std.fs.cwd().openDir("static", .{});
defer static.close();
- if (static.openFile(request.head.target[1..], .{})) |file| {
+ if (static.openFile(ctx.request.head.target[1..], .{})) |file| {
defer file.close();
- try http.respond_file(request, file, request.head.target[1..], allocator);
+ const content = file.readToEndAlloc(ctx.allocator, std.math.maxInt(usize));
+ const mime_type = mime.get_type(ctx.request.head.target);
+ ctx.response.headers.content_type = mime_type;
+ return content;
} else |_| {
- var subdir = if (request.head.target.len == 1) static
- else (static.openDir(request.head.target[1..], .{}) catch {
- try request.respond("Not Found", .{ .status = .not_found });
- return;
+ var subdir = if (ctx.request.head.target.len == 1) static
+ else (static.openDir(ctx.request.head.target[1..], .{}) catch {
+ return error.NotFound;
});
- defer if (request.head.target.len > 1) subdir.close();
+ defer if (ctx.request.head.target.len > 1) subdir.close();
if (subdir.openFile("index.html", .{})) |file| {
defer file.close();
- try http.respond_file(request, file, "index.html", allocator);
+ const content = file.readToEndAlloc(ctx.allocator, std.math.maxInt(usize));
+ ctx.response.headers.content_type = "text/html";
+ return content;
} else |_| {
- log.warn("File '{s}' Not Found", .{ request.head.target });
- try request.respond("Not Found", .{ .status = .not_found });
+ log.warn("File '{s}' Not Found", .{ ctx.request.head.target });
+ return error.NotFound;
}
}
}
diff --git a/src/routes/handler-info.zig b/src/routes/handler-info.zig
new file mode 100644
index 0000000..bac7612
--- /dev/null
+++ b/src/routes/handler-info.zig
@@ -0,0 +1,113 @@
+const std = @import("std");
+const Context = @import("context.zig");
+const Storage = @import("../storage/root.zig");
+
+const log = std.log.scoped(.handler_info);
+
+handler: *const fn (*Context) anyerror![]const u8,
+needs_auth: bool,
+method: std.http.Method,
+
+pub fn handle(
+ self: *const @This(),
+ request: *std.http.Server.Request,
+ storage: *Storage,
+ allocator: std.mem.Allocator,
+) !void {
+ if (request.head.method != self.method) {
+ return request.respond(
+ "{ \"error\": \"Bad Request\" }",
+ .{ .status = .bad_request }
+ );
+ }
+
+ var arena = std.heap.ArenaAllocator.init(allocator);
+ defer arena.deinit();
+ var context: Context = .{
+ .request = request,
+ .storage = storage,
+ .allocator = arena.allocator(),
+ };
+
+ const response = self.handler(&context) catch |err| {
+ const response, const status_code: std.http.Status = switch (err) {
+ error.BadRequest => .{ "{ \"error\": \"Bad Request\" }", .bad_request },
+ error.Unauthorized => .{ "{ \"error\": \"Unauthorized\" }", .unauthorized },
+ error.Forbidden => .{ "{ \"error\": \"Forbidden\" }", .forbidden },
+ error.NotFound => .{ "{ \"error\": \"Not Found\" }", .not_found },
+ else => blk: {
+ log.err("handler for '{s}' returned {}", .{request.head.target, err});
+ break :blk .{ "{ \"error\": \"Internal Server Error\" }", .internal_server_error };
+ },
+ };
+
+ return request.respond(response, .{ .status = status_code });
+ };
+
+ try request.respond(response, .{
+ .extra_headers = &.{
+ .{
+ .name = "Content-Type",
+ .value = context.response.headers.content_type
+ },
+ },
+ });
+}
+
+pub fn from_type(T: type) @This() {
+ const info = @typeInfo(@TypeOf(T.handler));
+ const return_type = info.@"fn".return_type orelse void;
+ const payload_type = @typeInfo(return_type).error_union.payload;
+
+ const Handler = type: {
+ break :type struct {
+ pub fn call(ctx: *Context) anyerror![]const u8 {
+ const args = args: {
+ const tuple = std.meta.fields(std.meta.ArgsTuple(@TypeOf(T.handler)));
+
+ if (tuple.len == 1) {
+ break :args .{ ctx };
+ } else if (tuple.len == 2) {
+ const Body = tuple[1].@"type";
+
+ var writer = std.Io.Writer.Allocating.init(ctx.allocator);
+ const interface = &writer.writer;
+
+ var buffer: [1024]u8 = undefined;
+ const reader = try ctx.request.readerExpectContinue(&buffer);
+
+ try reader.streamExact64(interface, ctx.request.head.content_length orelse 0);
+
+ const body = std.json.parseFromSliceLeaky(
+ Body,
+ ctx.allocator,
+ writer.written(),
+ .{}
+ ) catch return error.BadRequest;
+ break :args .{ ctx, body };
+ } else {
+ @compileError("invalid amount of arguments for request function");
+ }
+ };
+
+ if (payload_type == []const u8) {
+ return @call(.auto, T.handler, args);
+ } else if (payload_type == void) {
+ try @call(.auto, T.handler, args);
+ return "";
+ } else {
+ var writer = std.Io.Writer.Allocating.init(ctx.allocator);
+ var stringify = std.json.Stringify { .writer = &writer.writer };
+ try stringify.write(try @call(.auto, T.handler, args));
+ return writer.written();
+ }
+ }
+ };
+ };
+
+ return @This() {
+ .handler = Handler.call,
+ .needs_auth = T.needs_auth,
+ .method = T.method,
+ };
+}
diff --git a/src/routes/login.zig b/src/routes/login.zig
deleted file mode 100644
index 29bf370..0000000
--- a/src/routes/login.zig
+++ /dev/null
@@ -1,17 +0,0 @@
-const std = @import("std");
-
-pub fn handler(
- request: *std.http.Server.Request,
- allocator: std.mem.Allocator,
-) anyerror!void {
- var output = std.Io.Writer.Allocating.init(allocator);
- var stringify = std.json.Stringify { .writer = &output.writer };
-
- try stringify.write(.{
- .user = "nathan.reiner",
- .name = "Nathan Reiner",
- });
-
- try output.writer.flush();
- try request.respond(output.written(), .{});
-}
diff --git a/src/routes/root.zig b/src/routes/root.zig
index 01952bb..dbfce32 100644
--- a/src/routes/root.zig
+++ b/src/routes/root.zig
@@ -1,56 +1,15 @@
const std = @import("std");
-pub const fallback = @import("fallback.zig");
-pub const login = @import("login.zig");
+pub const HandlerInfo = @import("handler-info.zig");
-pub const HandlerInfo = struct {
- handler: *const fn (
- request: *std.http.Server.Request,
- allocator: std.mem.Allocator,
- ) anyerror!void,
- needs_auth: bool,
- method: std.http.Method,
-
- pub fn handle(
- self: *const @This(),
- request: *std.http.Server.Request,
- allocator: std.mem.Allocator,
- ) !void {
- if (request.head.method != self.method) {
- try request.respond("{ \"error\": \"Bad Request\" }", .{ .status = .bad_request });
- }
-
- self.handler(request, allocator) catch |err| {
- const response, const status_code: std.http.Status = switch (err) {
- error.BadRequest => .{ "{ \"error\": \"Bad Request\" }", .bad_request },
- error.Unauthorized => .{ "{ \"error\": \"Unauthorized\" }", .unauthorized },
- error.Forbidden => .{ "{ \"error\": \"Forbidden\" }", .forbidden },
- error.NotFound => .{ "{ \"error\": \"Not Found\" }", .not_found },
- else => .{ "{ \"error\": \"Internal Server Error\" }", .internal_server_error },
- };
-
- try request.respond(response, .{ .status = status_code });
- };
- }
-};
+pub const Context = @import("context.zig");
+pub const api = @import("api/root.zig");
+pub const fallback: HandlerInfo = .from_type(@import("fallback.zig"));
pub const handlers = std.StaticStringMap(HandlerInfo).initComptime(.{
- .{
- "",
- HandlerInfo {
- .handler = fallback.handler,
- .needs_auth = false,
- .method = .GET,
- }
- },
- .{
- "/api/login",
- HandlerInfo {
- .handler = login.handler,
- .needs_auth = false,
- .method = .POST,
- }
- },
+ .{ "", fallback },
+ .{ "/api/auth/login", api.auth.login },
+ .{ "/api/auth/first-login", api.auth.first_login },
});
pub fn get(path: []const u8) HandlerInfo {
diff --git a/src/storage/root.zig b/src/storage/root.zig
new file mode 100644
index 0000000..bf9803e
--- /dev/null
+++ b/src/storage/root.zig
@@ -0,0 +1,57 @@
+const std = @import("std");
+const config = @import("../config.zig");
+const prompt = @import("../prompt.zig");
+
+pub const User = @import("user.zig");
+
+const Self = @This();
+
+dir: std.fs.Dir,
+
+pub fn init(allocator: std.mem.Allocator) !Self {
+ const dir = std.fs.cwd().openDir(config.storage_path, .{}) catch blk: {
+ try std.fs.cwd().makeDir(config.storage_path);
+ break :blk try std.fs.cwd().openDir(config.storage_path, .{});
+ };
+
+ var self = Self { .dir = dir };
+
+ dir.access("user", .{}) catch |err| switch (err) {
+ error.FileNotFound => {
+ const name = try prompt.read("Username", allocator);
+ defer allocator.free(name);
+
+ const full_name = try prompt.read("Full Name", allocator);
+ defer allocator.free(full_name);
+
+ const birthday = try prompt.read("Birthday", allocator);
+ defer allocator.free(birthday);
+
+ const password = try prompt.read("Password", allocator);
+ defer allocator.free(password);
+
+ try self.dir.makeDir("user");
+
+ var user: User = try .new(
+ &self,
+ name,
+ full_name,
+ birthday,
+ password,
+ true,
+ allocator,
+ );
+ defer user.deinit();
+
+ try user.sync();
+ },
+ else => return err,
+ };
+
+ return self;
+}
+
+pub fn deinit(self: *Self) void {
+ self.dir.close();
+ self.* = undefined;
+}
diff --git a/src/storage/user.zig b/src/storage/user.zig
new file mode 100644
index 0000000..c981661
--- /dev/null
+++ b/src/storage/user.zig
@@ -0,0 +1,133 @@
+const std = @import("std");
+const Storage = @import("root.zig");
+
+const Self = @This();
+
+pub const UserInfo = struct {
+ name: []const u8,
+ full_name: []const u8,
+ birthday: []const u8,
+ hash: []const u8,
+ is_admin: bool,
+};
+
+dir: std.fs.Dir,
+info: UserInfo,
+arena: std.heap.ArenaAllocator,
+
+pub fn open(
+ storage: *Storage,
+ name: []const u8,
+ inner_allocator: std.mem.Allocator
+) !Self {
+ var arena: std.heap.ArenaAllocator = .init(inner_allocator);
+ errdefer arena.deinit();
+ const allocator = arena.allocator();
+
+ var user_dir = try storage.dir.openDir("user", .{});
+ defer user_dir.close();
+
+ var dir = try user_dir.openDir(name, .{});
+ errdefer dir.close();
+
+ const file = try dir.openFile("info.json", .{});
+ defer file.close();
+ const content = try file.readToEndAlloc(allocator, std.math.maxInt(usize));
+
+ const info = try std.json.parseFromSliceLeaky(UserInfo, allocator, content, .{});
+
+ return .{
+ .dir = dir,
+ .arena = arena,
+ .info = info,
+ };
+}
+
+pub fn new(
+ storage: *Storage,
+ name: []const u8,
+ full_name: []const u8,
+ birthday: []const u8,
+ password: []const u8,
+ is_admin: bool,
+ inner_allocator: std.mem.Allocator
+) !Self {
+ var arena: std.heap.ArenaAllocator = .init(inner_allocator);
+ const allocator = arena.allocator();
+ errdefer arena.deinit();
+
+ var user_dir = try storage.dir.openDir("user", .{});
+ defer user_dir.close();
+
+ try user_dir.makeDir(name);
+ var dir = try user_dir.openDir(name, .{});
+ errdefer dir.close();
+
+ var file = try dir.createFile("info.json", .{});
+ file.close();
+
+ const hash_buf = try allocator.alloc(u8, 256);
+ const hash = try std.crypto.pwhash.bcrypt.strHash(
+ password,
+ .{
+ .params = .{
+ .rounds_log = 3,
+ .silently_truncate_password = false,
+ },
+ .encoding = .phc,
+ },
+ hash_buf
+ );
+
+ const info: UserInfo = .{
+ .name = name,
+ .full_name = full_name,
+ .birthday = birthday,
+ .hash = hash,
+ .is_admin = is_admin,
+ };
+
+
+ return .{
+ .dir = dir,
+ .arena = arena,
+ .info = info,
+ };
+}
+
+pub fn sync(self: *Self) !void {
+ const file = try self.dir.openFile("info.json", .{ .mode = .write_only });
+ defer file.close();
+
+ var buffer: [1024]u8 = undefined;
+ var writer = file.writer(&buffer);
+
+ var stringify = std.json.Stringify {
+ .writer = &writer.interface,
+ .options = .{ .whitespace = .indent_2 }
+ };
+
+ try stringify.write(self.info);
+ try writer.interface.flush();
+}
+
+pub fn check_password(self: *Self, password: []const u8) bool {
+ std.crypto.pwhash.bcrypt.strVerify(self.info.hash, password, .{
+ .silently_truncate_password = false,
+ }) catch return false;
+ return true;
+}
+
+pub fn deinit(self: *Self) void {
+ self.dir.close();
+ self.arena.deinit();
+ self.* = undefined;
+}
+
+pub fn exists(storage: *Storage, name: []const u8) bool {
+ var user = storage.dir.openDir("user", .{}) catch return false;
+ defer user.deinit();
+
+ user.access(name) catch return false;
+ return true;
+}