This commit is contained in:
parent
7b6a959086
commit
0b804a0d63
5 changed files with 249 additions and 18 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
@ -1,3 +1,4 @@
|
|||
.zig-cache/
|
||||
zig-out/
|
||||
.direnv/
|
||||
.zetviel_creds
|
||||
|
|
33
README.md
33
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/<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
69
src/auth.zig
Normal 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();
|
||||
}
|
||||
};
|
51
src/main.zig
51
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);
|
||||
|
|
|
@ -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();
|
||||
|
|
Loading…
Add table
Reference in a new issue