Compare commits
4 commits
777f0a1016
...
0b804a0d63
Author | SHA1 | Date | |
---|---|---|---|
0b804a0d63 | |||
7b6a959086 | |||
7e703c4556 | |||
9038231fee |
8 changed files with 281 additions and 36 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
@ -1,3 +1,4 @@
|
||||||
.zig-cache/
|
.zig-cache/
|
||||||
zig-out/
|
zig-out/
|
||||||
.direnv/
|
.direnv/
|
||||||
|
.zetviel_creds
|
||||||
|
|
33
README.md
33
README.md
|
@ -13,6 +13,7 @@ Features
|
||||||
- REST API for notmuch queries
|
- REST API for notmuch queries
|
||||||
- Thread and message viewing
|
- Thread and message viewing
|
||||||
- Attachment handling
|
- Attachment handling
|
||||||
|
- Basic authentication for API routes
|
||||||
- Security headers for safe browsing
|
- Security headers for safe browsing
|
||||||
- Configurable port
|
- Configurable port
|
||||||
|
|
||||||
|
@ -46,8 +47,30 @@ Configuration
|
||||||
-------------
|
-------------
|
||||||
|
|
||||||
- `NOTMUCH_PATH` environment variable: Path to notmuch database (default: `mail`)
|
- `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)
|
- `--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
|
API Endpoints
|
||||||
-------------
|
-------------
|
||||||
|
|
||||||
|
@ -55,9 +78,15 @@ API Endpoints
|
||||||
- `GET /api/thread/<thread_id>` - Get messages in a thread
|
- `GET /api/thread/<thread_id>` - Get messages in a thread
|
||||||
- `GET /api/message/<message_id>` - Get message details with content
|
- `GET /api/message/<message_id>` - Get message details with content
|
||||||
- `GET /api/attachment/<message_id>/<num>` - Get attachment metadata
|
- `GET /api/attachment/<message_id>/<num>` - Get attachment metadata
|
||||||
|
- `GET /api/auth/status` - Check authentication status
|
||||||
|
|
||||||
Security
|
Security
|
||||||
--------
|
--------
|
||||||
|
|
||||||
**WARNING**: Zetviel is intended for local use only. It binds to 127.0.0.1 and should
|
**WARNING**: Zetviel binds to 0.0.0.0 by default, making it accessible on all network interfaces.
|
||||||
not be exposed to the internet without additional security measures.
|
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
|
||||||
|
|
|
@ -28,8 +28,10 @@ pub fn openMessage(self: *Self, filename: [:0]const u8) !Message {
|
||||||
// TODO: remove the :0
|
// TODO: remove the :0
|
||||||
self.gmimeInit();
|
self.gmimeInit();
|
||||||
// Open the file as a GMime stream
|
// Open the file as a GMime stream
|
||||||
const stream = gmime.g_mime_stream_fs_open(filename.ptr, gmime.O_RDONLY, 0o0644, null) orelse
|
const stream = gmime.g_mime_stream_fs_open(filename.ptr, gmime.O_RDONLY, 0o0644, null) orelse {
|
||||||
|
std.log.err("Failed to open message file: {s}", .{filename});
|
||||||
return error.FileOpenFailed;
|
return error.FileOpenFailed;
|
||||||
|
};
|
||||||
defer gmime.g_object_unref(stream);
|
defer gmime.g_object_unref(stream);
|
||||||
|
|
||||||
// Create a parser for the stream
|
// Create a parser for the stream
|
||||||
|
|
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 std = @import("std");
|
||||||
const httpz = @import("httpz");
|
const httpz = @import("httpz");
|
||||||
const root = @import("root.zig");
|
const root = @import("root.zig");
|
||||||
|
const auth = @import("auth.zig");
|
||||||
|
|
||||||
const version = @import("build_options").git_revision;
|
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";
|
const db_path = std.posix.getenv("NOTMUCH_PATH") orelse "mail";
|
||||||
try stdout.print("Notmuch database: {s}\n", .{db_path});
|
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
|
// Open notmuch database
|
||||||
var db = try root.openNotmuchDb(
|
var db = try root.openNotmuchDb(
|
||||||
allocator,
|
allocator,
|
||||||
|
@ -85,18 +105,26 @@ pub fn main() !u8 {
|
||||||
}, &db);
|
}, &db);
|
||||||
defer server.deinit();
|
defer server.deinit();
|
||||||
|
|
||||||
// API routes
|
// Security headers middleware
|
||||||
var security_headers = SecurityHeaders{};
|
var security_headers = SecurityHeaders{};
|
||||||
const security_middleware = httpz.Middleware(*root.NotmuchDb).init(&security_headers);
|
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
|
// Auth middleware for API routes
|
||||||
router.get("/", indexHandler, .{});
|
var basic_auth = auth.BasicAuth{ .creds = creds };
|
||||||
router.get("/*", staticHandler, .{});
|
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();
|
try server.listen();
|
||||||
return 0;
|
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("Content-Type", "text/html");
|
||||||
|
res.header("Cache-Control", "no-cache, no-store, must-revalidate");
|
||||||
res.body = content;
|
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 }, .{});
|
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" {
|
test "queryHandler with valid query" {
|
||||||
const allocator = std.testing.allocator;
|
const allocator = std.testing.allocator;
|
||||||
var db = try root.openNotmuchDb(allocator, "mail", null);
|
var db = try root.openNotmuchDb(allocator, "mail", null);
|
||||||
|
|
|
@ -124,6 +124,7 @@ pub const Db = struct {
|
||||||
if (nm_query == null) return error.CouldNotCreateQuery;
|
if (nm_query == null) return error.CouldNotCreateQuery;
|
||||||
const handle = nm_query.?;
|
const handle = nm_query.?;
|
||||||
errdefer c.notmuch_query_destroy(handle);
|
errdefer c.notmuch_query_destroy(handle);
|
||||||
|
// SAFETY: out paramter in notmuch_query_search_threads
|
||||||
var threads: ?*c.notmuch_threads_t = undefined;
|
var threads: ?*c.notmuch_threads_t = undefined;
|
||||||
const status = c.notmuch_query_search_threads(handle, &threads);
|
const status = c.notmuch_query_search_threads(handle, &threads);
|
||||||
if (status != c.NOTMUCH_STATUS_SUCCESS) return error.CouldNotSearchThreads;
|
if (status != c.NOTMUCH_STATUS_SUCCESS) return error.CouldNotSearchThreads;
|
||||||
|
@ -155,10 +156,13 @@ pub const Db = struct {
|
||||||
pub const Message = struct {
|
pub const Message = struct {
|
||||||
message_handle: *c.notmuch_message_t,
|
message_handle: *c.notmuch_message_t,
|
||||||
|
|
||||||
pub fn getHeader(self: Message, header: [:0]const u8) !?[]const u8 {
|
pub fn getHeader(self: Message, header: [:0]const u8) ?[]const u8 {
|
||||||
const val = c.notmuch_message_get_header(self.message_handle, header) orelse return error.ErrorGettingHeader; // null is an error
|
const val = c.notmuch_message_get_header(self.message_handle, header) orelse {
|
||||||
|
std.log.err("notmuch returned null for header '{s}' on message {s} (file: {s})", .{ header, self.getMessageId(), self.getFilename() });
|
||||||
|
return null;
|
||||||
|
};
|
||||||
const ziggy_val = std.mem.span(val);
|
const ziggy_val = std.mem.span(val);
|
||||||
if (ziggy_val.len == 0) return null; // empty string indicates message does not contain the header
|
if (ziggy_val.len == 0) return null;
|
||||||
return ziggy_val;
|
return ziggy_val;
|
||||||
}
|
}
|
||||||
pub fn getMessageId(self: Message) []const u8 {
|
pub fn getMessageId(self: Message) []const u8 {
|
||||||
|
|
29
src/root.zig
29
src/root.zig
|
@ -35,21 +35,24 @@ pub const Thread = struct {
|
||||||
// }
|
// }
|
||||||
//]
|
//]
|
||||||
try jws.beginArray();
|
try jws.beginArray();
|
||||||
var mi = self.thread.getMessages() catch return error.WriteFailed;
|
var mi = self.thread.getMessages() catch |err| {
|
||||||
|
std.log.err("Failed to get messages for thread {s}: {s}", .{ self.thread.getThreadId(), @errorName(err) });
|
||||||
|
return error.WriteFailed;
|
||||||
|
};
|
||||||
while (mi.next()) |m| {
|
while (mi.next()) |m| {
|
||||||
try jws.beginObject();
|
try jws.beginObject();
|
||||||
try jws.objectField("from");
|
try jws.objectField("from");
|
||||||
try jws.write(m.getHeader("from") catch return error.WriteFailed);
|
try jws.write(m.getHeader("from"));
|
||||||
try jws.objectField("to");
|
try jws.objectField("to");
|
||||||
try jws.write(m.getHeader("to") catch return error.WriteFailed);
|
try jws.write(m.getHeader("to"));
|
||||||
try jws.objectField("cc");
|
try jws.objectField("cc");
|
||||||
try jws.write(m.getHeader("cc") catch return error.WriteFailed);
|
try jws.write(m.getHeader("cc"));
|
||||||
try jws.objectField("bcc");
|
try jws.objectField("bcc");
|
||||||
try jws.write(m.getHeader("bcc") catch return error.WriteFailed);
|
try jws.write(m.getHeader("bcc"));
|
||||||
try jws.objectField("date");
|
try jws.objectField("date");
|
||||||
try jws.write(m.getHeader("date") catch return error.WriteFailed);
|
try jws.write(m.getHeader("date"));
|
||||||
try jws.objectField("subject");
|
try jws.objectField("subject");
|
||||||
try jws.write(m.getHeader("subject") catch return error.WriteFailed);
|
try jws.write(m.getHeader("subject"));
|
||||||
try jws.objectField("message_id");
|
try jws.objectField("message_id");
|
||||||
try jws.write(m.getMessageId());
|
try jws.write(m.getMessageId());
|
||||||
try jws.endObject();
|
try jws.endObject();
|
||||||
|
@ -250,12 +253,12 @@ pub const NotmuchDb = struct {
|
||||||
const content_info = try email_msg.getTextAndHtmlBodyVersions(self.allocator);
|
const content_info = try email_msg.getTextAndHtmlBodyVersions(self.allocator);
|
||||||
const attachments = try email_msg.getAttachments(self.allocator);
|
const attachments = try email_msg.getAttachments(self.allocator);
|
||||||
|
|
||||||
const from = if (notmuch_msg.getHeader("from") catch null) |h| try self.allocator.dupe(u8, h) else null;
|
const from = if (notmuch_msg.getHeader("from")) |h| try self.allocator.dupe(u8, h) else null;
|
||||||
const to = if (notmuch_msg.getHeader("to") catch null) |h| try self.allocator.dupe(u8, h) else null;
|
const to = if (notmuch_msg.getHeader("to")) |h| try self.allocator.dupe(u8, h) else null;
|
||||||
const cc = if (notmuch_msg.getHeader("cc") catch null) |h| try self.allocator.dupe(u8, h) else null;
|
const cc = if (notmuch_msg.getHeader("cc")) |h| try self.allocator.dupe(u8, h) else null;
|
||||||
const bcc = if (notmuch_msg.getHeader("bcc") catch null) |h| try self.allocator.dupe(u8, h) else null;
|
const bcc = if (notmuch_msg.getHeader("bcc")) |h| try self.allocator.dupe(u8, h) else null;
|
||||||
const date = if (notmuch_msg.getHeader("date") catch null) |h| try self.allocator.dupe(u8, h) else null;
|
const date = if (notmuch_msg.getHeader("date")) |h| try self.allocator.dupe(u8, h) else null;
|
||||||
const subject = if (notmuch_msg.getHeader("subject") catch null) |h| try self.allocator.dupe(u8, h) else null;
|
const subject = if (notmuch_msg.getHeader("subject")) |h| try self.allocator.dupe(u8, h) else null;
|
||||||
const msg_id = try self.allocator.dupe(u8, notmuch_msg.getMessageId());
|
const msg_id = try self.allocator.dupe(u8, notmuch_msg.getMessageId());
|
||||||
|
|
||||||
return .{
|
return .{
|
||||||
|
|
|
@ -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 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 ul { list-style: none; margin-top: 0.5rem; }
|
||||||
.help-overlay li { margin: 0.5rem 0; }
|
.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>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
@ -51,11 +57,14 @@ button:disabled { background: #444; cursor: not-allowed; }
|
||||||
<h1>Zetviel</h1>
|
<h1>Zetviel</h1>
|
||||||
<span class="help-icon" onclick="toggleHelp()" title="Help (?)">?</span>
|
<span class="help-icon" onclick="toggleHelp()" title="Help (?)">?</span>
|
||||||
</div>
|
</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>
|
</header>
|
||||||
|
|
||||||
<div class="search-bar">
|
<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>
|
<button onclick="search()">Search</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@ -89,6 +98,15 @@ button:disabled { background: #444; cursor: not-allowed; }
|
||||||
<li><code>?</code> - Toggle this help</li>
|
<li><code>?</code> - Toggle this help</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
<script>
|
<script>
|
||||||
let currentQuery = 'tag:inbox';
|
let currentQuery = 'tag:inbox';
|
||||||
|
@ -96,13 +114,77 @@ let isLoading = false;
|
||||||
let currentThreadIndex = -1;
|
let currentThreadIndex = -1;
|
||||||
let threads = [];
|
let threads = [];
|
||||||
let currentMessageId = null;
|
let currentMessageId = null;
|
||||||
|
let authCredentials = null;
|
||||||
|
|
||||||
async function api(endpoint) {
|
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}`);
|
if (!res.ok) throw new Error(`API error: ${res.status}`);
|
||||||
return res.json();
|
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) {
|
function setStatus(state) {
|
||||||
const status = document.getElementById('status');
|
const status = document.getElementById('status');
|
||||||
status.className = state === 'loading' ? 'status-loading' : state === 'ok' ? 'status-ok' : 'status-error';
|
status.className = state === 'loading' ? 'status-loading' : state === 'ok' ? 'status-ok' : 'status-error';
|
||||||
|
@ -253,18 +335,40 @@ function escapeHtml(text) {
|
||||||
return div.innerHTML;
|
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 params = new URLSearchParams(location.search);
|
||||||
const query = params.get('q') || 'tag:inbox';
|
const query = params.get('q');
|
||||||
document.getElementById('search').value = query;
|
if (query) {
|
||||||
loadThreads(query);
|
document.getElementById('search').value = query;
|
||||||
|
if (authCredentials) {
|
||||||
|
loadThreads(query);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
document.getElementById('search').addEventListener('keypress', (e) => {
|
document.getElementById('search').addEventListener('keypress', (e) => {
|
||||||
if (e.key === 'Enter') search();
|
if (e.key === 'Enter') {
|
||||||
|
search();
|
||||||
|
e.target.blur();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
document.addEventListener('keydown', (e) => {
|
document.addEventListener('keydown', (e) => {
|
||||||
if (document.activeElement.id === 'search') return;
|
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 === '/') {
|
if (e.key === '/') {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
Loading…
Add table
Reference in a new issue