diff --git a/.gitignore b/.gitignore index 0d6daf0..86111dc 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ .zig-cache/ zig-out/ .direnv/ +.zetviel_creds diff --git a/README.md b/README.md index 6b974f8..518a041 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,7 @@ Features - REST API for notmuch queries - Thread and message viewing - Attachment handling +- Basic authentication for API routes - Security headers for safe browsing - Configurable port @@ -46,8 +47,30 @@ Configuration ------------- - `NOTMUCH_PATH` environment variable: Path to notmuch database (default: `mail`) +- `ZETVIEL_CREDS` environment variable: Path to credentials file (default: `.zetviel_creds`) - `--port`: HTTP server port (default: 5000) +### Authentication + +Zetviel requires basic authentication for all API routes. Create a credentials file with: + +```sh +echo 'username' > .zetviel_creds +echo 'password' >> .zetviel_creds +``` + +Or set a custom path: + +```sh +export ZETVIEL_CREDS=/path/to/credentials +``` + +The credentials file should contain two lines: +1. Username +2. Password + +Static files (HTML, CSS, JS) are served without authentication. + API Endpoints ------------- @@ -55,9 +78,15 @@ API Endpoints - `GET /api/thread/` - Get messages in a thread - `GET /api/message/` - Get message details with content - `GET /api/attachment//` - Get attachment metadata +- `GET /api/auth/status` - Check authentication status Security -------- -**WARNING**: Zetviel is intended for local use only. It binds to 127.0.0.1 and should -not be exposed to the internet without additional security measures. +**WARNING**: Zetviel binds to 0.0.0.0 by default, making it accessible on all network interfaces. +While basic authentication is required for API routes, this is intended for local or trusted network use only. +Do not expose Zetviel directly to the internet without additional security measures such as: +- Running behind a reverse proxy with HTTPS +- Using a VPN or SSH tunnel +- Implementing additional authentication layers +- Restricting access via firewall rules diff --git a/src/auth.zig b/src/auth.zig new file mode 100644 index 0000000..9ffb68a --- /dev/null +++ b/src/auth.zig @@ -0,0 +1,69 @@ +const std = @import("std"); +const httpz = @import("httpz"); + +pub const Credentials = struct { + username: []const u8, + password: []const u8, +}; + +pub fn loadCredentials(allocator: std.mem.Allocator, path: []const u8) !Credentials { + const file = try std.fs.cwd().openFile(path, .{}); + defer file.close(); + + const content = try file.readToEndAlloc(allocator, 1024); + defer allocator.free(content); + + var lines = std.mem.splitScalar(u8, content, '\n'); + const username = std.mem.trim(u8, lines.next() orelse return error.InvalidCredentials, &std.ascii.whitespace); + const password = std.mem.trim(u8, lines.next() orelse return error.InvalidCredentials, &std.ascii.whitespace); + + return .{ + .username = try allocator.dupe(u8, username), + .password = try allocator.dupe(u8, password), + }; +} + +pub const BasicAuth = struct { + creds: Credentials, + + pub fn execute(self: *BasicAuth, req: *httpz.Request, res: *httpz.Response, executor: anytype) !void { + const auth_header = req.header("authorization") orelse { + res.status = 401; + return; + }; + + if (!std.mem.startsWith(u8, auth_header, "Basic ")) { + res.status = 401; + return; + } + + const encoded = auth_header[6..]; + var decoded_buf: [256]u8 = undefined; + const decoded_len = std.base64.standard.Decoder.calcSizeForSlice(encoded) catch { + res.status = 401; + return; + }; + _ = std.base64.standard.Decoder.decode(&decoded_buf, encoded) catch { + res.status = 401; + return; + }; + const decoded = decoded_buf[0..decoded_len]; + + var parts = std.mem.splitScalar(u8, decoded, ':'); + const username = parts.next() orelse { + res.status = 401; + return; + }; + const password = parts.next() orelse { + res.status = 401; + return; + }; + + if (!std.mem.eql(u8, username, self.creds.username) or !std.mem.eql(u8, password, self.creds.password)) { + res.status = 401; + return; + } + + return executor.next(); + } +}; diff --git a/src/main.zig b/src/main.zig index f075d14..e0ee7bf 100644 --- a/src/main.zig +++ b/src/main.zig @@ -1,6 +1,7 @@ const std = @import("std"); const httpz = @import("httpz"); const root = @import("root.zig"); +const auth = @import("auth.zig"); const version = @import("build_options").git_revision; @@ -67,6 +68,25 @@ pub fn main() !u8 { const db_path = std.posix.getenv("NOTMUCH_PATH") orelse "mail"; try stdout.print("Notmuch database: {s}\n", .{db_path}); + // Load credentials + const creds_path = std.posix.getenv("ZETVIEL_CREDS") orelse ".zetviel_creds"; + const creds = auth.loadCredentials(allocator, creds_path) catch |err| { + if (err == error.FileNotFound) { + try stderr.print("Warning: No credentials file found at {s}\n", .{creds_path}); + try stderr.writeAll("API routes will be unprotected. Create a credentials file with:\n"); + try stderr.writeAll(" echo 'username' > .zetviel_creds\n"); + try stderr.writeAll(" echo 'password' >> .zetviel_creds\n"); + } else { + try stderr.print("Error loading credentials: {s}\n", .{@errorName(err)}); + return 1; + } + return 1; + }; + defer { + allocator.free(creds.username); + allocator.free(creds.password); + } + // Open notmuch database var db = try root.openNotmuchDb( allocator, @@ -85,18 +105,26 @@ pub fn main() !u8 { }, &db); defer server.deinit(); - // API routes + // Security headers middleware var security_headers = SecurityHeaders{}; const security_middleware = httpz.Middleware(*root.NotmuchDb).init(&security_headers); - var router = try server.router(.{ .middlewares = &.{security_middleware} }); - router.get("/api/query/*", queryHandler, .{}); - router.get("/api/thread/:thread_id", threadHandler, .{}); - router.get("/api/message/:message_id", messageHandler, .{}); - router.get("/api/attachment/:message_id/:num", attachmentHandler, .{}); - // Static file serving - router.get("/", indexHandler, .{}); - router.get("/*", staticHandler, .{}); + // Auth middleware for API routes + var basic_auth = auth.BasicAuth{ .creds = creds }; + const auth_middleware = httpz.Middleware(*root.NotmuchDb).init(&basic_auth); + + // API routes with auth + var api_router = try server.router(.{ .middlewares = &.{ security_middleware, auth_middleware } }); + api_router.get("/api/query/*", queryHandler, .{}); + api_router.get("/api/thread/:thread_id", threadHandler, .{}); + api_router.get("/api/message/:message_id", messageHandler, .{}); + api_router.get("/api/attachment/:message_id/:num", attachmentHandler, .{}); + api_router.get("/api/auth/status", authStatusHandler, .{}); + + // Static file serving (no auth) + var static_router = try server.router(.{ .middlewares = &.{security_middleware} }); + static_router.get("/", indexHandler, .{}); + static_router.get("/*", staticHandler, .{}); try server.listen(); return 0; @@ -117,6 +145,7 @@ fn indexHandler(db: *root.NotmuchDb, _: *httpz.Request, res: *httpz.Response) !v }; res.header("Content-Type", "text/html"); + res.header("Cache-Control", "no-cache, no-store, must-revalidate"); res.body = content; } @@ -264,6 +293,10 @@ fn attachmentHandler(db: *root.NotmuchDb, req: *httpz.Request, res: *httpz.Respo try res.json(.{ .filename = att.filename, .content_type = att.content_type }, .{}); } +fn authStatusHandler(_: *root.NotmuchDb, _: *httpz.Request, res: *httpz.Response) !void { + try res.json(.{ .authenticated = true }, .{}); +} + test "queryHandler with valid query" { const allocator = std.testing.allocator; var db = try root.openNotmuchDb(allocator, "mail", null); diff --git a/static/index.html b/static/index.html index 5f00e52..fca3ca1 100644 --- a/static/index.html +++ b/static/index.html @@ -42,6 +42,12 @@ button:disabled { background: #444; cursor: not-allowed; } .help-overlay code { background: #2a2a2a; padding: 0.2rem 0.4rem; border-radius: 3px; color: #4ec9b0; } .help-overlay ul { list-style: none; margin-top: 0.5rem; } .help-overlay li { margin: 0.5rem 0; } +.login-overlay { display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.8); z-index: 2000; align-items: center; justify-content: center; } +.login-overlay.visible { display: flex; } +.login-box { background: #252525; border: 1px solid #444; border-radius: 8px; padding: 2rem; width: 300px; } +.login-box h2 { margin-bottom: 1rem; } +.login-box input { width: 100%; padding: 0.5rem; margin-bottom: 1rem; border: 1px solid #444; border-radius: 4px; background: #2a2a2a; color: #e0e0e0; } +.login-box button { width: 100%; } @@ -51,11 +57,14 @@ button:disabled { background: #444; cursor: not-allowed; }

Zetviel

? -
+
+ +
+
@@ -89,6 +98,15 @@ button:disabled { background: #444; cursor: not-allowed; }
  • ? - Toggle this help
  • + +