add authn
All checks were successful
Generic zig build / build (push) Successful in 52s

This commit is contained in:
Emil Lerch 2025-10-15 22:41:44 -07:00
parent 7b6a959086
commit 0b804a0d63
Signed by: lobo
GPG key ID: A7B62D657EF764F8
5 changed files with 249 additions and 18 deletions

1
.gitignore vendored
View file

@ -1,3 +1,4 @@
.zig-cache/
zig-out/
.direnv/
.zetviel_creds

View file

@ -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/<thread_id>` - Get messages in a thread
- `GET /api/message/<message_id>` - Get message details with content
- `GET /api/attachment/<message_id>/<num>` - 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

69
src/auth.zig Normal file
View file

@ -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();
}
};

View file

@ -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);

View file

@ -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%; }
</style>
</head>
<body>
@ -51,11 +57,14 @@ button:disabled { background: #444; cursor: not-allowed; }
<h1>Zetviel</h1>
<span class="help-icon" onclick="toggleHelp()" title="Help (?)">?</span>
</div>
<div id="status" class="status-ok"></div>
<div style="display: flex; align-items: center; gap: 1rem;">
<span id="auth-status" style="font-size: 0.9rem; color: #999;"></span>
<div id="status" class="status-ok"></div>
</div>
</header>
<div class="search-bar">
<input type="text" id="search" placeholder="Search (e.g., tag:inbox)" value="tag:inbox">
<input type="text" id="search" placeholder="Search (e.g., tag:inbox)">
<button onclick="search()">Search</button>
</div>
@ -89,6 +98,15 @@ button:disabled { background: #444; cursor: not-allowed; }
<li><code>?</code> - Toggle this help</li>
</ul>
</div>
<div id="login-overlay" class="login-overlay">
<div class="login-box">
<h2>Login Required</h2>
<input type="text" id="login-username" placeholder="Username" autocomplete="username">
<input type="password" id="login-password" placeholder="Password" autocomplete="current-password">
<button onclick="submitLogin()">Login</button>
</div>
</div>
</div>
<script>
let currentQuery = 'tag:inbox';
@ -96,13 +114,77 @@ let isLoading = false;
let currentThreadIndex = -1;
let threads = [];
let currentMessageId = null;
let authCredentials = null;
async function api(endpoint) {
const res = await fetch(`/api/${endpoint}`);
const headers = {};
if (authCredentials) {
headers['Authorization'] = `Basic ${btoa(`${authCredentials.username}:${authCredentials.password}`)}`;
}
const res = await fetch(`/api/${endpoint}`, { headers });
if (res.status === 401) {
const success = await promptLogin();
if (!success) throw new Error('Authentication required');
headers['Authorization'] = `Basic ${btoa(`${authCredentials.username}:${authCredentials.password}`)}`;
const retryRes = await fetch(`/api/${endpoint}`, { headers });
if (!retryRes.ok) throw new Error(`API error: ${retryRes.status}`);
return retryRes.json();
}
if (!res.ok) throw new Error(`API error: ${res.status}`);
return res.json();
}
async function checkAuthStatus() {
try {
await api('auth/status');
document.getElementById('auth-status').textContent = 'Logged in';
document.getElementById('auth-status').style.color = '#0f0';
return true;
} catch (e) {
document.getElementById('auth-status').textContent = '';
return false;
}
}
let loginResolve = null;
function promptLogin() {
return new Promise((resolve) => {
loginResolve = resolve;
document.getElementById('login-overlay').classList.add('visible');
document.getElementById('login-username').value = '';
document.getElementById('login-password').value = '';
document.getElementById('login-username').focus();
});
}
async function submitLogin() {
const username = document.getElementById('login-username').value;
const password = document.getElementById('login-password').value;
if (!username || !password) return;
authCredentials = { username, password };
try {
await fetch('/api/auth/status', {
headers: { 'Authorization': `Basic ${btoa(`${username}:${password}`)}` }
}).then(r => {
if (!r.ok) throw new Error('Invalid credentials');
});
sessionStorage.setItem('authCredentials', JSON.stringify(authCredentials));
document.getElementById('auth-status').textContent = 'Logged in';
document.getElementById('auth-status').style.color = '#0f0';
document.getElementById('login-overlay').classList.remove('visible');
if (loginResolve) loginResolve(true);
} catch (e) {
authCredentials = null;
alert('Login failed');
if (loginResolve) loginResolve(false);
}
}
function setStatus(state) {
const status = document.getElementById('status');
status.className = state === 'loading' ? 'status-loading' : state === 'ok' ? 'status-ok' : 'status-error';
@ -253,12 +335,22 @@ function escapeHtml(text) {
return div.innerHTML;
}
window.addEventListener('DOMContentLoaded', () => {
window.addEventListener('DOMContentLoaded', async () => {
// Restore credentials from sessionStorage
const stored = sessionStorage.getItem('authCredentials');
if (stored) {
authCredentials = JSON.parse(stored);
document.getElementById('auth-status').textContent = 'Logged in';
document.getElementById('auth-status').style.color = '#0f0';
}
const params = new URLSearchParams(location.search);
const query = params.get('q');
document.getElementById('search').value = query;
if (query !== '') {
loadThreads(query);
if (query) {
document.getElementById('search').value = query;
if (authCredentials) {
loadThreads(query);
}
}
document.getElementById('search').addEventListener('keypress', (e) => {
@ -270,6 +362,13 @@ window.addEventListener('DOMContentLoaded', () => {
document.addEventListener('keydown', (e) => {
if (document.activeElement.id === 'search') return;
if (document.getElementById('login-overlay').classList.contains('visible')) {
if (e.key === 'Enter') {
e.preventDefault();
submitLogin();
}
return;
}
if (e.key === '/') {
e.preventDefault();