From 3f18f02d07802d1fc705a500e5978a9b3cb2e751 Mon Sep 17 00:00:00 2001 From: Nathan Reiner Date: Fri, 14 Nov 2025 21:55:59 +0100 Subject: implement login --- src/config.zig | 1 + src/http.zig | 100 --------------------------- src/main.zig | 14 ++-- src/mime.zig | 87 +++++++++++++++++++++++ src/prompt.zig | 44 ++++++++++++ src/root.zig | 1 + src/routes/api/auth/first-login.zig | 14 ++++ src/routes/api/auth/login.zig | 25 +++++++ src/routes/api/auth/root.zig | 4 ++ src/routes/api/root.zig | 1 + src/routes/context.zig | 14 ++++ src/routes/fallback.zig | 35 ++++++---- src/routes/handler-info.zig | 113 ++++++++++++++++++++++++++++++ src/routes/login.zig | 17 ----- src/routes/root.zig | 55 ++------------- src/storage/root.zig | 57 ++++++++++++++++ src/storage/user.zig | 133 ++++++++++++++++++++++++++++++++++++ static/api/auth.js | 12 ++++ static/api/images.js | 20 ++++++ static/api/index.js | 13 +--- static/api/rest.js | 22 ++++++ static/icons/index.js | 1 + static/icons/next.js | 3 + static/index.js | 31 ++++++--- static/pages/login/index.css | 69 +++++++++++-------- static/pages/login/index.js | 66 ++++++++++++++---- static/pages/settings/index.css | 2 + static/pages/settings/index.js | 2 +- 28 files changed, 711 insertions(+), 245 deletions(-) create mode 100644 src/config.zig delete mode 100644 src/http.zig create mode 100644 src/mime.zig create mode 100644 src/prompt.zig create mode 100644 src/routes/api/auth/first-login.zig create mode 100644 src/routes/api/auth/login.zig create mode 100644 src/routes/api/auth/root.zig create mode 100644 src/routes/api/root.zig create mode 100644 src/routes/context.zig create mode 100644 src/routes/handler-info.zig delete mode 100644 src/routes/login.zig create mode 100644 src/storage/root.zig create mode 100644 src/storage/user.zig create mode 100644 static/api/auth.js create mode 100644 static/api/images.js create mode 100644 static/api/rest.js create mode 100644 static/icons/next.js 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/http.zig b/src/http.zig deleted file mode 100644 index ad4ecb6..0000000 --- a/src/http.zig +++ /dev/null @@ -1,100 +0,0 @@ -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); - - 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" }, - } - }); -} - -pub const content_types = std.StaticStringMap([]const u8).initComptime(.{ - .{ ".aac", "audio/aac" }, - .{ ".abw", "application/x-abiword" }, - .{ ".apng", "image/apng" }, - .{ ".arc", "application/x-freearc" }, - .{ ".avif", "image/avif" }, - .{ ".avi", "video/x-msvideo" }, - .{ ".azw", "application/vnd.amazon.ebook" }, - .{ ".bin", "application/octet-stream" }, - .{ ".bmp", "image/bmp" }, - .{ ".bz", "application/x-bzip" }, - .{ ".bz2", "application/x-bzip2" }, - .{ ".cda", "application/x-cdf" }, - .{ ".csh", "application/x-csh" }, - .{ ".css", "text/css" }, - .{ ".csv", "text/csv" }, - .{ ".doc", "application/msword" }, - .{ ".docx", "application/vnd.openxmlformats-officedocument.wordprocessingml.document" }, - .{ ".eot", "application/vnd.ms-fontobject" }, - .{ ".epub", "application/epub+zip" }, - .{ ".gz", "application/gzip." }, - .{ ".gif", "image/gif" }, - .{ ".htm", "text/html" }, - .{ ".html", "text/html" }, - .{ ".ico", "image/vnd.microsoft.icon" }, - .{ ".ics", "text/calendar" }, - .{ ".jar", "application/java-archive" }, - .{ ".jpeg, .jpg", "image/jpeg" }, - .{ ".js", "text/javascript" }, - .{ ".json", "application/json" }, - .{ ".jsonld", "application/ld+json" }, - .{ ".md", "text/markdown" }, - .{ ".mid, .midi", "audio/midi," }, - .{ ".mjs", "text/javascript" }, - .{ ".mp3", "audio/mpeg" }, - .{ ".mp4", "video/mp4" }, - .{ ".mpeg", "video/mpeg" }, - .{ ".mpkg", "application/vnd.apple.installer+xml" }, - .{ ".odp", "application/vnd.oasis.opendocument.presentation" }, - .{ ".ods", "application/vnd.oasis.opendocument.spreadsheet" }, - .{ ".odt", "application/vnd.oasis.opendocument.text" }, - .{ ".oga", "audio/ogg" }, - .{ ".ogv", "video/ogg" }, - .{ ".ogx", "application/ogg" }, - .{ ".opus", "audio/ogg" }, - .{ ".otf", "font/otf" }, - .{ ".png", "image/png" }, - .{ ".pdf", "application/pdf" }, - .{ ".php", "application/x-httpd-php" }, - .{ ".ppt", "application/vnd.ms-powerpoint" }, - .{ ".pptx", "application/vnd.openxmlformats-officedocument.presentationml.presentation" }, - .{ ".rar", "application/vnd.rar" }, - .{ ".rtf", "application/rtf" }, - .{ ".sh", "application/x-sh" }, - .{ ".svg", "image/svg+xml" }, - .{ ".tar", "application/x-tar" }, - .{ ".tif, .tiff", "image/tiff" }, - .{ ".ts", "video/mp2t" }, - .{ ".ttf", "font/ttf" }, - .{ ".txt", "text/plain" }, - .{ ".vsd", "application/vnd.visio" }, - .{ ".wav", "audio/wav" }, - .{ ".weba", "audio/webm" }, - .{ ".webm", "video/webm" }, - .{ ".webmanifest", "application/manifest+json" }, - .{ ".webp", "image/webp" }, - .{ ".woff", "font/woff" }, - .{ ".woff2", "font/woff2" }, - .{ ".xhtml", "application/xhtml+xml" }, - .{ ".xls", "application/vnd.ms-excel" }, - .{ ".xlsx", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" }, - .{ ".xml", "application/xml" }, - .{ ".xul", "application/vnd.mozilla.xul+xml" }, - .{ ".zip", "application/zip." }, - .{ ".3gp", "video/3gpp;" }, - .{ ".3g2", "video/3gpp2;" }, - .{ ".7z", "application/x-7z-compressed" }, -}); - 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/mime.zig b/src/mime.zig new file mode 100644 index 0000000..0082f80 --- /dev/null +++ b/src/mime.zig @@ -0,0 +1,87 @@ +const std = @import("std"); + +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..]; + return content_types.get(suffix) orelse "text/plain"; +} + +pub const content_types = std.StaticStringMap([]const u8).initComptime(.{ + .{ ".aac", "audio/aac" }, + .{ ".abw", "application/x-abiword" }, + .{ ".apng", "image/apng" }, + .{ ".arc", "application/x-freearc" }, + .{ ".avif", "image/avif" }, + .{ ".avi", "video/x-msvideo" }, + .{ ".azw", "application/vnd.amazon.ebook" }, + .{ ".bin", "application/octet-stream" }, + .{ ".bmp", "image/bmp" }, + .{ ".bz", "application/x-bzip" }, + .{ ".bz2", "application/x-bzip2" }, + .{ ".cda", "application/x-cdf" }, + .{ ".csh", "application/x-csh" }, + .{ ".css", "text/css" }, + .{ ".csv", "text/csv" }, + .{ ".doc", "application/msword" }, + .{ ".docx", "application/vnd.openxmlformats-officedocument.wordprocessingml.document" }, + .{ ".eot", "application/vnd.ms-fontobject" }, + .{ ".epub", "application/epub+zip" }, + .{ ".gz", "application/gzip." }, + .{ ".gif", "image/gif" }, + .{ ".htm", "text/html" }, + .{ ".html", "text/html" }, + .{ ".ico", "image/vnd.microsoft.icon" }, + .{ ".ics", "text/calendar" }, + .{ ".jar", "application/java-archive" }, + .{ ".jpeg, .jpg", "image/jpeg" }, + .{ ".js", "text/javascript" }, + .{ ".json", "application/json" }, + .{ ".jsonld", "application/ld+json" }, + .{ ".md", "text/markdown" }, + .{ ".mid, .midi", "audio/midi," }, + .{ ".mjs", "text/javascript" }, + .{ ".mp3", "audio/mpeg" }, + .{ ".mp4", "video/mp4" }, + .{ ".mpeg", "video/mpeg" }, + .{ ".mpkg", "application/vnd.apple.installer+xml" }, + .{ ".odp", "application/vnd.oasis.opendocument.presentation" }, + .{ ".ods", "application/vnd.oasis.opendocument.spreadsheet" }, + .{ ".odt", "application/vnd.oasis.opendocument.text" }, + .{ ".oga", "audio/ogg" }, + .{ ".ogv", "video/ogg" }, + .{ ".ogx", "application/ogg" }, + .{ ".opus", "audio/ogg" }, + .{ ".otf", "font/otf" }, + .{ ".png", "image/png" }, + .{ ".pdf", "application/pdf" }, + .{ ".php", "application/x-httpd-php" }, + .{ ".ppt", "application/vnd.ms-powerpoint" }, + .{ ".pptx", "application/vnd.openxmlformats-officedocument.presentationml.presentation" }, + .{ ".rar", "application/vnd.rar" }, + .{ ".rtf", "application/rtf" }, + .{ ".sh", "application/x-sh" }, + .{ ".svg", "image/svg+xml" }, + .{ ".tar", "application/x-tar" }, + .{ ".tif, .tiff", "image/tiff" }, + .{ ".ts", "video/mp2t" }, + .{ ".ttf", "font/ttf" }, + .{ ".txt", "text/plain" }, + .{ ".vsd", "application/vnd.visio" }, + .{ ".wav", "audio/wav" }, + .{ ".weba", "audio/webm" }, + .{ ".webm", "video/webm" }, + .{ ".webmanifest", "application/manifest+json" }, + .{ ".webp", "image/webp" }, + .{ ".woff", "font/woff" }, + .{ ".woff2", "font/woff2" }, + .{ ".xhtml", "application/xhtml+xml" }, + .{ ".xls", "application/vnd.ms-excel" }, + .{ ".xlsx", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" }, + .{ ".xml", "application/xml" }, + .{ ".xul", "application/vnd.mozilla.xul+xml" }, + .{ ".zip", "application/zip." }, + .{ ".3gp", "video/3gpp;" }, + .{ ".3g2", "video/3gpp2;" }, + .{ ".7z", "application/x-7z-compressed" }, +}); + 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; +} diff --git a/static/api/auth.js b/static/api/auth.js new file mode 100644 index 0000000..e663d88 --- /dev/null +++ b/static/api/auth.js @@ -0,0 +1,12 @@ +import * as rest from './rest.js'; + + +export async function login(user, password) { + const response = await rest.post('/api/auth/login', { user, password }); + return response?.success ?? false; +} + +export async function is_first_login(user) { + const response = await rest.post('/api/auth/first-login', { user }); + return response?.is_first ?? false; +} diff --git a/static/api/images.js b/static/api/images.js new file mode 100644 index 0000000..915aae6 --- /dev/null +++ b/static/api/images.js @@ -0,0 +1,20 @@ +import * as sfw from 'sfw'; +const { Input } = sfw.element.native; + +export async function upload_to_timeline() { + const input = Input.new({ + type: 'file', + multiple: true, + accept: 'image/jpeg', + }) + input.click(); +} + +export async function upload_to_profile() { + const input = Input.new({ + type: 'file', + multiple: false, + accept: 'image/jpeg', + }) + input.click(); +} diff --git a/static/api/index.js b/static/api/index.js index 50b9b26..07b484d 100644 --- a/static/api/index.js +++ b/static/api/index.js @@ -1,11 +1,2 @@ -import * as sfw from 'sfw'; -const { Input } = sfw.element.native; - -export async function upload_images() { - const input = Input.new({ - type: 'file', - multiple: true, - accept: 'image/jpeg', - }) - input.click(); -} +export * as images from './images.js'; +export * as auth from './auth.js'; diff --git a/static/api/rest.js b/static/api/rest.js new file mode 100644 index 0000000..b314615 --- /dev/null +++ b/static/api/rest.js @@ -0,0 +1,22 @@ + +async function handle_fetch(promise) { + const result = await promise; + const json = await result.json(); + + if (!result.ok) { + throw json; + } + + return json; +} + +export function get(url) { + return handle_fetch(fetch(url)); +} + +export async function post(url, body) { + return handle_fetch(fetch(url, { + method: 'POST', + body: JSON.stringify(body), + })); +} diff --git a/static/icons/index.js b/static/icons/index.js index 08ab7ea..3562754 100644 --- a/static/icons/index.js +++ b/static/icons/index.js @@ -11,6 +11,7 @@ const icons = [ 'add', 'edit', 'check', + 'next', ]; const target = { diff --git a/static/icons/next.js b/static/icons/next.js new file mode 100644 index 0000000..9728ee7 --- /dev/null +++ b/static/icons/next.js @@ -0,0 +1,3 @@ +export default ` + +`; diff --git a/static/index.js b/static/index.js index 870e3bb..c6388eb 100644 --- a/static/index.js +++ b/static/index.js @@ -29,15 +29,28 @@ const image_viewer = ImageViewer.new(); '/images/0010.jpg', ].forEach(url => image_viewer.add(url)) + const login = LoginView.new({ - onlogin: () => { - document.body.innerHTML = ''; - document.body.append( - main, - search, - month_select, - ) - } + onlogin: async (user, password) => { + if (await api.auth.login(user, password)) { + document.body.innerHTML = ''; + document.body.append( + main, + search, + month_select, + ) + } else { + login.comment = 'Incorrect username or password.'; + } + }, + + onpassword: async (user) => { + if (await api.auth.is_first_login(user)) { + login.comment = 'Please enter a new password.'; + } else { + login.comment = ''; + } + }, }); const search = Search.new({ @@ -73,7 +86,7 @@ const main = MainView.new({ main.active_view = image_viewer; month_select.show(); }, - onupload: () => api.upload_images(), + onupload: () => api.images.upload_to_timeline(), onshuffle: () => { main.active_kind = MainView.Kind.home; main.active_view = shuffle; diff --git a/static/pages/login/index.css b/static/pages/login/index.css index 8a59d83..aa6726e 100644 --- a/static/pages/login/index.css +++ b/static/pages/login/index.css @@ -12,12 +12,52 @@ transform: translate(-50%, -50%); max-width: 300px; width: 100%; - padding: 20px; + padding: 0px; box-shadow: #223223aa 1px 1px 4px; border-radius: var(--border-radius); + z-index: 2000; + display: grid; + grid-template-columns: auto 30px; background: #ffffff99; backdrop-filter: blur(10px); - z-index: 2000; +} + +#box input { + border-top-right-radius: 0px; + border-bottom-right-radius: 0px; + background: #0000; +} + +#box button { + border-top-left-radius: 0px; + border-bottom-left-radius: 0px; + background: var(--page-background); + color: var(--fg); + padding: 5px; + background: #0000; +} + +#username { + position: absolute; + top: calc(50% - 40px); + left: 50%; + transform: translate(-50%, -50%); + z-index: 10000; + user-select: none; + color: var(--fg-primary); + font-size: 0.8em; + cursor: pointer; +} +#comment { + position: absolute; + top: calc(50% + 40px); + left: 50%; + transform: translate(-50%, -50%); + z-index: 10000; + user-select: none; + color: var(--primary); + font-size: 0.8em; + cursor: pointer; } #title { @@ -88,31 +128,6 @@ z-index: -2; animation: right-bubble 1s normal forwards ease; } - -#form { - display: grid; - grid-template-columns: 100px auto; - max-width: 300px; - width: 100%; - margin: 0; - margin-bottom: 20px; - border-radius: 3px; - gap: 10px; -} - -#form input { - width: 100%; -} - -#form label { - margin: auto 0px; - font-weight: bold; -} - -button { - width: 100%; -} - #subtitle { position: absolute; bottom: 50px; diff --git a/static/pages/login/index.js b/static/pages/login/index.js index fc14dbf..07c0aa2 100644 --- a/static/pages/login/index.js +++ b/static/pages/login/index.js @@ -1,42 +1,53 @@ import * as sfw from 'sfw'; const { Div, Label, H1: Title, Input, Button } = sfw.element.native; +import icons from '../../icons/index.js'; + const css = await sfw.css(import.meta.url, './index.css'); export default class LoginView extends sfw.element.Container { + #input #user - #password + #comment constructor() { super({ css }); this.onlogin = () => {}; + this.onpassword = () => {}; + + this.#user = null; this.body.append( Div.new({ id: 'container', children: [ Div.new({ id: 'title', innerText: 'Memora' }), + this.#user = Div.new({ + id: 'username', + onclick: () => this.#reset(), + }), Div.new({ id: 'box', children: [ - - Div.new({ - id: 'form', - children: [ - Label.new({ innerText: 'User' }), - this.#user = Input.new({ }), - - Label.new({ innerText: 'Password' }), - this.#password = Input.new({ type: 'password' }), - ] + this.#input = Input.new({ + placeholder: 'Username', + onkeydown: (e) => { + if (e.key === 'Enter') { + return this.#next(); + } + }, }), Button.new({ - innerText: 'Login', - onclick: () => this.onlogin(this.#user.value, this.#password.value), + children: [ icons.next ], + onclick: () => this.#next(), }), ] }), + this.#comment = Div.new({ + id: 'comment', + onclick: () => this.#reset(), + }), Div.new({ id: 'subtitle', innerText: 'Where nostalgia is home.', @@ -45,4 +56,33 @@ export default class LoginView extends sfw.element.Container { }) ); } + + async #next() { + if (this.#input.value === '') return; + + if (this.#user.innerText) { + await this.onlogin(this.#user.innerText, this.#input.value); + this.#user.innerText = ''; + this.#input.value = ''; + this.#input.placeholder = 'Username'; + this.#input.type = 'text'; + } else { + await this.onpassword(this.#input.value); + this.#user.innerText = this.#input.value; + this.#input.value = ''; + this.#input.placeholder = 'Password'; + this.#input.type = 'password'; + } + } + + #reset() { + this.#user.innerText = ''; + this.#input.value = ''; + this.#input.placeholder = 'Username'; + this.#input.type = 'text'; + } + + set comment(value) { + this.#comment.innerText = value; + } } diff --git a/static/pages/settings/index.css b/static/pages/settings/index.css index 5128c18..eb718ab 100644 --- a/static/pages/settings/index.css +++ b/static/pages/settings/index.css @@ -7,6 +7,8 @@ display: flex; flex-flow: column; gap: 20px; + max-width: 700px; + margin: auto; } #profile-image { diff --git a/static/pages/settings/index.js b/static/pages/settings/index.js index da30ba7..6a0e231 100644 --- a/static/pages/settings/index.js +++ b/static/pages/settings/index.js @@ -45,7 +45,7 @@ export default class SettingsView extends sfw.element.Container { }), Div.new({ id: 'logout', - innerText: 'Log-out', + innerText: 'Logout', onclick: () => this.onlogout(), }), Div.new({ -- cgit v1.2.3-70-g09d2