summaryrefslogtreecommitdiff
path: root/backend/api/src
diff options
context:
space:
mode:
authoraura <nw@moneybot.cc>2026-02-17 22:39:42 +0100
committeraura <nw@moneybot.cc>2026-02-17 22:39:42 +0100
commit636b0323075225c584b62719ed51e75521bb7ffb (patch)
tree61b02271b6d0695a4beffc23fb6eb062a7da22c3 /backend/api/src
push source
Diffstat (limited to 'backend/api/src')
-rw-r--r--backend/api/src/api.zig307
-rw-r--r--backend/api/src/chat.zig586
-rw-r--r--backend/api/src/config.zig35
-rw-r--r--backend/api/src/db.zig149
-rw-r--r--backend/api/src/mail.zig60
-rw-r--r--backend/api/src/main.zig28
-rw-r--r--backend/api/src/model.zig123
-rw-r--r--backend/api/src/net-util.zig140
-rw-r--r--backend/api/src/req.zig311
-rw-r--r--backend/api/src/server.zig101
-rw-r--r--backend/api/src/stripe.zig181
-rw-r--r--backend/api/src/user.zig533
-rw-r--r--backend/api/src/userdata.zig568
-rw-r--r--backend/api/src/userdefs.zig322
-rw-r--r--backend/api/src/util.zig271
15 files changed, 3715 insertions, 0 deletions
diff --git a/backend/api/src/api.zig b/backend/api/src/api.zig
new file mode 100644
index 0000000..b5d69c0
--- /dev/null
+++ b/backend/api/src/api.zig
@@ -0,0 +1,307 @@
+const z = @import( "std" );
+const ws = @import( "websocket" );
+const req = @import( "req.zig" );
+const net = @import( "net-util.zig" );
+const config = @import( "config.zig" );
+const builtin = @import( "builtin" );
+
+const alloc = z.heap.page_allocator;
+var lock = z.Thread.Mutex{};
+var is_listening: bool = true;
+var server: ws.Server = undefined;
+
+var stopped: bool = false;
+
+const u = struct {
+ usingnamespace @import( "util.zig" );
+ usingnamespace @import( "userdefs.zig" );
+ usingnamespace @import( "user.zig" );
+ usingnamespace @import( "userdata.zig" );
+};
+
+const ArrayList = z.ArrayList;
+const ErrRes = net.ErrorResponse;
+const OkRes = net.OkResponse;
+
+const Message = ws.Message;
+const Handshake = ws.Handshake;
+
+const memeql = z.mem.eql;
+const ServerStatus = struct {
+ lastUpdate: u64,
+ loadedModel: ?[]const u8 = null,
+ isBusy: bool,
+ domain: []const u8,
+ msg: ?[]const u8 = null
+};
+
+const ServerContext = struct {
+ status: ServerStatus,
+ conn: *ws.Conn,
+ uuid: [36]u8,
+};
+
+const StateManager = struct {
+ const Self = StateManager;
+ contexts: ArrayList(*ServerContext),
+
+ pub fn init() Self {
+ var ret: Self = undefined;
+ ret.contexts = ArrayList(*ServerContext).init( alloc );
+ return ret;
+ }
+
+ pub fn deinit( self: *Self ) void {
+ for( self.contexts.items ) |ctx| {
+ alloc.destroy( ctx );
+ }
+ self.contexts.deinit();
+ }
+
+ pub fn createContext( self: *Self ) !*ServerContext {
+ lock.lock();
+ defer lock.unlock();
+
+ const ctx = try alloc.create( ServerContext );
+ ctx.status.lastUpdate = 0;
+ ctx.status.loadedModel = "";
+ ctx.status.isBusy = false;
+ ctx.status.domain = "";
+ ctx.uuid = net.uuidv4();
+
+ try self.contexts.append( ctx );
+ return ctx;
+ }
+
+ pub fn deleteContext( self: *Self, ctx: *ServerContext ) void {
+ lock.lock();
+ defer lock.unlock();
+ for( self.contexts.items, 0..self.contexts.items.len ) |it, i| {
+ if( it == ctx ) {
+ alloc.destroy( it );
+ _=self.contexts.orderedRemove( i );
+ return;
+ }
+ }
+ }
+
+ pub fn deleteByUuid( self: *Self, uuid: []const u8 ) void {
+ lock.lock();
+ defer lock.unlock();
+ for( self.contexts.items, 0..self.contexts.items.len ) |it, i| {
+ if( it.uuid == uuid ) {
+ alloc.destroy( it );
+ _=self.contexts.orderedRemove( i );
+ return;
+ }
+ }
+ }
+
+ pub fn checkServers( self: *Self ) void {
+ lock.lock();
+ defer lock.unlock();
+ for( self.contexts.items ) |it| {
+ if( z.time.milliTimestamp() - 20000 > it.status.lastUpdate ) {
+ z.debug.print( "server {s} not responding for 20sec: disconnecting\n", .{ it.status.domain } );
+ it.conn.writeClose() catch {};
+ it.conn.close();
+ lock.unlock();
+ // locked again
+ self.deleteContext( it );
+ lock.lock();
+ }
+ }
+ }
+};
+
+const WebsocketHandler = struct {
+ mgr: *StateManager,
+ ctx: *ServerContext,
+
+ msg: ?u.JsonResponse(ServerStatus) = null,
+
+ pub fn init( h: ws.Handshake, conn: *ws.Conn, mgr: *StateManager ) !WebsocketHandler {
+ _ = h;
+
+ const ctx = try mgr.createContext();
+ ctx.conn = conn;
+ return WebsocketHandler{
+ .mgr = mgr,
+ .ctx = ctx,
+ };
+ }
+
+ pub fn handle( self: *WebsocketHandler, data: Message ) !void {
+ lock.lock();
+ defer lock.unlock();
+ if( !is_listening )
+ return error.ConnectionClosed;
+
+ const status = u.jsonParseAlloc( ServerStatus, data.data, alloc ) catch {
+ z.debug.print( "received invalid json from server {s}:\n{s}\n", .{ self.ctx.status.domain, data.data } );
+ return;
+ };
+ if( self.msg ) |msg|
+ msg.deinit();
+ self.msg = status;
+
+ self.ctx.status = status.v;
+ self.ctx.status.msg = null;
+
+ const reply = try u.jsonStringifyAlloc( .{ .msg = "ping" }, alloc );
+ defer alloc.free( reply );
+ try self.ctx.conn.write( reply );
+ }
+
+ pub fn close( self: *WebsocketHandler ) void {
+ self.mgr.deleteContext( self.ctx );
+ }
+};
+
+var state: StateManager = undefined;
+
+fn checkLoop() void {
+ while( is_listening ) {
+ state.checkServers();
+ u.sendReminderEmails() catch |e| {
+ z.debug.print( "Error sending reminder emails {any} {any}\n", .{e, @errorReturnTrace()} );
+ };
+ lock.lock();
+ z.debug.print( "\x1b[1;32m[api] server count : {d}\n\x1b[0m", .{ state.contexts.items.len } );
+ for( state.contexts.items ) |ctx| {
+ const status = ctx.status;
+ z.debug.print( " [{s}] model: \x1b[1;32m{s}\x1b[0m busy: \x1b[1;32m{any}\n\x1b[0m", .{
+ status.domain,
+ if( status.loadedModel != null and status.loadedModel.?.len > 0 ) status.loadedModel.? else "none",
+ status.isBusy
+ } );
+ }
+ lock.unlock();
+
+ // 5 sec.
+ z.time.sleep( 5000000000 );
+ }
+
+ z.time.sleep( 5000000000 );
+ server.deinit( alloc );
+ lock.lock();
+ state.deinit();
+ stopped = true;
+ lock.unlock();
+ z.debug.print( "server stopped\n", .{} );
+}
+
+fn dispatchListen() !void {
+ state = StateManager.init();
+ _= z.Thread.spawn( .{}, checkLoop, .{} ) catch return;
+
+ const cfg = ws.Config.Server{
+ .port = @intCast( config.api_port ),
+ .address = "127.0.0.1",
+ .max_headers = 10,
+ };
+
+ server = try ws.Server.init( alloc, cfg );
+ defer server.deinit( alloc );
+
+ var no_delay = true;
+ const address = blk: {
+ if( comptime builtin.os.tag != .windows ) {
+ if( cfg.unix_path ) |unix_path| {
+ no_delay = false;
+ z.fs.deleteFileAbsolute( unix_path ) catch {};
+ break :blk try z.net.Address.initUnix( unix_path );
+ }
+ }
+ break :blk try z.net.Address.parseIp( cfg.address, cfg.port );
+ };
+ var listener = try address.listen( .{
+ .reuse_address = true,
+ .kernel_backlog = 1024,
+ });
+ defer listener.deinit();
+
+ if( no_delay ) {
+ try z.posix.setsockopt( listener.stream.handle, z.posix.IPPROTO.TCP, 1, &z.mem.toBytes( @as(c_int, 1) ) );
+ }
+
+ while( true ) {
+ lock.lock();
+ if( !is_listening ) {
+ lock.unlock();
+ break;
+ }
+ lock.unlock();
+
+ if( listener.accept() ) |conn| {
+ const args = .{ &server, WebsocketHandler, &state, conn.stream };
+ const thread = try z.Thread.spawn( .{}, ws.Server.accept, args );
+ thread.detach();
+ } else |err| {
+ z.log.err( "failed to accept connection {}", .{err} );
+ }
+ }
+}
+
+pub fn listen() !void {
+ is_listening = true;
+ _=try z.Thread.spawn( .{}, dispatchListen, .{} );
+}
+
+pub fn stop() void {
+ lock.lock();
+ z.debug.print( "server stopping\n", .{} );
+ is_listening = false;
+ for( state.contexts.items ) |ctx| {
+ ctx.conn.close();
+ }
+ lock.unlock();
+
+ while( true ) {
+ lock.lock();
+ if( stopped )
+ break;
+ lock.unlock();
+
+ z.Thread.yield() catch {};
+ }
+}
+
+///returned string owned by caller
+pub fn getAvailableServer( model: ?[]const u8, a: z.mem.Allocator ) ?[]const u8 {
+ lock.lock();
+ defer lock.unlock();
+
+ var first_free: ?[]const u8 = null;
+ var first_matched: ?[]const u8 = null;
+ for( state.contexts.items ) |ctx| {
+ if( ctx.status.isBusy )
+ continue;
+
+ if( first_free == null )
+ first_free = ctx.status.domain;
+ if( model != null and first_matched == null and ctx.status.loadedModel != null ) {
+ if( memeql( u8, ctx.status.loadedModel.?, model.? ) )
+ first_matched = ctx.status.domain;
+ }
+
+ if( first_matched != null and first_free != null )
+ break;
+ }
+
+ if( first_matched == null and first_free == null )
+ return null;
+
+ return a.dupe( u8, first_matched orelse first_free.? ) catch null;
+}
+
+pub fn markServerAsBusy( name: []const u8 ) !void {
+ for( state.contexts.items ) |*ctx| {
+ if( memeql( u8, ctx.*.status.domain, name ) ) {
+ ctx.*.status.isBusy = true;
+ return;
+ }
+ }
+
+ return error.ServerNotFound;
+}
diff --git a/backend/api/src/chat.zig b/backend/api/src/chat.zig
new file mode 100644
index 0000000..1200562
--- /dev/null
+++ b/backend/api/src/chat.zig
@@ -0,0 +1,586 @@
+const z = @import( "std" );
+const zap = @import( "zap" );
+const api = @import( "api.zig" );
+const req = @import( "req.zig" );
+const net = @import( "net-util.zig" );
+const model = @import( "model.zig" );
+
+const u = struct {
+ usingnamespace @import( "util.zig" );
+ usingnamespace @import( "userdefs.zig" );
+ usingnamespace @import( "user.zig" );
+ usingnamespace @import( "userdata.zig" );
+};
+
+// --------------------------------------------------------------------------------------------------------
+// some docs because this is becoming a little complex:
+// the api is mostly a proxy between the user and model instances
+// user sends ChatReq to api, api forwards a ModelInstanceChatReq to instance after verifying user acc etc
+// instance replies with a ChatStream defined in typescript [api-defs.ts](file://../instance/api-defs.ts#L58)
+// api simply forwards this ChatStream to the client, only parsing it to check for a title.
+// if a title is present, it's updated in the user db entry.
+//
+// the situation looks similar for the generate endpoint except there are no title checks.
+// the msg is simply proxied to the user.
+//
+// legend:
+// (source) - message source
+// [type] - message type
+// {target} - message target
+// @/ - route
+//
+// chat route:
+// (user) [ChatReq] req @/chat --> (api) [ModelInstanceChatReq] req @/chat --> {instance}
+// (instance) [ChatStream] res --> (api) proxy --> {user}
+//
+// generate route:
+// (user) [GenerateReq] req @/generate --> (api) [ModelInstanceGenerateReq] req @/generate --> {instance}
+// (instance) [undefined in zig] res --> (api) proxy --> {user}
+// --------------------------------------------------------------------------------------------------------
+
+const ArrayList = z.ArrayList;
+const ErrRes = net.ErrorResponse;
+const OkRes = net.OkResponse;
+const Request = zap.Request;
+
+const memeql = z.mem.eql;
+
+const alloc = u.alloc;
+
+pub const title_gen_model = "qwen2.5-1.5b";
+pub const routes = .{
+ .@"chat" = chat,
+ .@"get-chat" = getChat,
+ .@"generate" = generate,
+ .@"create-chat" = createChat,
+ .@"delete-chat" = deleteChat,
+ .@"get-chat-name" = getChatName
+};
+
+const ToolCall = struct {
+ name: []const u8,
+ parameters: struct {
+ pub const @"getty.db" = u.@"json.ignore.unknown";
+ }// processed either by instance or client, we dont care abt the contents
+};
+
+const MsgFile = struct {
+ name: []const u8,
+ type: []const u8,
+ content: []const u8
+};
+
+const Msg = struct {
+ content: []const u8,
+ role: []const u8,
+ timestamp: []const u8,
+ toolCall: ?ToolCall = null,
+ images: ?[][]const u8 = null,
+ files: ?[]MsgFile = null,
+
+ pub const @"getty.db" = u.@"json.ignore.unknown";
+};
+
+const ClientOptions = struct {
+ seed: ?u32,
+ temperature: ?f32,
+
+ pub const @"getty.db" = u.@"json.ignore.unknown";
+};
+
+const ChatReq = struct {
+ model: []const u8,
+ messages: []Msg,
+ system: ?[]const u8,
+ chatfile: ?[]const u8 = null,
+ options: ?ClientOptions = null,
+ generateTitle: ?bool = null,
+
+ pub const @"getty.db" = u.@"json.ignore.unknown";
+};
+
+const ChatStream = struct {
+ status: []const u8,
+ done: bool,
+ title: ?[]const u8,
+
+ pub const @"getty.db" = u.@"json.ignore.unknown";
+};
+
+const GenerateReq = struct {
+ model: []const u8,
+ prompt: []const u8,
+ suffix: ?[]const u8 = "",
+ options: ?ClientOptions = null,
+
+ pub const @"getty.db" = u.@"json.ignore.unknown";
+};
+
+const GetChatReq = struct {
+ chatId: []const u8,
+
+ pub const @"getty.db" = u.@"json.ignore.unknown";
+};
+
+const ChatMsgContext = struct {
+ uuid: []const u8,
+ chatfile: []const u8
+};
+
+
+// theres no reason for these to be split up other than the frontend being suck
+// todo later: fix
+const ModelInstanceChatOptions = struct {
+ system: ?struct {
+ model: ?[]const u8 = null,
+ user: ?[]const u8 = null,
+ },
+ model: model.Model,
+ uuid: []const u8,
+ chatfile: ?[]const u8 = null,
+ generateTitle: ?bool = null,
+};
+
+const ModelInstanceChatReq = struct {
+ messages: []Msg,
+ options: ModelInstanceChatOptions,
+};
+
+const ModelInstanceGenerateOptions = struct {
+ model: model.Model,
+ system: ?struct {
+ model: ?[]const u8 = null,
+ user: ?[]const u8 = null,
+ } = null,
+};
+
+const ModelInstanceGenerateReq = struct {
+ options: ModelInstanceGenerateOptions,
+ prompt: []const u8,
+ suffix: ?[]const u8,
+};
+
+///forwards the instance error to client if reported
+///sends a 500 otherwise
+fn handleInstanceError( r: Request, q: *const req.Result ) void {
+ if( q.body ) |body| {
+ const msg = u.jsonParse( struct { status: []const u8, msg: []const u8 }, body ) catch null;
+ if( msg != null ) {
+ z.debug.print( "res err status [{any}]: {s}\n", .{ q.status, body } );
+ r.sendChunk( body ) catch {};
+ }
+ } else {
+ net.sendJsonChunk( r, .internal_server_error, ErrRes{ .msg = "could not contact server" } );
+ }
+}
+
+
+// -------------------------------------------------------------------------------------------
+// chat --------------------------------------------------------------------------------------
+
+fn chatChunkFn( chunk: []const u8, r: Request ) void {
+ const ctx = r.getUserContext( ChatMsgContext );
+ var parts = z.mem.split( u8, chunk, "\n" );
+ var part: ?[]const u8 = parts.first();
+ while( part ) |p| : (part = parts.next()) {
+ if( p.len == 0 )
+ continue;
+
+ var buf = alloc.alloc( u8, p.len + 1 ) catch return;
+ defer alloc.free( buf );
+ @memcpy( buf[0..p.len], p );
+ buf[p.len] = '\n';
+
+ if( ctx ) |_ctx| {
+ if( u.jsonParse( ChatStream, buf ) catch null ) |parsed| {
+ if( parsed.v.done and parsed.v.title != null ) {
+ setChatTitle( _ctx.uuid, _ctx.chatfile, parsed.v.title.? );
+ }
+
+ parsed.deinit();
+ }
+ }
+
+ r.sendChunk( buf ) catch |e| {
+ z.debug.print( "error sending chunk: {any}\n", .{e} );
+ };
+ }
+}
+
+fn startChat( r: Request, address: []const u8, params: ChatReq, uuid: []const u8, user: *u.UserEntry ) void {
+ const _model = model.getModelByDisplayName( params.model ) catch {
+ return net.sendJsonChunk( r, .bad_request, ErrRes{ .msg = "invalid model" } );
+ };
+
+ if( !model.canBeUsedByUser( _model.*, user ) ) {
+ return net.sendJson( r, .unauthorized, ErrRes{ .msg = "user is not authorized to use this model" } );
+ }
+
+ const chat_req = ModelInstanceChatReq{
+ .messages = params.messages,
+ .options = ModelInstanceChatOptions{
+ .model = _model.*,
+ .system = .{
+ .model = _model.system,
+ .user = params.system,
+ },
+ .uuid = uuid,
+ .chatfile = params.chatfile,
+ .generateTitle = params.generateTitle
+ },
+ };
+
+ if( params.chatfile ) |chatfile| {
+ var ctx = ChatMsgContext{
+ .uuid = uuid,
+ .chatfile = chatfile
+ };
+ r.setUserContext( &ctx );
+ }
+
+ const params_str = u.jsonStringify( chat_req ) catch {
+ return net.sendJson( r, .bad_request, ErrRes{ .msg = "invalid request format" } );
+ }; defer alloc.free( params_str );
+
+ r.setChunked() catch {
+ return net.sendJson( r, .internal_server_error, ErrRes{ .msg = "internal server error" } );
+ };
+ const q = req.sendWithData( Request, .{
+ .method = .POST,
+ .url = address,
+ .headers = &[_]z.http.Header{ .{ .name = "Content-Type", .value = "application/json" } },
+ .body = params_str,
+ .chunk_fn = chatChunkFn,
+ .chunk_data = r
+ }, alloc );
+ defer q.deinit();
+
+ if( !q.ok )
+ handleInstanceError( r, &q );
+ r.endStream() catch |e| {
+ return z.debug.print( "error closing connection {any} {any}", .{e, @errorReturnTrace() } );
+ };
+}
+
+///route @/chat
+fn chat( r: Request ) void {
+ if( net.handleInvalidPostReq( r ) ) return;
+ u.checkDbOnThread() catch return;
+
+ const uuid = u.uuidFromApiOrAuthToken( r ) catch return;
+ defer alloc.free( uuid );
+
+ const params = u.jsonParse( ChatReq, r.body.? ) catch |e| {
+ z.debug.print( "err: {any}\n", .{e} );
+ return net.sendJson( r, .bad_request, ErrRes{ .msg = "invalid request format" } );
+ }; defer params.deinit();
+
+ var user = u.getEntry( uuid ) catch {
+ return net.sendJson( r, .unauthorized, ErrRes{ .msg = "user not found" } );
+ }; defer user.db_entry.?.deinit();
+
+ u.checkSubscription( &user ) catch {
+ return net.sendJson( r, .internal_server_error, ErrRes{ .msg = "internal server error" } );
+ };
+
+ var prefs = u.getSettings( uuid ) catch {
+ return net.sendJson( r, .unauthorized, ErrRes{ .msg = "error getting user data" } );
+ }; defer prefs.db_entry.?.deinit();
+
+ if( params.v.chatfile != null and !u.hasChat( &prefs, params.v.chatfile.? ) )
+ return net.sendJson( r, .bad_request, ErrRes{ .msg = "specified chat file does not exist" } );
+
+ const serv = api.getAvailableServer( params.v.model, alloc ) orelse {
+ return net.sendJson( r, .service_unavailable, ErrRes{ .msg = "no server available" } );
+ }; defer alloc.free( serv );
+
+ api.markServerAsBusy( serv ) catch {
+ return net.sendJson( r, .internal_server_error, ErrRes{ .msg = "internal server error" } );
+ };
+
+ const address = z.fmt.allocPrintZ( alloc, "{s}/chat", .{serv} ) catch return;
+ defer alloc.free( address );
+ startChat( r, address, params.v, uuid, &user );
+}
+
+fn setChatTitle( uuid: []const u8, chatfile: []const u8, title: []const u8 ) void {
+ var prefs = u.getSettings( uuid ) catch {
+ return z.debug.print( "setChatTitle called for invalid uuid: {s}\n", .{uuid} );
+ }; defer prefs.db_entry.?.deinit();
+
+ if( prefs.chat_files == null and prefs.chat_files.?.files == null )
+ return;
+
+ const files = prefs.chat_files.?.files.?;
+ for( files ) |*f| {
+ if( memeql( u8, f.id, chatfile ) ) {
+ f.name = title;
+ break;
+ }
+ }
+ prefs.chat_files = u.ChatFiles{ .files = files };
+ u.updateSettings( &prefs ) catch {};
+}
+
+// -------------------------------------------------------------------------------------------
+// generate ----------------------------------------------------------------------------------
+
+fn generateChunkFn( chunk: []const u8, r: Request ) void {
+ var parts = z.mem.split( u8, chunk, "\n" );
+ var part: ?[]const u8 = parts.first();
+ while( part ) |p| : (part = parts.next()) {
+ if( p.len == 0 )
+ continue;
+
+ var buf = alloc.alloc( u8, p.len + 1 ) catch return;
+ defer alloc.free( buf );
+ @memcpy( buf[0..p.len], p );
+ buf[p.len] = '\n';
+
+ r.sendChunk( buf ) catch |e| {
+ z.debug.print( "error sending chunk: {any}\n", .{e} );
+ };
+ }
+}
+
+fn startGenerate( r: Request, address: []const u8, params: GenerateReq, user: *u.UserEntry ) void {
+ const _model = model.getModelByDisplayName( params.model ) catch {
+ const json = u.jsonStringify( ErrRes{ .msg = "invalid model" } ) catch "";
+ defer alloc.free( json );
+ return r.sendChunk( json ) catch {};
+ };
+
+ if( !model.canBeUsedByUser( _model.*, user ) ) {
+ return net.sendJson( r, .unauthorized, ErrRes{ .msg = "user is not authorized to use this model" } );
+ }
+
+ const gen_req = ModelInstanceGenerateReq{
+ .options = ModelInstanceGenerateOptions{
+ .model = _model.*,
+ },
+ .prompt = params.prompt,
+ .suffix = params.suffix,
+ };
+
+ const params_str = u.jsonStringify( gen_req ) catch {
+ return net.sendJsonChunk( r, .bad_request, ErrRes{ .msg = "invalid request format" } );
+ }; defer alloc.free( params_str );
+
+ r.setChunked() catch {
+ return net.sendJson( r, .internal_server_error, ErrRes{ .msg = "internal server error" } );
+ };
+ const q = req.sendWithData( Request, .{
+ .method = .POST,
+ .url = address,
+ .headers = &[_]z.http.Header{ .{ .name = "Content-Type", .value = "application/json" } },
+ .body = params_str,
+ .chunk_fn = generateChunkFn,
+ .chunk_data = r
+ }, alloc );
+ defer q.deinit();
+
+ if( !q.ok )
+ handleInstanceError( r, &q );
+ r.endStream() catch |e| {
+ return z.debug.print( "error closing connection {any} {any}", .{e, @errorReturnTrace() } );
+ };
+}
+
+///route @/generate
+fn generate( r: Request ) void {
+ if( net.handleInvalidPostReq( r ) ) return;
+ u.checkDbOnThread() catch return;
+
+ const uuid = u.uuidFromApiOrAuthToken( r ) catch return;
+ defer alloc.free( uuid );
+
+ const params = u.jsonParse( GenerateReq, r.body.? ) catch |e| {
+ z.debug.print( "error parsing request {any}", .{e} );
+ return net.sendJson( r, .bad_request, ErrRes{ .msg = "invalid request format" } );
+ }; defer params.deinit();
+
+ var user = u.getEntry( uuid ) catch {
+ return net.sendJson( r, .not_found, ErrRes{ .msg = "user not found" } );
+ }; defer user.db_entry.?.deinit();
+
+ u.checkSubscription( &user ) catch {
+ return net.sendJson( r, .internal_server_error, ErrRes{ .msg = "internal server error" } );
+ };
+
+ const serv = api.getAvailableServer( params.v.model, alloc ) orelse {
+ return net.sendJson( r, .service_unavailable, ErrRes{ .msg = "no server available" } );
+ }; defer alloc.free( serv );
+
+ api.markServerAsBusy( serv ) catch {
+ return net.sendJson( r, .internal_server_error, ErrRes{ .msg = "internal server error" } );
+ };
+
+ const address = z.fmt.allocPrintZ( alloc, "{s}/generate", .{serv} ) catch return;
+ defer alloc.free( address );
+ startGenerate( r, address, params.v, &user );
+}
+
+
+
+// -------------------------------------------------------------------------------------------
+// chat management (create-chat, delete-chat, get-chat, get-chat-name) -----------------------
+
+///route @/create-chat
+fn createChat( r: Request ) void {
+ if( net.handleInvalidPostReq( r ) ) return;
+ u.checkDbOnThread() catch return;
+
+ const uuid = u.uuidFromApiOrAuthToken( r ) catch return;
+ defer alloc.free( uuid );
+
+ var userdata = u.getSettings( uuid ) catch {
+ return net.sendJson( r, .not_found, ErrRes{ .msg = "user not found" } );
+ }; defer userdata.db_entry.?.deinit();
+
+ var chats = userdata.chat_files;
+ if( chats == null )
+ chats = u.ChatFiles{ .files = &[_]u.ChatEntry{} };
+
+ const files = chats.?.files;
+ var list = ArrayList( u.ChatEntry ).init( alloc );
+ defer list.deinit();
+
+ if( files != null and files.?.len > 0 )
+ list.appendSlice( files.? ) catch {};
+
+ const chat_uuid = net.uuidv4();
+ var buf = [_]u8{ 0 } ** 64;
+ const dup = z.fmt.bufPrintZ( &buf, "{s}", .{chat_uuid} ) catch &chat_uuid;
+ const new_entry = u.ChatEntry{
+ .id = dup,
+ .name = "new chat"
+ };
+
+ list.append( new_entry ) catch {};
+ chats.?.files = list.items;
+ userdata.chat_files = chats;
+ u.updateSettings( &userdata ) catch {
+ return net.sendJson( r, .internal_server_error, ErrRes{ .msg = "internal server error" } );
+ };
+
+ return net.sendJson( r, .ok, OkRes( .{ .chatId = dup } ) );
+}
+
+///route @/get-chat
+fn getChat( r: Request ) void {
+ if( net.handleInvalidPostReq( r ) ) return;
+ u.checkDbOnThread() catch return;
+
+ const uuid = u.uuidFromApiOrAuthToken( r ) catch return;
+ defer alloc.free( uuid );
+
+ const params = u.jsonParse( GetChatReq, r.body.? ) catch {
+ return net.sendJson( r, .bad_request, ErrRes{ .msg = "invalid request format" } );
+ }; defer params.deinit();
+
+ const userdata = u.getSettings( uuid ) catch {
+ return net.sendJson( r, .internal_server_error, ErrRes{ .msg = "user not found" } );
+ }; defer userdata.db_entry.?.deinit();
+
+ const chats = userdata.chat_files;
+ if( chats == null or chats.?.files == null )
+ return net.sendJson( r, .not_found, ErrRes{ .msg = "chat not found" } );
+
+ for( chats.?.files.? ) |file| {
+ const id = file.id;
+ if( memeql( u8, id, params.v.chatId ) ) {
+ var pathbuf: [256]u8 = undefined;
+ const slice = z.fmt.bufPrint( &pathbuf, "../data/chats/{s}.json", .{id} ) catch "";
+ const contents = u.readFileCrypto( slice ) catch {
+ return net.sendJson( r, .internal_server_error, ErrRes{ .msg = "error reading file" } );
+ }; defer alloc.free( contents );
+
+ return net.sendJson( r, .ok, OkRes( .{ .name = file.name, .contents = contents } ) );
+ }
+ }
+
+ return net.sendJson( r, .not_found, ErrRes{ .msg = "chat not found" } );
+}
+
+///route @/delete-chat
+fn deleteChat( r: Request ) void {
+ if( net.handleInvalidPostReq( r ) ) return;
+ u.checkDbOnThread() catch return;
+
+ const uuid = u.uuidFromApiOrAuthToken( r ) catch return;
+ defer alloc.free( uuid );
+
+ const params = u.jsonParse( GetChatReq, r.body.? ) catch {
+ return net.sendJson( r, .bad_request, ErrRes{ .msg = "invalid request format" } );
+ }; defer params.deinit();
+
+ var userdata = u.getSettings( uuid ) catch {
+ return net.sendJson( r, .internal_server_error, ErrRes{ .msg = "user not found" } );
+ }; defer userdata.db_entry.?.deinit();
+
+ const chats = userdata.chat_files;
+ if( chats == null or chats.?.files == null )
+ return net.sendJson( r, .not_found, ErrRes{ .msg = "chat not found" } );
+
+ var updated_list = ArrayList( u.ChatEntry ).init( alloc );
+ defer updated_list.deinit();
+
+ var found = false;
+ for( chats.?.files.? ) |file| {
+ const id = file.id;
+ if( memeql( u8, id, params.v.chatId ) ) {
+ var pathbuf: [256]u8 = undefined;
+ const slice = z.fmt.bufPrint( &pathbuf, "../data/chats/{s}.json", .{id} ) catch "";
+ if( !u.fileExists( slice ) ) {
+ found = true; continue;
+ }
+
+ u.deleteFile( slice ) catch continue;
+ found = true;
+ }
+ else {
+ updated_list.append( file ) catch {};
+ }
+ }
+
+ if( found ) {
+ userdata.chat_files.?.files = updated_list.items;
+ u.updateSettings( &userdata ) catch {
+ return net.sendJson( r, .internal_server_error, ErrRes{ .msg = "error updating user data" } );
+ };
+
+ return net.sendJson( r, .ok, OkRes( .{} ) );
+ }
+
+ return net.sendJson( r, .not_found, ErrRes{ .msg = "chat not found" } );
+}
+
+///route @/get-chat-name
+fn getChatName( r: Request ) void {
+ if( net.handleInvalidPostReq( r ) ) return;
+ u.checkDbOnThread() catch return;
+
+ const uuid = u.uuidFromApiOrAuthToken( r ) catch return;
+ defer alloc.free( uuid );
+
+ const params = u.jsonParse( GetChatReq, r.body.? ) catch {
+ return net.sendJson( r, .bad_request, ErrRes{ .msg = "invalid request format" } );
+ }; defer params.deinit();
+
+ const userdata = u.getSettings( uuid ) catch {
+ return net.sendJson( r, .internal_server_error, ErrRes{ .msg = "user not found" } );
+ }; defer userdata.db_entry.?.deinit();
+
+ const chats = userdata.chat_files;
+ if( chats == null or chats.?.files == null )
+ return net.sendJson( r, .not_found, ErrRes{ .msg = "chat not found" } );
+
+ for( chats.?.files.? ) |file| {
+ const id = file.id;
+ if( memeql( u8, id, params.v.chatId ) ) {
+ return net.sendJson( r, .ok, OkRes( .{ .name = file.name } ) );
+ }
+ }
+
+ return net.sendJson( r, .not_found, ErrRes{ .msg = "file not found" } );
+}
diff --git a/backend/api/src/config.zig b/backend/api/src/config.zig
new file mode 100644
index 0000000..fdc6cb7
--- /dev/null
+++ b/backend/api/src/config.zig
@@ -0,0 +1,35 @@
+const z = @import( "std" );
+
+pub var server_url: []const u8 = "http://localhost:8081";
+pub var server_port: u32 = 3000;
+pub var api_port: u32 = 3003;
+const memeql = z.mem.eql;
+
+pub fn parse() !void {
+ var args = z.process.args();
+ var argit = args.next();
+ while( argit ) |arg| : (argit = args.next()) {
+ if( memeql( u8, arg, "--domain" ) ) {
+ const next = args.next();
+ if( next ) |domain| {
+ server_url = domain;
+ }
+ }
+
+ if( memeql( u8, arg, "--port" ) ) {
+ const next = args.next();
+ if( next ) |port| {
+ const iport = z.fmt.parseInt( u32, port, 10 ) catch 3000;
+ server_port = iport;
+ }
+ }
+
+ if( memeql( u8, arg, "--api-port" ) ) {
+ const next = args.next();
+ if( next ) |port| {
+ const iport = z.fmt.parseInt( u32, port, 10 ) catch 3000;
+ api_port = iport;
+ }
+ }
+ }
+}
diff --git a/backend/api/src/db.zig b/backend/api/src/db.zig
new file mode 100644
index 0000000..78258f0
--- /dev/null
+++ b/backend/api/src/db.zig
@@ -0,0 +1,149 @@
+const z = @import( "std" );
+const u = @import( "util.zig" );
+const sql = @import( "sqlite" );
+
+const alloc = u.alloc;
+
+const ArenaAllocator = z.heap.ArenaAllocator;
+const Mutex = z.Thread.Mutex;
+const Db = sql.Db;
+pub threadlocal var dbi: Interface = undefined;
+pub threadlocal var dbi_init: bool = false;
+
+var db_mutex = Mutex{};
+
+pub fn create( dbfile: [:0]const u8 ) !void {
+ dbiCheck() catch try init( dbfile );
+
+ const create_users_q =
+ \\CREATE TABLE IF NOT EXISTS users (
+ \\ uuid TEXT PRIMARY KEY,
+ \\ email TEXT,
+ \\ tokens TEXT,
+ \\ login_token TEXT,
+ \\ token_resetdate INTEGER,
+ \\ api_tokens TEXT,
+ \\ created_at INTEGER,
+ \\ subscription_data TEXT
+ \\)
+ ;
+
+ try dbi.put( create_users_q, .{} );
+ // ensure all fields actually exist in the database
+ dbi.put( "ALTER TABLE users ADD COLUMN email TEXT", .{} ) catch {};
+ dbi.put( "ALTER TABLE users ADD COLUMN tokens TEXT", .{} ) catch {};
+ dbi.put( "ALTER TABLE users ADD COLUMN login_token TEXT", .{} ) catch {};
+ dbi.put( "ALTER TABLE users ADD COLUMN token_resetdate INTEGER", .{} ) catch {};
+ dbi.put( "ALTER TABLE users ADD COLUMN api_tokens TEXT", .{} ) catch {};
+ dbi.put( "ALTER TABLE users ADD COLUMN created_at INTEGER", .{} ) catch {};
+ dbi.put( "ALTER TABLE users ADD COLUMN subscription_data TEXT", .{} ) catch {};
+
+ const create_userdata_q =
+ \\CREATE TABLE IF NOT EXISTS user_data (
+ \\ uuid TEXT PRIMARY KEY,
+ \\ nickname TEXT,
+ \\ prompt_data TEXT,
+ \\ site_prefs TEXT,
+ \\ chat_files TEXT
+ \\)
+ ;
+
+ try dbi.put( create_userdata_q, .{} );
+ // ensure all fields actually exist in the database
+ dbi.put( "ALTER TABLE user_data ADD COLUMN nickname INTEGER", .{} ) catch {};
+ dbi.put( "ALTER TABLE user_data ADD COLUMN prompt_data INTEGER", .{} ) catch {};
+ dbi.put( "ALTER TABLE user_data ADD COLUMN site_prefs INTEGER", .{} ) catch {};
+ dbi.put( "ALTER TABLE user_data ADD COLUMN chat_files INTEGER", .{} ) catch {};
+}
+
+pub fn init( dbfile: [:0]const u8 ) !void {
+ if( dbi_init ) return error.DbiAlreadyInitialized;
+
+ const db = try sql.Db.init( .{
+ .mode = Db.Mode{ .File = dbfile },
+ .open_flags = .{
+ .write = true,
+ .create = true
+ },
+ .threading_mode = .MultiThread
+ } );
+
+ dbi = .{ .db = db };
+ dbi_init = true;
+}
+
+fn dbiCheck() !void {
+ if( !dbi_init ) return error.DbiNotInitialized;
+}
+
+///methods of t get ignored
+pub fn Row( comptime t: type ) type {
+ return struct {
+ v: t,
+ alloc: ArenaAllocator,
+ pub fn deinit( self: @This() ) void {
+ self.alloc.deinit();
+ }
+ };
+}
+
+pub const Interface = struct {
+ const Self = Interface;
+ db: Db,
+ pub fn deinit( self: *Interface ) void {
+ self.db.deinit();
+ }
+
+ pub fn put( self: *Interface, comptime query: []const u8, value: anytype ) !void {
+ try dbiCheck();
+ db_mutex.lock();
+ defer db_mutex.unlock();
+
+ var statement = try self.db.prepare( query );
+ defer statement.deinit();
+
+ try statement.exec( .{}, value );
+ }
+
+ ///returns the first row of the query
+ pub fn one( self: *Interface, comptime t: type, comptime query: []const u8, value: anytype ) !t {
+ try dbiCheck();
+ db_mutex.lock();
+ defer db_mutex.unlock();
+
+ var statement = try self.db.prepare( query );
+ defer statement.deinit();
+
+ const ret = try statement.one( t, .{}, value );
+ return ret.?;
+ }
+
+ ///returns all rows of the query
+ ///must be freed by caller
+ pub fn all( self: *Interface, comptime t: type, comptime query: []const u8, value: anytype ) ![]t {
+ try dbiCheck();
+ db_mutex.lock();
+ defer db_mutex.unlock();
+
+ var statement = try self.db.prepare( query );
+ defer statement.deinit();
+
+ return try statement.all( t, alloc, .{}, value );
+ }
+
+ pub fn oneAlloc( self: *Interface, comptime t: type, comptime query: []const u8, value: anytype ) !Row(t) {
+ try dbiCheck();
+ db_mutex.lock();
+ defer db_mutex.unlock();
+
+ var arena = ArenaAllocator.init( alloc );
+ const a = arena.allocator();
+
+ var statement = try self.db.prepareDynamic( query );
+ defer statement.deinit();
+
+ const ret = try statement.oneAlloc( t, a, .{}, value );
+ if( ret == null ) return error.QueryError;
+ return .{ .v = ret.?, .alloc = arena };
+ }
+};
diff --git a/backend/api/src/mail.zig b/backend/api/src/mail.zig
new file mode 100644
index 0000000..2489a48
--- /dev/null
+++ b/backend/api/src/mail.zig
@@ -0,0 +1,60 @@
+const z = @import( "std" );
+const u = @import( "util.zig" );
+const smtp = @import( "smtp_client" );
+
+const bufPrint = z.fmt.bufPrint;
+const alloc = u.alloc;
+
+pub var smtp_host = "smtppro.zoho.com";
+pub var smtp_alias = "Axonbox Support";
+pub var smtp_username = "support@axonbox.net";
+pub var smtp_port: u16 = 465;
+
+const Config = smtp.Config;
+
+fn getPassword() ![]const u8 {
+ return try u.readFile( "../data/mail_password.txt" );
+}
+
+fn getConfig() !Config {
+ const pass = try getPassword();
+
+ return Config {
+ .host = smtp_host,
+ .username = smtp_username,
+ .password = pass,
+ .port = smtp_port,
+ .encryption = .tls,
+ .allocator = alloc,
+ };
+}
+
+fn formatEmail( to: []const u8, subject: []const u8, body: []const u8 ) []const u8 {
+ const escaped_newl = z.mem.replaceOwned( u8, alloc, body, "\n", "\r\n" ) catch return "";
+ const escaped_dots = z.mem.replaceOwned( u8, alloc, escaped_newl, "\n.", "\n.." ) catch return "";
+ defer alloc.free( escaped_dots );
+ alloc.free( escaped_newl );
+
+ const fmt = "From: {s} <{s}>\r\nTo: {s}\r\nSubject: {s}\r\n\r\n{s}\r\n.\r\n";
+ const slice = z.fmt.allocPrint( alloc, fmt, .{
+ smtp_alias, smtp_username, to, subject, escaped_dots
+ } ) catch return "";
+
+ return slice;
+}
+
+pub fn send( to: []const u8, subject: []const u8, body: []const u8 ) !void {
+ const config = getConfig() catch {
+ z.debug.print( "error creating mail config", .{} );
+ return;
+ };
+ defer alloc.free( config.password.? );
+
+ const data = formatEmail( to, subject, body );
+ defer alloc.free( data );
+
+ smtp.send( .{ .to = &.{to}, .from = smtp_username, .data = data }, config ) catch |e| {
+ z.debug.print( "error sending mail {any} {any}", .{ e, @errorReturnTrace() } );
+ return e;
+ };
+}
diff --git a/backend/api/src/main.zig b/backend/api/src/main.zig
new file mode 100644
index 0000000..d0dd46f
--- /dev/null
+++ b/backend/api/src/main.zig
@@ -0,0 +1,28 @@
+const z = @import( "std" );
+const u = @import( "util.zig" );
+const db = @import( "db.zig" );
+const zap = @import( "zap" );
+const req = @import( "req.zig" );
+const api = @import( "api.zig" );
+const chat = @import( "chat.zig" );
+const user = @import( "userdata.zig" );
+const config = @import( "config.zig" );
+const server = @import( "server.zig" );
+
+pub fn main() !void {
+ const a = u.alloc;
+ defer _=u.gpa.deinit();
+
+ try config.parse();
+ z.debug.print( "domain: {s}\n", .{ config.server_url } );
+ z.debug.print( "starting...\n", .{} );
+
+ try db.create( "../data/users.sqlite" );
+ user.createDirectories();
+
+ try api.listen();
+ try server.listen( a );
+ zap.start( .{ .threads = 12, .workers = 0 } );
+
+ server.deinit();
+}
diff --git a/backend/api/src/model.zig b/backend/api/src/model.zig
new file mode 100644
index 0000000..b35104f
--- /dev/null
+++ b/backend/api/src/model.zig
@@ -0,0 +1,123 @@
+const z = @import( "std" );
+const zap = @import( "zap" );
+const net = @import( "net-util.zig" );
+const u = struct {
+ usingnamespace @import( "util.zig" );
+ usingnamespace @import( "user.zig" );
+ usingnamespace @import( "userdefs.zig" );
+};
+
+const alloc = z.heap.page_allocator;
+
+const ArrayList = z.ArrayList;
+const OkRes = net.OkResponse;
+const ErrRes = net.ErrorResponse;
+
+const memeql = z.mem.eql;
+
+threadlocal var modelcache: ?u.JsonResponse([]Model) = null;
+
+pub const routes = .{
+ .@"models" = models
+};
+
+pub const ModelCapabilities = struct {
+ vision: u32 = 0,
+ remind: u32 = 0,
+ notes: u32 = 0,
+ web: u32 = 0,
+ thinker: ?u32 = 0,
+
+ pub const @"getty.db" = u.@"json.ignore.unknown";
+};
+
+pub const Model = struct {
+ name: []const u8,
+ modelname: []const u8,
+ capabilities: ModelCapabilities = .{},
+ system: []const u8 = "",
+ description: []const u8 = "",
+ short_description: []const u8 = "",
+ free: u32 = 0,
+ license: []const u8 = "",
+};
+
+pub const UserResponse = struct {
+ name: []const u8,
+ capabilities: ModelCapabilities = .{},
+ description: struct {
+ full: []const u8 = "",
+ short: []const u8 = "",
+ },
+ license: []const u8 = "",
+ free: u32 = 0,
+
+ pub fn fromModel( model: Model ) UserResponse {
+ return UserResponse {
+ .name = model.name,
+ .capabilities = model.capabilities,
+ .description = .{
+ .full = model.description,
+ .short = model.short_description
+ },
+ .free = model.free,
+ .license = model.license
+ };
+ }
+};
+
+pub fn canBeUsedByUser( model: Model, user: *const u.UserEntry ) bool {
+ if( memeql( u8, user.subscription_data.plan, "free" ) ) {
+ return model.free == 1;
+ }
+
+ return true;
+}
+
+pub fn loadModels() !*u.JsonResponse([]Model) {
+ const modelmap = try u.readFileAlloc( "../data/modelmap.json", alloc );
+ defer alloc.free( modelmap );
+
+ if( modelcache ) |cache| {
+ cache.deinit();
+ }
+
+ modelcache = u.jsonParseAlloc( []Model, modelmap, alloc ) catch |e| {
+ return e;
+ };
+
+ return &(modelcache.?);
+}
+
+pub fn getModelByDisplayName( modelname: []const u8 ) !*const Model {
+ const modelmap = loadModels() catch |e| {
+ z.debug.print( "failed to load model map: {any} {any}", .{ e, @errorReturnTrace() } );
+ return e;
+ };
+
+ for( modelmap.v ) |*model| {
+ if( memeql( u8, model.name, modelname ) ) {
+ return model;
+ }
+ }
+
+ return error.ModelNotFound;
+}
+
+///assuming endpoint for models is supposed to be pub for support page use?
+///route @/models
+pub fn models( r: zap.Request ) void {
+ const modelmap = loadModels() catch |e| {
+ z.debug.print( "failed to load model map: {any} {any}", .{ e, @errorReturnTrace() } );
+ return net.sendJson( r, .internal_server_error, ErrRes{ .msg = "failed to load model info" } );
+ };
+
+ var list = ArrayList( UserResponse ).init( alloc );
+ defer list.deinit();
+
+ for( modelmap.v ) |model| {
+ list.append( UserResponse.fromModel( model ) ) catch {};
+ }
+
+ net.sendJson( r, .ok, OkRes( .{ .models = list.items } ) );
+}
diff --git a/backend/api/src/net-util.zig b/backend/api/src/net-util.zig
new file mode 100644
index 0000000..1cc8fb1
--- /dev/null
+++ b/backend/api/src/net-util.zig
@@ -0,0 +1,140 @@
+const z = @import( "std" );
+const u = @import( "util.zig" );
+const zap = @import( "zap" );
+const jwt = @import( "jwt" );
+const uuid = @import( "uuid" );
+
+const alloc = u.alloc;
+
+const JWT = jwt.JWT;
+const memcmp = z.mem.eql;
+const Status = zap.StatusCode;
+const Request = zap.Request;
+
+pub const ErrorResponse = struct {
+ status: []const u8 = "error",
+ msg: []const u8
+};
+
+const OkResponseTemplate = struct {
+ status: []const u8 = "ok"
+};
+
+fn MergeOk( comptime t: type ) type {
+ const ti = @typeInfo( t );
+ comptime if( ti.Struct.is_tuple )
+ return OkResponseTemplate;
+
+ return u.MergeTypes( OkResponseTemplate, t );
+}
+
+pub fn OkResponse( t: anytype ) MergeOk(@TypeOf(t)) {
+ const ret: MergeOk(@TypeOf(t)) = t;
+ return ret;
+}
+
+pub fn sendJson( r: Request, status: Status, t: anytype ) void {
+ r.setStatus( status );
+
+ const encoded = u.jsonStringify( t ) catch |e| {
+ z.debug.print( "Failed to encode JSON: {any} {any} {any}\n", .{t, e, @errorReturnTrace()} );
+ return;
+ };
+ defer alloc.free( encoded );
+
+ r.sendJson( encoded ) catch |e| {
+ z.debug.print( "Failed to send body: {s} {any} {any}\n", .{encoded, e, @errorReturnTrace()} );
+ };
+}
+
+pub fn sendJsonChunk( r: Request, status: Status, t: anytype ) void {
+ r.setStatus( status );
+ const encoded = u.jsonStringify( t ) catch |e| {
+ z.debug.print( "Failed to encode JSON: {any} {any} {any}\n", .{t, e, @errorReturnTrace()} );
+ return;
+ };
+ defer alloc.free( encoded );
+
+ z.debug.print( "sending {s}\n", .{ encoded } );
+ r.sendChunk( encoded ) catch |e| {
+ z.debug.print( "Failed to send body: {s} {any} {any}\n", .{encoded, e, @errorReturnTrace()} );
+ };
+}
+
+pub fn handleInvalidPostReq( r: Request ) bool {
+ if( r.method == null ) {
+ sendJson( r, .method_not_allowed, .{
+ .status = "error", .msg = "method is null"
+ } );
+ return true;
+ }
+
+ if( r.methodAsEnum() != .POST ) {
+ if( r.methodAsEnum() == .OPTIONS ) {
+ r.sendBody( "" ) catch {};
+ return true;
+ }
+
+ sendJson( r, .method_not_allowed, .{
+ .status = "error", .msg = "method not allowed"
+ } );
+ return true;
+ }
+
+ if( r.body == null ) {
+ sendJson( r, .bad_request, .{
+ .status = "error", .msg = "body is null"
+ } );
+ return true;
+ }
+ r.parseBody() catch {
+ sendJson( r, .bad_request, .{
+ .status = "error", .msg = "body is not valid json"
+ } );
+
+ return true;
+ };
+
+ return false;
+}
+
+///caller has to free
+pub fn getJwtSecret() ![]const u8 {
+ return u.readFile( "../data/jwt_secret.txt" );
+}
+
+///caller has to free
+pub fn parseJWT( t: type, str: []const u8 ) !JWT(t) {
+ const key = try getJwtSecret();
+ defer alloc.free( key );
+ const token = try jwt.decode(
+ alloc,
+ t,
+ str,
+ .{ .secret = key },
+ .{},
+ );
+
+ return token;
+}
+
+///caller has to free
+pub fn encodeJWT( data: anytype ) ![]const u8 {
+ const secret = try getJwtSecret();
+ defer alloc.free( secret );
+ const token = try jwt.encode(
+ alloc,
+ .{ .alg = .HS256 },
+ data,
+ .{ .secret = secret }
+ );
+
+ return token;
+}
+
+pub fn uuidv4() uuid.urn.Urn {
+ const id = uuid.v4.new();
+ const urn = uuid.urn.serialize(id);
+
+ return urn;
+}
diff --git a/backend/api/src/req.zig b/backend/api/src/req.zig
new file mode 100644
index 0000000..90e5591
--- /dev/null
+++ b/backend/api/src/req.zig
@@ -0,0 +1,311 @@
+const z = @import( "std" );
+const u = @import( "util.zig" );
+const HttpClient = @import( "tls12" );
+
+const ClientResponse = HttpClient.Response;
+const ClientRequest = HttpClient.Request;
+
+const ArenaAllocator = z.heap.ArenaAllocator;
+const Allocator = z.mem.Allocator;
+const print = z.debug.print;
+
+pub const Header = z.http.Header;
+
+pub const OnChunkFunc = *const fn ( []const u8 ) void;
+pub fn OnChunkFuncWithData( t: type ) type {
+ return *const fn ( []const u8, t ) void;
+}
+pub const OnReqEndFunc = *const fn( *Result ) void;
+pub fn OnReqEndFuncWithData( t: type ) type {
+ return *const fn( *Result, t ) void;
+}
+
+
+const ParamsBase = struct {
+ url: []const u8,
+ body: ?[]const u8 = null,
+ method: z.http.Method = z.http.Method.GET,
+ headers: ?[]const Header = null,
+};
+
+pub fn Params() type {
+ return u.MergeTypes( ParamsBase, struct { chunk_fn: ?OnChunkFunc = null } );
+}
+pub fn ParamsAsync() type {
+ return u.MergeTypes( ParamsBase, struct { end_fn: ?OnReqEndFunc = null, chunk_fn: ?OnChunkFunc = null, mutex: z.Thread.Mutex = .{} } );
+}
+pub fn ParamsWithData( t: type ) type {
+ return u.MergeTypes( ParamsBase, struct { chunk_fn: ?OnChunkFuncWithData(t), chunk_data: t = undefined } );
+}
+pub fn ParamsWithDataAsync( t: type ) type {
+ return u.MergeTypes( ParamsBase, struct { end_fn: ?OnReqEndFuncWithData(t), chunk_fn: ?OnChunkFuncWithData(t), chunk_data: t = undefined, mutex: z.Thread.Mutex = .{} } );
+}
+
+
+pub const Result = struct {
+ ok: bool = false,
+ url: []const u8 = &[_]u8{ 0 },
+ status: z.http.Status = z.http.Status.teapot,
+ body: ?[]const u8 = null,
+ headers: z.ArrayList( z.http.Header ),
+ alloc: z.mem.Allocator = undefined,
+
+ pub fn deinit( self: *const Result ) void {
+ if( self.body ) |*body|
+ self.alloc.free( body.* );
+
+ self.headers.deinit();
+ }
+};
+
+pub fn send( params: Params(), a: Allocator ) Result {
+ var ret = Result{
+ .alloc = a,
+ .url = params.url,
+ .headers = z.ArrayList( Header ).init( a )
+ };
+
+ const uri = z.Uri.parse( params.url ) catch return ret;
+ var client = HttpClient{ .allocator = a };
+ defer client.deinit();
+
+ const header_buf: []u8 = a.alloc( u8, 1024 * 8 ) catch return ret;
+ defer a.free( header_buf );
+
+ var req = client.open( params.method, uri, .{
+ .server_header_buffer = header_buf
+ }) catch |e| { z.debug.print( "{any}\n", .{ e } ); return ret; };
+ defer req.deinit();
+
+ if( params.headers ) |hdrs|
+ req.extra_headers = hdrs;
+
+ if( params.body != null and params.method == z.http.Method.POST )
+ req.transfer_encoding = .{ .content_length = params.body.?.len };
+
+ req.send() catch return ret;
+ if( params.body != null and params.method == z.http.Method.POST ) {
+ var writer = req.writer();
+ writer.writeAll( params.body.? ) catch return ret;
+ }
+ req.finish() catch return ret;
+ req.wait() catch return ret;
+
+ ret.ok = true;
+ if( params.chunk_fn != null ) {
+ receiveChunked( &ret, &req, params, a ) catch { ret.ok = false; };
+ } else {
+ receiveSingle( &ret, &req, a ) catch { ret.ok = false; };
+ }
+
+ receiveHeaders( &ret, &req );
+ return ret;
+}
+
+///compared to send, takes an additional data arg that gets passed to the chunk_fn
+pub fn sendWithData( t: type, params: ParamsWithData(t), a: Allocator ) Result {
+ var ret = Result{
+ .alloc = a,
+ .url = params.url,
+ .headers = z.ArrayList( Header ).init( a )
+ };
+
+ const uri = z.Uri.parse( params.url ) catch return ret;
+ var client = HttpClient{ .allocator = a };
+ defer client.deinit();
+
+ const header_buf: []u8 = a.alloc( u8, 1024 * 8 ) catch return ret;
+ defer a.free( header_buf );
+
+ var req = client.open( params.method, uri, .{
+ .server_header_buffer = header_buf
+ }) catch |e| { z.debug.print( "http error: {any}\n", .{ e } ); return ret; };
+ defer req.deinit();
+
+ if( params.headers ) |hdrs|
+ req.extra_headers = hdrs;
+
+ if( params.body != null and params.method == z.http.Method.POST )
+ req.transfer_encoding = .{ .content_length = params.body.?.len };
+
+ req.send() catch return ret;
+ if( params.body != null and params.method == z.http.Method.POST ) {
+ var writer = req.writer();
+ writer.writeAll( params.body.? ) catch return ret;
+ }
+ req.finish() catch return ret;
+ req.wait() catch return ret;
+
+ receiveHeaders( &ret, &req );
+ if( params.chunk_fn != null ) {
+ receiveChunkedWithData( t, &ret, &req, params, a ) catch { ret.ok = false; };
+ } else {
+ receiveSingle( &ret, &req, a ) catch { ret.ok = false; };
+ }
+
+ ret.ok = req.response.status == .ok;
+ return ret;
+}
+
+///UNFINISHED: DOES NOT WORK YET
+pub fn dispatchSendWithData( t: type, params: ParamsWithDataAsync(t), _a: ArenaAllocator ) void {
+ var arena = _a;
+ const a = arena.allocator();
+ defer arena.deinit();
+ var ret = Result{
+ .alloc = a,
+ .url = params.url,
+ .headers = z.ArrayList( Header ).init( a )
+ };
+
+ defer if( params.end_fn ) |f| f( &ret, params.chunk_data );
+
+ const uri = z.Uri.parse( params.url ) catch return;
+ var client = HttpClient{ .allocator = a };
+
+ const header_buf: []u8 = a.alloc( u8, 1024 * 8 ) catch return;
+
+ var req = client.open( params.method, uri, .{
+ .server_header_buffer = header_buf,
+ }) catch |e| { z.debug.print( "http error: {any}\n", .{ e } ); return; };
+
+ if( params.headers ) |hdrs|
+ req.extra_headers = hdrs;
+
+ if( params.body != null and params.method == z.http.Method.POST )
+ req.transfer_encoding = .{ .content_length = params.body.?.len };
+
+ req.send() catch return;
+ if( params.body != null and params.method == z.http.Method.POST ) {
+ var writer = req.writer();
+ writer.writeAll( params.body.? ) catch return;
+ }
+ req.finish() catch return;
+ req.wait() catch return;
+
+ receiveHeaders( &ret, &req );
+ if( params.chunk_fn != null ) {
+ receiveChunkedWithDataAsync( t, &ret, &req, params, a ) catch { ret.ok = false; };
+ } else {
+ receiveSingle( &ret, &req, a ) catch { ret.ok = false; };
+ }
+
+ ret.ok = req.response.status == .ok;
+}
+
+pub fn sendWithDataAsync( t: type, params: ParamsWithDataAsync(t), _arena: ArenaAllocator ) void {
+ var arena = _arena;
+ const a = arena.allocator();
+ const paramsc = params;
+
+ _=z.Thread.spawn( .{ .allocator = a }, dispatchSendWithData, .{ t, paramsc, arena } ) catch |e| {
+ arena.deinit();
+ z.debug.print( "error spawning thread: {any}\n", .{ e } ); return;
+ };
+}
+
+fn receiveHeaders( ret: *Result, req: *ClientRequest ) void {
+ var iter = req.response.iterateHeaders();
+ while( iter.next() ) |header|
+ ret.headers.append( header ) catch continue;
+}
+
+fn receiveSingle( ret: *Result, res: *ClientRequest, a: Allocator ) !void {
+ ret.status = res.response.status;
+ // fuck it we ball
+ const slice = try res.reader().readAllAlloc( a, 1024 * 1024 );
+ ret.body = slice;
+ if( ret.status != .ok )
+ ret.ok = false;
+}
+
+fn receiveChunked( ret: *Result, req: *ClientRequest, q: Params(), a: Allocator ) !void {
+ ret.status = req.response.status;
+
+ var full_str = z.ArrayList( u8 ).init( a );
+ defer full_str.deinit();
+ const writer = full_str.writer();
+
+ var buf = try a.alloc( u8, 1024 * 16 );
+ defer a.free( buf );
+
+ var bytes_total: usize = 0;
+ const reader = req.reader();
+ while( true ) {
+ const bytes_read = try reader.read( buf );
+ if( bytes_read == 0 )
+ break;
+
+ bytes_total += bytes_read;
+ _=try writer.write( buf[0..bytes_read] );
+ const f = q.chunk_fn;
+ if( f ) |func| func( buf[0..bytes_read] );
+ z.Thread.yield() catch {};
+ }
+
+ const slice = try a.alloc( u8, bytes_total );
+ @memcpy( slice[0..bytes_total], full_str.items[0..bytes_total] );
+ ret.body = slice;
+}
+
+fn receiveChunkedWithData( t: type, ret: *Result, req: *ClientRequest, q: ParamsWithData(t), a: Allocator ) !void {
+ ret.status = req.response.status;
+
+ var full_str = z.ArrayList( u8 ).init( a );
+ defer full_str.deinit();
+ const writer = full_str.writer();
+
+ var buf = try a.alloc( u8, 1024 * 16 );
+ defer a.free( buf );
+
+ var bytes_total: usize = 0;
+ const reader = req.reader();
+
+ //todo: throw this into another thread
+ while( true ) {
+ const bytes_read = try reader.read( buf );
+ if( bytes_read == 0 )
+ break;
+
+ bytes_total += bytes_read;
+ _=try writer.write( buf[0..bytes_read] );
+ const f = q.chunk_fn;
+ if( f ) |func| func( buf[0..bytes_read], q.chunk_data );
+ z.Thread.yield() catch {};
+ }
+
+ const slice = try a.alloc( u8, bytes_total );
+ @memcpy( slice[0..bytes_total], full_str.items[0..bytes_total] );
+ ret.body = slice;
+}
+
+fn receiveChunkedWithDataAsync( t: type, ret: *Result, req: *ClientRequest, q: ParamsWithDataAsync(t), a: Allocator ) !void {
+ ret.status = req.response.status;
+
+ var full_str = z.ArrayList( u8 ).init( a );
+ defer full_str.deinit();
+ const writer = full_str.writer();
+
+ var buf = try a.alloc( u8, 1024 * 16 );
+ defer a.free( buf );
+
+ var bytes_total: usize = 0;
+ const reader = req.reader();
+
+ //todo: throw this into another thread
+ while( true ) {
+ const bytes_read = try reader.read( buf );
+ if( bytes_read == 0 )
+ break;
+
+ bytes_total += bytes_read;
+ _=try writer.write( buf[0..bytes_read] );
+ const f = q.chunk_fn;
+ if( f ) |func| func( buf[0..bytes_read], q.chunk_data );
+ z.Thread.yield() catch {};
+ }
+
+ const slice = try a.alloc( u8, bytes_total );
+ @memcpy( slice[0..bytes_total], full_str.items[0..bytes_total] );
+ ret.body = slice;
+}
diff --git a/backend/api/src/server.zig b/backend/api/src/server.zig
new file mode 100644
index 0000000..99f12d6
--- /dev/null
+++ b/backend/api/src/server.zig
@@ -0,0 +1,101 @@
+const z = @import( "std" );
+const u = @import( "util.zig" );
+const zap = @import( "zap" );
+const api = @import( "api.zig" );
+const user = @import( "user.zig" );
+const chat = @import( "chat.zig" );
+const prefs = @import( "userdata.zig" );
+const model = @import( "model.zig" );
+const stripe = @import( "stripe.zig" );
+const config = @import( "config.zig" );
+const userdata = @import( "userdata.zig" );
+
+const Allocator = z.mem.Allocator;
+const Status = zap.StatusCode;
+const Request = zap.Request;
+
+const bufprint = z.fmt.bufPrint;
+var routes: z.StringHashMap( zap.HttpRequestFn ) = undefined;
+const alloc = u.alloc;
+
+fn index( r: Request ) void {
+ r.sendBody( "" ) catch {};
+}
+
+fn stop( r: Request ) void {
+ api.stop();
+ z.time.sleep( 5000000000 );
+ zap.stop();
+ r.sendBody( "" ) catch {};
+}
+
+fn setupRoutes( a: Allocator ) void {
+ routes = z.StringHashMap( zap.HttpRequestFn ).init( a );
+ routes.put( "/", index ) catch {};
+ //routes.put( "/stop", stop ) catch {};
+
+ // user routes
+ routes.put( "/login", user.routes.@"login" ) catch {};
+ routes.put( "/get-tokens", user.routes.@"get-tokens" ) catch {};
+ routes.put( "/create-token", user.routes.@"create-token" ) catch {};
+ routes.put( "/delete-token", user.routes.@"delete-token" ) catch {};
+ routes.put( "/delete-tokens", user.routes.@"delete-tokens" ) catch {};
+ routes.put( "/send-login-link", user.routes.@"send-login-link" ) catch {};
+ routes.put( "/invalidate-session", user.routes.@"invalidate-session" ) catch {};
+ routes.put( "/invalidate-all-sessions", user.routes.@"invalidate-all-sessions" ) catch {};
+
+ // settings routes
+ routes.put( "/settings", prefs.routes.@"settings" ) catch {};
+ routes.put( "/get-notes", prefs.routes.@"get-notes" ) catch {};
+ routes.put( "/getalldata", prefs.routes.@"getalldata" ) catch {};
+ routes.put( "/delete-note", prefs.routes.@"delete-note" ) catch {};
+ routes.put( "/delete-notes", prefs.routes.@"delete-notes" ) catch {};
+ routes.put( "/update-settings", prefs.routes.@"update-settings" ) catch {};
+
+ // models route
+ routes.put( "/models", model.routes.@"models" ) catch {};
+
+ // stripe route
+ routes.put( "/create-payment-intent", stripe.routes.@"create-payment-intent" ) catch {};
+
+ // chat routes
+ routes.put( "/chat", chat.routes.@"chat" ) catch {};
+ routes.put( "/get-chat", chat.routes.@"get-chat" ) catch {};
+ routes.put( "/generate", chat.routes.@"generate" ) catch {};
+ routes.put( "/create-chat", chat.routes.@"create-chat" ) catch {};
+ routes.put( "/delete-chat", chat.routes.@"delete-chat" ) catch {};
+}
+
+fn onRequest( r: Request ) void {
+ r.setHeader( "Access-Control-Allow-Origin", "*" ) catch {};
+ r.setHeader( "Access-Control-Allow-Methods", "*" ) catch {};
+ r.setHeader( "Access-Control-Allow-Headers", "*" ) catch {};
+ r.setHeader( "Access-Control-Allow-Credentials", "true" ) catch {};
+ if( r.path ) |p| {
+ if( routes.get( p ) ) |route| {
+ route( r );
+ return;
+ }
+ }
+
+ r.setStatus( .not_found );
+ r.sendBody( "<h1>404 Not Found</h1>" ) catch return;
+}
+
+var listener: zap.HttpListener = undefined;
+
+pub fn listen( a: Allocator ) !void {
+ setupRoutes( a );
+
+ listener = zap.HttpListener.init( .{
+ .port = config.server_port,
+ .on_request = onRequest,
+ .max_clients = 5000,
+ } );
+
+ try listener.listen();
+}
+
+pub fn deinit() void {
+ routes.deinit();
+}
diff --git a/backend/api/src/stripe.zig b/backend/api/src/stripe.zig
new file mode 100644
index 0000000..87cef10
--- /dev/null
+++ b/backend/api/src/stripe.zig
@@ -0,0 +1,181 @@
+const z = @import( "std" );
+const db = @import( "db.zig" );
+const req = @import( "req.zig" );
+const net = @import( "net-util.zig" );
+const zap = @import( "zap" );
+const jwt = @import( "jwt" );
+const mail = @import( "mail.zig" );
+const config = @import( "config.zig" );
+
+const u = struct {
+ usingnamespace @import( "util.zig" );
+ usingnamespace @import( "userdefs.zig" );
+ usingnamespace @import( "user.zig" );
+};
+
+const ArenaAllocator = z.heap.ArenaAllocator;
+const ErrorRes = net.ErrorResponse;
+const Status = zap.StatusCode;
+const ArrayList = z.ArrayList;
+const OkRes = net.OkResponse;
+const Request = zap.Request;
+const JWT = jwt.JWT;
+
+const alloc = u.alloc;
+const memeql = z.mem.eql;
+
+pub const routes = .{
+ .@"create-payment-intent" = createPaymentIntent
+};
+
+pub const SubLength = enum(u32) {
+ week = 1,
+ month = 2,
+ year = 3,
+
+ pub fn getTime( self: SubLength ) i64 {
+ switch( self ) {
+ .week => return 7 * 24 * 60 * 60 * 1000,
+ .month => return 30 * 24 * 60 * 60 * 1000,
+ .year => return 365 * 24 * 60 * 60 * 1000
+ }
+ }
+
+ pub fn getCostAmount( self: SubLength ) u32 {
+ switch( self ) {
+ .week => return 270,
+ .month => return 1000,
+ .year => return 10000
+ }
+ }
+};
+
+const StripeUrlParams = struct {
+ amount: u32 = 1000,
+ currency: []const u8 = "usd",
+ description: []const u8 = "Axonbox premium subscription",
+ payment_method: []const u8,
+ confirm: []const u8 = "true",
+ uuid: []const u8,
+ return_url: []const u8
+};
+
+const StripeRequestParams = struct {
+ paymentMethodId: []const u8,
+ subLength: SubLength = .month,
+
+ pub const @"getty.db" = u.@"json.ignore.unknown";
+};
+
+const StripeResponse = struct {
+ status: []const u8,
+
+ pub const @"getty.db" = u.@"json.ignore.unknown";
+};
+
+///freed by caller
+fn formatStripeParams( params: StripeUrlParams ) []const u8 {
+ const ret = z.fmt.allocPrint( alloc,
+ "amount={d}&currency={s}&description={s}&payment_method={s}&confirm={s}&metadata%5Buuid%5D={s}&return_url={s}",
+ .{
+ params.amount,
+ params.currency,
+ params.description,
+ params.payment_method,
+ params.confirm,
+ params.uuid,
+ params.return_url
+ }
+ ) catch return "";
+
+ return ret;
+}
+
+fn formatReturnUrl( buf: []u8 ) []const u8 {
+ return z.fmt.bufPrintZ( buf, "{s}/payment-success.html", .{
+ config.server_url
+ } ) catch return "";
+}
+
+fn getStripeKeyBearer() ![]const u8 {
+ const file = try u.readFile( "../data/stripe_key.txt" );
+ defer alloc.free( file );
+
+ const buf = try z.fmt.allocPrint( alloc, "Bearer {s}", .{ file } );
+ return buf;
+}
+
+pub fn handleStripeResponse( user: *u.UserEntry, res: StripeResponse, r: zap.Request, length: SubLength ) void {
+ const status = res.status;
+ if( memeql( u8, status, "succeeded" ) ) {
+ user.subscription_data = u.SubData{
+ .plan = "paid",
+ .endTime = z.time.milliTimestamp() + length.getTime(),
+ .reminded = false
+ };
+
+ u.updateEntry( user.* ) catch {
+ return net.sendJson( r, .internal_server_error, ErrorRes{ .msg = "failed to update user" } );
+ };
+ return net.sendJson( r, .ok, OkRes( .{ .msg = "success", .paymentIntent = res } ) );
+ }
+
+ net.sendJson( r, .bad_request, ErrorRes{ .msg = "failed" } );
+}
+
+
+///route @/create-payment-intent
+pub fn createPaymentIntent( r: zap.Request ) void {
+ if( net.handleInvalidPostReq( r ) ) return;
+ u.checkDbOnThread() catch return;
+
+ var token = u.tokenFromPostReq( r ) catch return;
+ defer token.deinit();
+
+ const params = u.jsonParse( StripeRequestParams, r.body.? ) catch {
+ return net.sendJson( r, .bad_request, ErrorRes{ .msg = "invalid request format" } );
+ }; defer params.deinit();
+
+ var user = u.getEntry( token.claims.uuid ) catch {
+ return net.sendJson( r, .unauthorized, ErrorRes{ .msg = "user not found" } );
+ }; defer user.db_entry.?.deinit();
+
+ if( !memeql( u8, user.subscription_data.plan, "free" ) ) {
+ return net.sendJson( r, .bad_request, ErrorRes{ .msg = "user already subscribed" } );
+ }
+
+ const stripe_key = getStripeKeyBearer() catch {
+ return net.sendJson( r, .internal_server_error, ErrorRes{ .msg = "internal server error" } );
+ }; defer alloc.free( stripe_key );
+
+ const headers = [_]z.http.Header{
+ .{ .name = "Authorization", .value = stripe_key }
+ };
+
+ var buf: [2048]u8 = undefined;
+ const body = formatStripeParams( .{
+ .amount = params.v.subLength.getCostAmount(),
+ .payment_method = params.v.paymentMethodId,
+ .uuid = user.uuid,
+ .return_url = formatReturnUrl( &buf )
+ } ); defer alloc.free( body );
+
+ var res = req.send( .{
+ .url = "https://api.stripe.com/v1/payment_intents",
+ .method = .POST,
+ .headers = &headers,
+ .body = body
+ }, alloc );
+ defer res.deinit();
+
+ if( !res.ok ) {
+ z.debug.print( "failed to create payment intent: {s}\n", .{ res.body.? } );
+ return net.sendJson( r, .internal_server_error, ErrorRes{ .msg = "failed to create payment intent" } );
+ }
+
+ const stripe_json = u.jsonParse( StripeResponse, res.body.? ) catch {
+ return net.sendJson( r, .internal_server_error, ErrorRes{ .msg = "failed to parse provider response" } );
+ }; defer stripe_json.deinit();
+
+ handleStripeResponse( &user, stripe_json.v, r, params.v.subLength );
+}
diff --git a/backend/api/src/user.zig b/backend/api/src/user.zig
new file mode 100644
index 0000000..bec6e9c
--- /dev/null
+++ b/backend/api/src/user.zig
@@ -0,0 +1,533 @@
+const z = @import( "std" );
+const db = @import( "db.zig" );
+const net = @import( "net-util.zig" );
+const zap = @import( "zap" );
+const jwt = @import( "jwt" );
+const mail = @import( "mail.zig" );
+const config = @import( "config.zig" );
+const u = struct {
+ usingnamespace @import( "util.zig" );
+ usingnamespace @import( "userdefs.zig" );
+};
+
+const ctime = @cImport( @cInclude( "time.h" ) );
+
+const ArenaAllocator = z.heap.ArenaAllocator;
+const ErrorRes = net.ErrorResponse;
+const Status = zap.StatusCode;
+const ArrayList = z.ArrayList;
+const OkRes = net.OkResponse;
+const Request = zap.Request;
+const JWT = jwt.JWT;
+
+const UserDbEntry = u.UserDbEntry;
+const LoginToken = u.LoginToken;
+const UserEntry = u.UserEntry;
+const AuthToken = u.AuthToken;
+const ApiToken = u.ApiToken;
+
+const uuidv4 = net.uuidv4;
+const alloc = u.alloc;
+
+const login_link_email =
+\\Visit the following link to log in:
+\\{s}/login?token={s}
+;
+
+const reminder_email =
+\\Your plan will expire on: {s}.
+\\Subscriptions do not renew automatically.
+\\Don't forget to resubscribe at {s}/upgrade
+\\Or if you don't want to, let us know why at https://x.com/axonbox
+;
+
+pub const routes = .{
+ .@"login" = login,
+ .@"get-tokens" = getTokens,
+ .@"create-token" = createToken,
+ .@"delete-token" = deleteToken,
+ .@"delete-tokens" = deleteTokens,
+ .@"send-login-link" = sendLoginLink,
+ .@"invalidate-session" = invalidateSession,
+ .@"invalidate-all-sessions" = invalidateAllSessions,
+};
+
+fn createNew( email: []const u8, uuid: []const u8 ) !UserEntry {
+ try u.checkDbOnThread();
+ var ret: UserEntry = undefined;
+
+ ret.uuid = uuid;
+ ret.email = email;
+ ret.tokens = &[_][]const u8{};
+ ret.api_tokens = &[_][]const u8{};
+ ret.login_token = "";
+ ret.token_resetdate = z.time.timestamp();
+ ret.created_at = z.time.timestamp();
+ ret.subscription_data = .{ .plan = "free", .endTime = -1 };
+ ret.db_entry = null;
+
+ return ret;
+}
+
+pub fn updateEntry( user: UserEntry ) !void {
+ try u.checkDbOnThread();
+
+ var arena = ArenaAllocator.init( alloc );
+ defer arena.deinit();
+ const db_entry = try u.userToDb( user, &arena );
+
+ const query =
+ \\INSERT OR REPLACE INTO users ( uuid, email, tokens, login_token, token_resetdate, api_tokens, created_at, subscription_data )
+ \\VALUES ( ?, ?, ?, ?, ?, ?, ?, ? )
+ ;
+
+ try u.dbi.put( query, db_entry );
+}
+
+pub fn checkSubscription( user: *UserEntry ) !void {
+ const time = z.time.milliTimestamp();
+ const end_time = user.subscription_data.endTime;
+
+ if( time > end_time and end_time > -1 ) {
+ user.subscription_data.endTime = -1;
+ user.subscription_data.plan = "free";
+ try updateEntry( user.* );
+ }
+}
+
+///freed by caller
+pub fn getEntryFromEmail( email: []const u8 ) !UserEntry {
+ try u.checkDbOnThread();
+
+ const query = "SELECT * FROM users WHERE email = ?";
+ var res = u.dbi.oneAlloc( UserDbEntry, query, .{ .email = email } ) catch |e| {
+ switch( e ) {
+ error.QueryError => return error.UserNotFound,
+ else => return error.DatabaseError
+ }
+ };
+ return u.dbToUser( &res ) catch return error.UserParse;
+}
+
+pub fn getEntry( uuid: []const u8 ) !UserEntry {
+ try u.checkDbOnThread();
+
+ const query = "SELECT * FROM users WHERE uuid = ?";
+ var res = try u.dbi.oneAlloc( UserDbEntry, query, .{ .uuid = uuid } );
+ return u.dbToUser( &res );
+}
+
+///returns generated token
+fn updateLoginToken( user: UserEntry ) ![]const u8 {
+ try u.checkDbOnThread();
+ var copy = user;
+ _= try updateAuthTokens( &copy, false );
+ const token = u.generateLoginToken( user.uuid ) catch return error.TokenError;
+ copy.login_token = token;
+ updateEntry( copy ) catch {
+ alloc.free( token );
+ return error.DatabaseError;
+ };
+
+ return token;
+}
+
+fn setRemindedStatus( user: *UserEntry, value: bool ) !void {
+ user.subscription_data.reminded = value;
+ try updateEntry( user.* );
+}
+
+fn sendReminderEmail( user: UserDbEntry ) !void {
+ var arena = ArenaAllocator.init( alloc );
+ defer arena.deinit();
+
+ var row = try u.entryToRow( user, &arena );
+ var entry = try u.dbToUser( &row );
+
+ const t = @divFloor( entry.subscription_data.endTime, 1000 ); // ms to s
+ const lt = ctime.localtime( &t );
+ const fmt = "%a %d %b %Y %I:%M:%S %p %Z";
+
+ // this is supposed to be user.subscription_data's "endTime" ( unix epoch milliseconds ) as a date-string, not the current time
+ var dt_str_buf: [40]u8 = undefined;
+ @memset( &dt_str_buf, 0 );
+
+ const len = ctime.strftime( &dt_str_buf, dt_str_buf.len - 1, fmt, lt );
+ const dt_str = dt_str_buf[ 0 .. len ];
+
+ var buf: [4096]u8 = undefined;
+ const slice = z.fmt.bufPrintZ( &buf, reminder_email, .{ dt_str, config.server_url } ) catch return;
+ try mail.send( user.email, "Expiration Notice", slice );
+ try setRemindedStatus( &entry, true );
+}
+
+fn isTime( hour: u8, minute: u8, timezone: i64 ) bool {
+ const seconds_in_day = 24 * 60 * 60;
+ const time = z.time.timestamp();
+ const local_time = time + timezone;
+ const seconds_today = @mod( local_time, seconds_in_day );
+ const target = @as( i64, hour ) * 3600 + @as( i64, minute ) * 60;
+ const diff: i64 = if ( seconds_today >= target ) seconds_today - target else target - seconds_today;
+ const delta: f64 = @floatFromInt( diff );
+ return delta <= 2.5;
+}
+
+pub fn sendReminderEmails() !void {
+ if ( !isTime( 0, 0, -18000 ) ) // midnight +-2.5s adjusted for CST timezone during DST
+ return;
+
+ try u.checkDbOnThread();
+ const time = z.time.milliTimestamp();
+ const sevenDays = ( 1000 * 60 * 60 * 24 * 7 );
+
+ const checkEndTime = time + sevenDays;
+ const query =
+\\SELECT * FROM users WHERE
+\\json_extract( subscription_data, '$.endTime' ) <= ?
+\\AND
+\\json_extract( subscription_data, '$.reminded' ) = false
+;
+
+ const res = try u.dbi.all( UserDbEntry, query, .{ .endTime = checkEndTime } );
+ defer u.alloc.free( res );
+ if ( res.len == 0 )
+ return;
+
+ for ( res ) |r|
+ try sendReminderEmail( r );
+}
+
+fn sendEmail( email: []const u8, token: []const u8 ) !void {
+ var buf: [4096]u8 = undefined;
+ const slice = z.fmt.bufPrintZ( &buf, login_link_email, .{ config.server_url, token } ) catch return;
+ try mail.send( email, "Login link", slice );
+}
+
+///route @/send-login-link
+fn sendLoginLink( r: Request ) void {
+ if( net.handleInvalidPostReq( r ) ) return;
+ u.checkDbOnThread() catch {
+ return net.sendJson( r, .internal_server_error, ErrorRes{ .msg = "internal server error" } );
+ };
+
+ const body = r.body.?;
+ const json = u.jsonParse( struct{ email: []const u8 }, body ) catch {
+ return net.sendJson( r, .bad_request, ErrorRes{ .msg = "missing email field" } );
+ }; defer json.deinit();
+
+ const email = json.v.email;
+ if( !u.Validator.isValidStrEmail( email ) )
+ return net.sendJson( r, .bad_request, ErrorRes{ .msg = "invalid email format" } );
+
+ const user = getEntryFromEmail( email ) catch |e| r: {
+ switch( e ) {
+ error.UserNotFound => {
+ const uuid = uuidv4();
+ break :r createNew( email, &uuid ) catch {
+ return net.sendJson( r, .internal_server_error, ErrorRes{ .msg = "could not create user" } );
+ };
+ },
+ else => {
+ z.debug.print( "error getting entry for {s} {any} {any}", .{ email, e, @errorReturnTrace() } );
+ return net.sendJson( r, .internal_server_error, ErrorRes{ .msg = "server error" } );
+ }
+ }
+ }; defer { if( user.db_entry ) |entry| entry.deinit(); }
+
+ const token = updateLoginToken( user ) catch |e| {
+ z.debug.print( "error updating login token {s} {any} {any}", .{ user.uuid, e, @errorReturnTrace() } );
+ switch( e ) {
+ error.TokenError => return net.sendJson( r, .internal_server_error, ErrorRes{ .msg = "failed to generate token" } ),
+ else => return net.sendJson( r, .internal_server_error, ErrorRes{ .msg = "failed to update user" } )
+ }
+ }; defer alloc.free( token );
+
+ sendEmail( email, token ) catch {
+ return net.sendJson( r, .bad_request, ErrorRes{ .msg = "failed to send email" } );
+ };
+ net.sendJson( r, .ok, OkRes( .{ .msg = "email sent" } ) );
+}
+
+///returns generated token
+fn updateAuthTokens( user: *UserEntry, create_new: bool ) !?[]const u8 {
+ const tokens = user.tokens;
+ var list = ArrayList( []const u8 ).init( alloc );
+ defer list.deinit();
+ // should not be possible, if it happened we either had a messed up clock
+ // or the db was somehow invalid. fix it up.
+ if( user.token_resetdate > z.time.timestamp() ) {
+ user.token_resetdate = z.time.timestamp();
+ }
+
+ for( tokens ) |token| {
+ var t = u.verifyToken( AuthToken, token ) catch {
+ continue;
+ };
+ defer t.deinit();
+ list.append( token ) catch {};
+ }
+
+ var new_token: ?[]const u8 = null;
+ if( create_new ) {
+ new_token = u.generateAuthToken( user.uuid, user.email ) catch {
+ return error.TokenError;
+ };
+
+ list.append( new_token.? ) catch {};
+ user.tokens = list.items;
+ }
+ updateEntry( user.* ) catch return error.DatabaseError;
+ return new_token;
+}
+
+///route @/login
+fn login( r: Request ) void {
+ if( r.methodAsEnum() != .GET )
+ return net.sendJson( r, .bad_request, ErrorRes{ .msg = "method not allowed" } );
+ r.parseQuery();
+
+ u.checkDbOnThread() catch {
+ return net.sendJson( r, .internal_server_error, ErrorRes{ .msg = "internal server error" } );
+ };
+
+ var decoded = u.tokenFromGetReq( r ) catch return;
+ defer decoded.deinit();
+
+ var user = getEntry( decoded.claims.uuid ) catch {
+ return net.sendJson( r, .not_found, ErrorRes{ .msg = "user not found" } );
+ }; defer user.db_entry.?.deinit();
+
+ const token_str = r.getParamStr( alloc, "token", true ) catch return orelse return;
+ defer token_str.deinit();
+ if( !z.mem.eql( u8, token_str.str, user.login_token ) ) {
+ return net.sendJson( r, .unauthorized, ErrorRes{ .msg = "invalid token" } );
+ }
+ user.login_token = "";
+ checkSubscription( &user ) catch return net.sendJson( r, .internal_server_error, ErrorRes{ .msg = "subscription error" } );
+
+
+ const new_token = updateAuthTokens( &user, true ) catch {
+ return net.sendJson( r, .internal_server_error, ErrorRes{ .msg = "failed to update token" } );
+ }; defer alloc.free( new_token.? );
+
+ net.sendJson( r, .ok, OkRes( .{
+ .session = new_token.?,
+ .msg = "signed in"
+ } ) );
+}
+
+///route @/invalidate-session
+fn invalidateSession( r: Request ) void {
+ if( net.handleInvalidPostReq( r ) ) return;
+ u.checkDbOnThread() catch {
+ return net.sendJson( r, .internal_server_error, ErrorRes{ .msg = "internal server error" } );
+ };
+
+ var decoded = u.tokenFromPostReq( r ) catch return;
+ defer decoded.deinit();
+
+ var user = getEntry( decoded.claims.uuid ) catch {
+ return net.sendJson( r, .not_found, ErrorRes{ .msg = "user not found" } );
+ }; defer user.db_entry.?.deinit();
+
+ var found = false;
+ var list = ArrayList( []const u8 ).init( alloc );
+ defer list.deinit();
+ for( user.tokens ) |token| {
+ var t = u.verifyToken( AuthToken, token ) catch {
+ continue;
+ }; defer t.deinit();
+
+ if( t.claims.iat == decoded.claims.iat ) {
+ found = true;
+ continue;
+ }
+ list.append( token ) catch {};
+ }
+
+ user.tokens = list.items;
+ updateEntry( user ) catch |e| {
+ z.debug.print( "error updating user entry {s} {any} {any}\n", .{ user.uuid, e, @errorReturnTrace() } );
+ return net.sendJson( r, .internal_server_error, ErrorRes{ .msg = @errorName(e) } );
+ };
+
+ if( found ) {
+ net.sendJson( r, .ok, OkRes( .{ .msg = "session invalidated" } ) );
+ }
+ else {
+ net.sendJson( r, .bad_request, ErrorRes{ .msg = "token not found" } );
+ }
+}
+
+///route @/invalidate-all-sessions
+fn invalidateAllSessions( r: Request ) void {
+ if( net.handleInvalidPostReq( r ) ) return;
+ u.checkDbOnThread() catch {
+ return net.sendJson( r, .internal_server_error, ErrorRes{ .msg = "internal server error" } );
+ };
+
+ var decoded = u.tokenFromPostReq( r ) catch return;
+ defer decoded.deinit();
+
+ var user = getEntry( decoded.claims.uuid ) catch {
+ return net.sendJson( r, .not_found, ErrorRes{ .msg = "user not found" } );
+ }; defer user.db_entry.?.deinit();
+
+ user.token_resetdate = z.time.timestamp();
+ user.tokens = &[_][]const u8{};
+ updateEntry( user ) catch |e| {
+ z.debug.print( "error updating user entry {s} {any} {any}\n", .{ user.uuid, e, @errorReturnTrace() } );
+ return net.sendJson( r, .internal_server_error, ErrorRes{ .msg = @errorName(e) } );
+ };
+
+ net.sendJson( r, .ok, OkRes( .{ .msg = "sessions invalidated" } ) );
+}
+
+///returns only the api tokens
+///route @/get-tokens
+fn getTokens( r: Request ) void {
+ if( net.handleInvalidPostReq( r ) ) return;
+ u.checkDbOnThread() catch {
+ return net.sendJson( r, .internal_server_error, ErrorRes{ .msg = "internal server error" } );
+ };
+
+ var decoded = u.tokenFromPostReq( r ) catch return;
+ defer decoded.deinit();
+
+ var user = getEntry( decoded.claims.uuid ) catch {
+ return net.sendJson( r, .not_found, ErrorRes{ .msg = "user not found" } );
+ }; defer user.db_entry.?.deinit();
+
+ var arena = ArenaAllocator.init( alloc );
+ defer arena.deinit();
+ const a = arena.allocator();
+
+ var list = ArrayList( struct { id: u32, value: []const u8 } ).init( a );
+ var i: u32 = 0;
+ for( user.api_tokens ) |tok| {
+ var t = u.verifyToken( ApiToken, tok ) catch |e| {
+ z.debug.print( "token {s} invalid {any}\n", .{tok, e} );
+ continue;
+ }; defer t.deinit();
+
+ const slice = z.fmt.allocPrint( a, "...{s}", .{tok[tok.len-5..tok.len-1]} ) catch "";
+ list.append( .{
+ .id = i,
+ .value = slice,
+ } ) catch {};
+ i += 1;
+ }
+
+ return net.sendJson( r, .ok, OkRes( .{
+ .msg = "tokens retrieved",
+ .tokens = list.items,
+ } ) );
+}
+
+///creates a new api token, not an auth token
+///route @/create-token
+fn createToken( r: Request ) void {
+ if( net.handleInvalidPostReq( r ) ) return;
+ u.checkDbOnThread() catch {
+ return net.sendJson( r, .internal_server_error, ErrorRes{ .msg = "internal server error" } );
+ };
+
+ var decoded = u.tokenFromPostReq( r ) catch return;
+ defer decoded.deinit();
+
+ var user = getEntry( decoded.claims.uuid ) catch {
+ return net.sendJson( r, .not_found, ErrorRes{ .msg = "user not found" } );
+ }; defer user.db_entry.?.deinit();
+
+ var list = ArrayList( []const u8 ).init( alloc );
+ defer list.deinit();
+ for( user.api_tokens ) |tok| {
+ var t = u.verifyToken( ApiToken, tok ) catch {
+ continue;
+ };
+ list.append( tok ) catch {};
+ t.deinit();
+ }
+
+ const newtoken = u.generateApiToken( user.uuid ) catch |e| {
+ z.debug.print( "error generating api token for {s} {any} {any}\n", .{ user.uuid, e, @errorReturnTrace() } );
+ return net.sendJson( r, .internal_server_error, ErrorRes{ .msg = "error creating token" } );
+ }; defer alloc.free( newtoken );
+
+ list.append( newtoken ) catch {};
+ user.api_tokens = list.items;
+
+ updateEntry( user ) catch {
+ return net.sendJson( r, .internal_server_error, ErrorRes{ .msg = "internal server error" } );
+ };
+
+ net.sendJson( r, .ok, OkRes( .{ .msg = "token created", .token = newtoken } ) );
+}
+
+///deletes an api token, not an auth token
+///route @/delete-token
+fn deleteToken( r: Request ) void {
+ if( net.handleInvalidPostReq( r ) ) return;
+ u.checkDbOnThread() catch {
+ return net.sendJson( r, .internal_server_error, ErrorRes{ .msg = "internal server error" } );
+ };
+
+ const body = r.body.?;
+ const json = u.jsonParse( struct{ token: []const u8, id: u64 }, body ) catch |e| {
+ z.debug.print( "error parsing json {any} {any}\n", .{ e, @errorReturnTrace() } );
+ return net.sendJson( r, .bad_request, ErrorRes{ .msg = "invalid json" } );
+ };
+
+ var decoded = u.verifyToken( AuthToken, json.v.token ) catch {
+ return net.sendJson( r, .unauthorized, ErrorRes{ .msg = "invalid token" } );
+ }; defer decoded.deinit();
+
+ var user = getEntry( decoded.claims.uuid ) catch {
+ return net.sendJson( r, .not_found, ErrorRes{ .msg = "user not found" } );
+ }; defer user.db_entry.?.deinit();
+
+ const tokens = user.api_tokens;
+ var list = ArrayList( []const u8 ).init( alloc );
+ defer list.deinit();
+ for( tokens, 0.. ) |tok, i| {
+ if( i == json.v.id ) continue;
+ var t = u.verifyToken( ApiToken, tok ) catch {
+ continue;
+ };
+ list.append( tok ) catch {};
+ t.deinit();
+ }
+
+ user.api_tokens = list.items;
+ updateEntry( user ) catch |e| {
+ z.debug.print( "failed to update entry {s} {any} {any}", .{ user.uuid, e, @errorReturnTrace() } );
+ return net.sendJson( r, .internal_server_error, ErrorRes{ .msg = "failed to update user" } );
+ };
+
+ net.sendJson( r, .ok, OkRes( .{ .msg = "token deleted" } ) );
+}
+
+///wipes all api tokens, not auth tokens
+///route @/delete-tokens
+fn deleteTokens( r: Request ) void {
+ u.checkDbOnThread() catch {
+ return net.sendJson( r, .internal_server_error, ErrorRes{ .msg = "internal server error" } );
+ };
+ if( net.handleInvalidPostReq( r ) ) return;
+
+ var decoded = u.tokenFromPostReq( r ) catch return;
+ defer decoded.deinit();
+
+ var user = getEntry( decoded.claims.uuid ) catch {
+ return net.sendJson( r, .not_found, ErrorRes{ .msg = "user not found" } );
+ }; defer user.db_entry.?.deinit();
+
+ var empty_tokens = [_][]const u8{};
+ user.api_tokens = &empty_tokens;
+ updateEntry( user ) catch |e| {
+ z.debug.print( "failed to update user {s} {any} {any}", .{ user.uuid, e, @errorReturnTrace() } );
+ return net.sendJson( r, .internal_server_error, ErrorRes{ .msg = "failed to update user" } );
+ };
+
+ net.sendJson( r, .ok, OkRes( .{ .msg = "tokens deleted" } ) );
+}
diff --git a/backend/api/src/userdata.zig b/backend/api/src/userdata.zig
new file mode 100644
index 0000000..f16e126
--- /dev/null
+++ b/backend/api/src/userdata.zig
@@ -0,0 +1,568 @@
+const z = @import( "std" );
+const db = @import( "db.zig" );
+const zap = @import( "zap" );
+const req = @import( "req.zig" );
+const net = @import( "net-util.zig" );
+const model = @import( "model.zig" );
+
+const u = struct {
+ usingnamespace @import( "util.zig" );
+ usingnamespace @import( "userdefs.zig" );
+ usingnamespace @import( "user.zig" );
+};
+
+const alloc = u.alloc;
+
+const ArenaAllocator = z.heap.ArenaAllocator;
+const ArrayList = z.ArrayList;
+const UserEntry = u.UserEntry;
+const ErrorRes = u.ErrorRes;
+const OkRes = net.OkResponse;
+
+const bufprint = z.fmt.bufPrint;
+const memeql = z.mem.eql;
+
+pub const routes = .{
+ .@"settings" = settings,
+ .@"get-notes" = getNotes,
+ .@"getalldata" = getAllData,
+ .@"delete-note" = deleteNote,
+ .@"delete-notes" = deleteNotes,
+ .@"update-settings" = updateSettingsRoute
+};
+
+pub const ChatEntry = struct {
+ id: []const u8,
+ name: []const u8,
+};
+
+pub const ChatFiles = struct {
+ files: ?[]ChatEntry,
+};
+
+const PrefsDbEntry = struct {
+ uuid: []const u8,
+ nickname: []const u8,
+ prompt_data: []const u8,
+ site_prefs: []const u8,
+ chat_files: []const u8
+};
+
+const PrefsPromptData = struct {
+ system: ?[]const u8 = ""
+};
+
+const PrefsSiteData = struct {
+ font: ?[]const u8 = "Terminal",
+ model: ?[]const u8 = null
+};
+
+pub const Prefs = struct {
+ uuid: []const u8,
+ nickname: []const u8,
+ prompt_data: PrefsPromptData = .{},
+ site_prefs: PrefsSiteData = .{},
+ chat_files: ?ChatFiles = null,
+ db_entry: ?db.Row(PrefsDbEntry) = null,
+
+ pub const @"getty.sb" = struct {
+ pub const attributes = .{
+ .db_entry = .{ .skip = true },
+ };
+ };
+};
+
+const PrefsResponse = struct {
+ uuid: []const u8,
+ nickname: []const u8,
+ prompt_data: PrefsPromptData = .{},
+ site_prefs: PrefsSiteData = .{},
+ chat_files: ?ChatFiles = null,
+ plan: u.SubData,
+
+ pub fn fromPrefs( prefs: *const Prefs, user: *const u.UserEntry ) PrefsResponse {
+ return PrefsResponse {
+ .uuid = prefs.uuid,
+ .nickname = prefs.nickname,
+ .prompt_data = prefs.prompt_data,
+ .site_prefs = prefs.site_prefs,
+ .chat_files = prefs.chat_files,
+ .plan = user.subscription_data
+ };
+ }
+};
+
+const NoteEntry = struct {
+ content: []const u8,
+ id: []const u8
+};
+
+const NoteTargetReq = struct {
+ noteId: []const u8,
+
+ pub const @"getty.db" = u.@"json.ignore.unknown";
+};
+
+/// creates directories for notes and chats
+pub fn createDirectories() void {
+ var pathbuf: [256]u8 = undefined;
+ const notes_path = bufprint( &pathbuf, "../data/notes", .{} ) catch unreachable;
+ var notes_dir = z.fs.cwd().openDir( notes_path, .{} ) catch r: {
+ z.fs.cwd().makeDir( notes_path ) catch {};
+ break :r null;
+ }; if( notes_dir ) |*c| c.close();
+
+ const chats_path = bufprint( &pathbuf, "../data/chats", .{} ) catch unreachable;
+ var chats_dir = z.fs.cwd().openDir( chats_path, .{} ) catch r: {
+ z.fs.cwd().makeDir( chats_path ) catch {};
+ break :r null;
+ }; if( chats_dir ) |*c| c.close();
+}
+
+pub const UpdateReqParams = struct {
+ pub const @"getty.db" = u.@"json.ignore.unknown";
+
+ prefs: struct {
+ prompt_data: ?PrefsPromptData = null,
+ site_prefs: ?PrefsSiteData = null,
+ nickname: ?[]const u8 = null,
+
+ pub const @"getty.db" = u.@"json.ignore.unknown";
+ } = .{},
+
+ pub fn isValid( self: @This(), user: *const UserEntry ) !bool {
+ if( self.prefs.prompt_data ) |p| {
+ if( p.system ) |s| {
+ if( !u.Validator.isValidStrSystem( s ) )
+ return error.InvalidSystemPrompt;
+ }
+ }
+ if( self.prefs.nickname ) |name| {
+ if( !u.Validator.isValidStrNickname( name ) )
+ return error.InvalidNickname;
+ }
+ if( self.prefs.site_prefs ) |p| {
+ if( p.font ) |f| if( !u.Validator.isValidStrFont( f ) ) return error.InvalidSiteFont;
+ if( p.model ) |m| {
+ if( !u.Validator.isValidStrFont( m ) ) return error.InvalidModel; // todo later: check if model exists within modelmap
+
+ const models = model.loadModels() catch return error.ModelMapError;
+ for( models.v ) |mod| {
+ if( memeql( u8, m, mod.name ) ) {
+ return model.canBeUsedByUser( mod, user );
+ }
+ }
+
+ return error.InvalidModel;
+ }
+ }
+
+ return true;
+ }
+};
+
+fn dbToPrefs( row: *db.Row(PrefsDbEntry) ) !Prefs {
+ const a = row.alloc.allocator();
+ const prompt = try u.jsonParseAlloc( PrefsPromptData, row.v.prompt_data, a );
+ const site = try u.jsonParseAlloc( PrefsSiteData, row.v.site_prefs, a );
+ const chat = try u.jsonParseAlloc( ChatFiles, row.v.chat_files, a );
+
+ return Prefs{
+ .uuid = row.v.uuid,
+ .nickname = row.v.nickname,
+ .prompt_data = prompt.v,
+ .site_prefs = site.v,
+ .chat_files = chat.v,
+ .db_entry = row.*
+ };
+}
+
+fn prefsToDb( prefs: Prefs, arena: *ArenaAllocator ) !PrefsDbEntry {
+ const a = arena.allocator();
+ const prompt = try u.jsonStringifyAlloc( prefs.prompt_data, a );
+ const site = try u.jsonStringifyAlloc( prefs.site_prefs, a );
+ const chat = try u.jsonStringifyAlloc( prefs.chat_files, a );
+
+ return PrefsDbEntry {
+ .uuid = prefs.uuid,
+ .nickname = prefs.nickname,
+ .prompt_data = prompt,
+ .site_prefs = site,
+ .chat_files = chat
+ };
+}
+
+pub fn getSettings( uuid: []const u8 ) !Prefs {
+ const query = "SELECT * FROM user_data WHERE uuid = ?";
+ var res = try u.dbi.oneAlloc( PrefsDbEntry, query, .{ .uuid = uuid } );
+ return dbToPrefs( &res );
+}
+
+fn getSettingsDbEntry( uuid: []const u8 ) !db.Row(PrefsDbEntry) {
+ const query = "SELECT * FROM user_data WHERE uuid = ?";
+ const res = try u.dbi.oneAlloc( PrefsDbEntry, query, .{ .uuid = uuid } );
+ return res;
+}
+
+fn createNewSettings( name: []const u8, uuid: []const u8 ) !Prefs {
+ var arena = z.heap.ArenaAllocator.init( alloc );
+ const a = arena.allocator();
+ defer arena.deinit();
+
+
+ const models = model.loadModels() catch null;
+ var modelname: []const u8 = "qwen2.5:1.5b";
+ if( models ) |mods| {
+ for( mods.v ) |mod| {
+ if( mod.free == 1 ) {
+ modelname = a.dupe( u8, mod.name ) catch "";
+ break;
+ }
+ }
+ }
+
+ const prefs = Prefs {
+ .uuid = uuid,
+ .nickname = name,
+ .site_prefs = .{ .font = "Terminal", .model = modelname },
+ .prompt_data = .{ .system = "" },
+ .chat_files = .{ .files = &[_]ChatEntry{} }
+ };
+
+ const converted = try prefsToDb( prefs, &arena );
+ const query = "INSERT INTO user_data (uuid, nickname, site_prefs, prompt_data, chat_files) VALUES (?, ?, ?, ?, ?)";
+ try u.dbi.put( query, .{
+ .uuid = converted.uuid,
+ .nickname = converted.nickname,
+ .site_prefs = converted.site_prefs,
+ .prompt_data = converted.prompt_data,
+ .chat_files = converted.chat_files
+ } );
+
+ // return the actual entry - if it fails then we panic
+ return getSettings( uuid );
+}
+
+fn updateSettingsFromParams( oldprefs: *Prefs, newprefs: *UpdateReqParams, user: *const UserEntry ) !void {
+ _ = try newprefs.isValid( user );
+
+ if( newprefs.prefs.site_prefs ) |site| {
+ if( site.model ) |m| oldprefs.site_prefs.model = m;
+ if( site.font ) |f| oldprefs.site_prefs.font = f;
+ }
+ if( newprefs.prefs.nickname ) |nickname| {
+ oldprefs.nickname = nickname;
+ }
+ if( newprefs.prefs.prompt_data ) |prompt_data| {
+ oldprefs.prompt_data = prompt_data;
+ }
+
+ var arena = z.heap.ArenaAllocator.init( alloc );
+ defer arena.deinit();
+
+ const converted = prefsToDb( oldprefs.*, &arena ) catch return error.JsonParseError;
+ const query = "UPDATE user_data SET site_prefs = ?, nickname = ?, prompt_data = ? WHERE uuid = ?";
+
+ return u.dbi.put( query, .{
+ .site_prefs = converted.site_prefs,
+ .nickname = converted.nickname,
+ .prompt_data = converted.prompt_data,
+ .uuid = converted.uuid,
+ } );
+}
+
+pub fn hasChat( prefs: *Prefs, id: []const u8 ) bool {
+ if( prefs.chat_files == null )
+ return false;
+
+ if( prefs.chat_files.?.files == null )
+ return false;
+
+ const files = prefs.chat_files.?.files.?;
+ for( files ) |file| {
+ if( memeql( u8, file.id, id ) )
+ return true;
+ }
+
+ return false;
+}
+
+pub fn updateSettings( newprefs: *Prefs ) !void {
+ try u.checkDbOnThread();
+
+ var arena = ArenaAllocator.init( alloc );
+ const a = arena.allocator();
+ defer arena.deinit();
+
+ const prefs = u.jsonStringifyAlloc( newprefs.site_prefs, a ) catch return error.InvalidSitePrefs;
+ const prompt_data = u.jsonStringifyAlloc( newprefs.prompt_data, a ) catch return error.InvalidPromptData;
+ const chat_files = u.jsonStringifyAlloc( newprefs.chat_files, a ) catch return error.InvalidChatFiles;
+
+ const nickname = newprefs.nickname;
+ const uuid = newprefs.uuid;
+
+ const query = "UPDATE user_data SET site_prefs = ?, nickname = ?, prompt_data = ?, chat_files = ? WHERE uuid = ?";
+ return u.dbi.put( query, .{
+ .site_prefs = prefs,
+ .nickname = nickname,
+ .prompt_data = prompt_data,
+ .chat_files = chat_files,
+ .uuid = uuid,
+ } );
+}
+
+///route @/settings
+fn settings( r: zap.Request ) void {
+ u.checkDbOnThread() catch {
+ return net.sendJson( r, .internal_server_error, ErrorRes{ .msg = "internal server error" } );
+ };
+ if( net.handleInvalidPostReq( r ) ) return;
+
+ var decoded = u.apiTokenFromPostReq( r ) catch return;
+ defer decoded.deinit();
+
+ var user = u.getEntry( decoded.claims.uuid ) catch |e| {
+ switch( e ) {
+ error.QueryError => return net.sendJson( r, .not_found, ErrorRes{ .msg = "user not found" } ),
+ else => return net.sendJson( r, .internal_server_error, ErrorRes{ .msg = "internal_server_error" } )
+ }
+ }; defer user.db_entry.?.deinit();
+
+ var entry = getSettings( decoded.claims.uuid ) catch |e| {
+ switch( e ) {
+ error.QueryError => return net.sendJson( r, .not_found, ErrorRes{ .status = "nodata", .msg = "no user data found. is this account not activated?" } ),
+ else => {
+ z.debug.print( "error getting settings: {s} {any} {any}\n", .{ decoded.claims.uuid, e, @errorReturnTrace() } );
+ return net.sendJson( r, .internal_server_error, ErrorRes{ .msg = "internal server error" } );
+ }
+ }
+ }; defer entry.db_entry.?.deinit();
+
+ return net.sendJson( r, .ok, OkRes( .{ .userprefs = PrefsResponse.fromPrefs( &entry, &user ) } ) );
+}
+
+///route @/update-settings
+fn updateSettingsRoute( r: zap.Request ) void {
+ u.checkDbOnThread() catch {
+ return net.sendJson( r, .internal_server_error, ErrorRes{ .msg = "internal server error" } );
+ };
+ if( net.handleInvalidPostReq( r ) ) return;
+
+ var decoded = u.tokenFromPostReq( r ) catch return;
+ defer decoded.deinit();
+
+ var user = u.getEntry( decoded.claims.uuid ) catch {
+ return net.sendJson( r, .not_found, ErrorRes{ .msg = "user not found" } );
+ }; defer user.db_entry.?.deinit();
+
+ var entry = getSettings( user.uuid ) catch |e| r: {
+ switch( e ) {
+ error.QueryError => break :r createNewSettings( "user", user.uuid ) catch {
+ z.debug.print( "error querying settings: {s} {any}", .{ user.uuid, @errorReturnTrace() } );
+ return net.sendJson( r, .internal_server_error, ErrorRes{ .msg = "internal server error" } );
+ },
+ else => return net.sendJson( r, .internal_server_error, ErrorRes{ .msg = "internal server error" } )
+ }
+ }; defer { if( entry.db_entry ) |e| e.deinit(); }
+
+ var params = u.jsonParse( UpdateReqParams, r.body.? ) catch {
+ return net.sendJson( r, .bad_request, ErrorRes{ .msg = "invalid request body" } );
+ }; defer params.deinit();
+
+ updateSettingsFromParams( &entry, &params.v, &user ) catch |e| {
+ switch( e ) {
+ error.JsonParseError => return net.sendJson( r, .bad_request, ErrorRes{ .msg = "invalid request body" } ),
+ error.InvalidNickname => return net.sendJson( r, .bad_request, ErrorRes{ .msg = "invalid field: nickname" } ),
+ error.InvalidSiteFont => return net.sendJson( r, .bad_request, ErrorRes{ .msg = "invalid field: site_font" } ),
+ error.InvalidSystemPrompt => return net.sendJson( r, .bad_request, ErrorRes{ .msg = "invalid field: system_prompt" } ),
+ error.InvalidModel => return net.sendJson( r, .bad_request, ErrorRes{ .msg = "invalid field: model" } ),
+ else => {
+ z.debug.print( "error updating settings: {s} {any}", .{ entry.uuid, @errorReturnTrace() } );
+ return net.sendJson( r, .internal_server_error, ErrorRes{.msg = "internal server error" } );
+ }
+ }
+ };
+
+ return net.sendJson( r, .ok, OkRes( .{
+ .msg = "settings updated successfully",
+ .userprefs = PrefsResponse.fromPrefs( &entry, &user )
+ } ) );
+}
+
+fn readNotesFile( uuid: []const u8 ) !u.JsonResponse([]NoteEntry) {
+ var buf: [256]u8 = undefined;
+ const path = z.fmt.bufPrint( &buf, "../data/notes/{s}-notes.json", .{ uuid } ) catch "";
+
+ const contents = u.readFileCrypto( path ) catch {
+ return error.IOError;
+ }; defer alloc.free( contents );
+
+ const json = u.jsonParse( []NoteEntry, contents ) catch {
+ return error.ParseError;
+ };
+
+ return json;
+}
+
+fn saveNotesFile( uuid: []const u8, notes: []NoteEntry ) !void {
+ var buf: [256]u8 = undefined;
+ const path = z.fmt.bufPrint( &buf, "../data/notes/{s}-notes.json", .{ uuid } ) catch "";
+
+ const contents = u.jsonStringify( notes ) catch {
+ return error.EncodeError;
+ }; defer alloc.free( contents );
+
+ const stdout = u.writeFileCrypto( path, contents ) catch |e| {
+ z.debug.print( "Failed to write file: {any}", .{ e } );
+ return error.IOError;
+ };
+
+ alloc.free( stdout );
+}
+
+///route @/get-notes
+fn getNotes( r: zap.Request ) void {
+ u.checkDbOnThread() catch {
+ return net.sendJson( r, .internal_server_error, ErrorRes{ .msg = "internal server error" } );
+ };
+ if( net.handleInvalidPostReq( r ) ) return;
+
+ const uuid = u.uuidFromApiOrAuthToken( r ) catch return;
+ defer alloc.free( uuid );
+
+ const notes = readNotesFile( uuid ) catch |e| {
+ switch( e ) {
+ error.IOError => return net.sendJson( r, .ok, OkRes( .{ .notes = [_]NoteEntry{} } ) ),
+ error.ParseError => return net.sendJson( r, .internal_server_error, ErrorRes{ .msg = "error parsing notes file" } )
+ }
+ }; defer notes.deinit();
+
+ return net.sendJson( r, .ok, OkRes( .{ .notes = notes.v } ) );
+}
+
+///route @/delete-note
+fn deleteNote( r: zap.Request ) void {
+ u.checkDbOnThread() catch {
+ return net.sendJson( r, .internal_server_error, ErrorRes{ .msg = "internal server error" } );
+ };
+ if( net.handleInvalidPostReq( r ) ) return;
+
+ const uuid = u.uuidFromApiOrAuthToken( r ) catch return;
+ defer alloc.free( uuid );
+
+ const params = u.jsonParse( NoteTargetReq, r.body.? ) catch {
+ z.debug.print( "{s}\n", .{ r.body.? } );
+ return net.sendJson( r, .bad_request, ErrorRes{ .msg = "invalid request format" } );
+ }; defer params.deinit();
+
+ const notes = readNotesFile( uuid ) catch |e| {
+ switch( e ) {
+ error.IOError => return net.sendJson( r, .not_found, ErrorRes{ .msg = "note not found" } ),
+ error.ParseError => return net.sendJson( r, .internal_server_error, ErrorRes{ .msg = "error parsing notes file" } )
+ }
+ }; defer notes.deinit();
+
+ var list = ArrayList( NoteEntry ).init( alloc );
+ defer list.deinit();
+ for( notes.v ) |note| {
+ if( !memeql( u8, note.id, params.v.noteId ) )
+ list.append( note ) catch {};
+ }
+
+ saveNotesFile( uuid, list.items ) catch {
+ return net.sendJson( r, .internal_server_error, ErrorRes{ .msg = "error saving notes file" } );
+ };
+
+ return net.sendJson( r, .ok, OkRes( .{ .msg = "note deleted" } ) );
+}
+
+///route @/delete-notes
+fn deleteNotes( r: zap.Request ) void {
+ u.checkDbOnThread() catch {
+ return net.sendJson( r, .internal_server_error, ErrorRes{ .msg = "internal server error" } );
+ };
+ if( net.handleInvalidPostReq( r ) ) return;
+
+ const uuid = u.uuidFromApiOrAuthToken( r ) catch return;
+ defer alloc.free( uuid );
+
+ var buf: [256]u8 = undefined;
+ const path = z.fmt.bufPrint( &buf, "../data/notes/{s}-notes.json", .{ uuid } ) catch "";
+
+ z.fs.cwd().deleteFile( path ) catch |e| {
+ switch ( e ) {
+ error.FileNotFound => return net.sendJson( r, .not_found, ErrorRes{ .msg = "no notes found" } ),
+ else => return net.sendJson( r, .internal_server_error, ErrorRes{ .msg = "internal server error" } ),
+ }
+ };
+
+ net.sendJson( r, .ok, OkRes( .{ .msg = "notes deleted" } ) );
+}
+
+fn aggregateUserData( uuid: []const u8 ) ![]const u8 {
+ const userdata = try getSettings( uuid );
+ defer userdata.db_entry.?.deinit();
+
+ const json = try u.jsonStringify( userdata );
+ defer alloc.free( json );
+
+ const wrapped_json = try u.jsonStringify( json );
+ defer alloc.free( wrapped_json );
+
+ const proc = try z.process.Child.run( .{
+ .allocator = alloc,
+ .argv = &.{ "node", "../data-aggregate.cjs", wrapped_json }
+ } );
+ defer alloc.free( proc.stdout );
+ defer alloc.free( proc.stderr );
+
+ while( true ) {
+ if( proc.term == .Exited )
+ break;
+ z.Thread.yield() catch {};
+ }
+
+ if( proc.stderr.len > 0 )
+ z.debug.print( "{s}\n", .{ proc.stderr } );
+ if( proc.stdout.len > 0 and proc.stdout[0] == '0' ) {
+ z.debug.print( "{s}\n", .{ proc.stdout } );
+ return error.ReadError;
+ }
+
+ const copy = alloc.dupe( u8, proc.stdout );
+ return copy;
+}
+
+///route @/getalldata
+fn getAllData( r: zap.Request ) void {
+ if( net.handleInvalidPostReq( r ) ) return;
+ u.checkDbOnThread() catch return;
+
+ var token = u.tokenFromPostReq( r ) catch return;
+ defer token.deinit();
+ const user = u.getEntry( token.claims.uuid ) catch {
+ return net.sendJson( r, .unauthorized, ErrorRes{ .msg = "user not found" } );
+ };
+ defer user.db_entry.?.deinit();
+
+ const data = aggregateUserData( user.uuid ) catch |e| {
+ z.debug.print( "error aggregating data: {any} {any}", .{ e, @errorReturnTrace() } );
+ return net.sendJson( r, .internal_server_error, ErrorRes{ .msg = "internal server error" } );
+ };
+ defer alloc.free( data );
+
+ var path_buf: [256]u8 = undefined;
+ const path = z.fmt.bufPrint( &path_buf, "/tmp/{s}.zip", .{ user.uuid } ) catch {
+ return net.sendJson( r, .internal_server_error, ErrorRes{ .msg = "internal server error" } );
+ };
+
+ u.writeFile( path, data ) catch |e| {
+ z.debug.print( "error writing file: {any} {any}", .{ e, @errorReturnTrace() } );
+ return net.sendJson( r, .internal_server_error, ErrorRes{ .msg = "internal server error" } );
+ };
+ r.setHeader( "Content-Type", "application/zip" ) catch {};
+ r.setHeader( "Content-Disposition", "attachment; filename=userdata.zip" ) catch {};
+ r.sendFile( path ) catch |e| {
+ z.debug.print( "error sending file: {any} {any}", .{ e, @errorReturnTrace() } );
+ };
+}
diff --git a/backend/api/src/userdefs.zig b/backend/api/src/userdefs.zig
new file mode 100644
index 0000000..e43b680
--- /dev/null
+++ b/backend/api/src/userdefs.zig
@@ -0,0 +1,322 @@
+const z = @import( "std" );
+const u = struct {
+ usingnamespace @import( "util.zig" );
+ usingnamespace @import( "user.zig" );
+};
+const db = @import( "db.zig" );
+const net = @import( "net-util.zig" );
+const zap = @import( "zap" );
+const jwt = @import( "jwt" );
+
+const alloc = u.alloc;
+const memeql = z.mem.eql;
+
+pub const ArenaAllocator = z.heap.ArenaAllocator;
+pub const ErrorRes = net.ErrorResponse;
+pub const Status = zap.StatusCode;
+pub const ArrayList = z.ArrayList;
+pub const Request = zap.Request;
+pub const JWT = jwt.JWT;
+
+pub threadlocal var dbi: *db.Interface = undefined;
+
+pub const LoginToken = struct {
+ uuid: []const u8,
+ exp: i64,
+
+ pub const @"getty.db" = u.@"json.ignore.unknown";
+};
+
+pub const AuthToken = struct {
+ uuid: []const u8,
+ email: []const u8,
+ exp: i64,
+ iat: i64,
+
+ pub const @"getty.db" = u.@"json.ignore.unknown";
+};
+
+pub const ApiToken = struct {
+ uuid: []const u8,
+ exp: i64,
+ iat: i64,
+
+ pub const @"getty.db" = u.@"json.ignore.unknown";
+};
+
+pub const SubData = struct {
+ endTime: i64,
+ plan: []const u8,
+ reminded: ?bool = false
+};
+
+///layout must follow database structure
+pub fn UserEntryTemplate( comptime token_type: type, comptime sub_type: type ) type {
+ return struct {
+ uuid: []const u8,
+ email: []const u8,
+ tokens: token_type,
+ login_token: []const u8,
+ token_resetdate: i64,
+ api_tokens: token_type,
+ created_at: i64,
+ subscription_data: sub_type
+ };
+}
+
+pub const UserDbEntry = UserEntryTemplate( []const u8, []const u8 );
+pub const UserEntry = u.MergeTypes(
+ UserEntryTemplate( [][]const u8, SubData ),
+ struct { db_entry: ?db.Row(UserDbEntry) = null }
+);
+
+///row should not create any new allocations after this call
+pub fn dbToUser( row: *db.Row(UserDbEntry) ) !UserEntry {
+ const a = row.alloc.allocator();
+ const tokens = try u.jsonParseAlloc( [][]const u8, row.v.tokens, a );
+ const api_tokens = try u.jsonParseAlloc( [][]const u8, row.v.api_tokens, a );
+ const sub_data = try u.jsonParseAlloc( SubData, row.v.subscription_data, a );
+
+ var ret: UserEntry = undefined;
+ const ret_slice= @as( *[ @sizeOf(@TypeOf(row.v)) ]u8, @ptrCast( &ret ) );
+ const row_slice= @as( *const [ @sizeOf(@TypeOf(row.v)) ]u8, @ptrCast( &row.v ) );
+ @memcpy( ret_slice, row_slice );
+
+ ret.tokens = tokens.v;
+ ret.api_tokens = api_tokens.v;
+ ret.subscription_data = sub_data.v;
+ ret.db_entry = row.*;
+ return ret;
+}
+
+pub fn userToDb( entry: UserEntry, arena: *ArenaAllocator ) !UserDbEntry {
+ const a = arena.allocator();
+
+ const tokens = try u.jsonStringifyAlloc( entry.tokens, a );
+ const api_tokens = try u.jsonStringifyAlloc( entry.api_tokens, a );
+ const sub_data = try u.jsonStringifyAlloc( entry.subscription_data, a );
+
+ var ret: UserDbEntry = undefined;
+ const ret_slice = @as( *[ @sizeOf(@TypeOf(ret)) ]u8, @ptrCast( &ret ) );
+ const entry_slice = @as( *const[ @sizeOf(@TypeOf(ret)) ]u8, @ptrCast( &entry ) );
+ @memcpy( ret_slice, entry_slice );
+
+ ret.tokens = tokens;
+ ret.api_tokens = api_tokens;
+ ret.subscription_data = sub_data;
+ return ret;
+}
+
+pub fn entryToRow( entry: UserDbEntry, arena: *ArenaAllocator ) !db.Row( UserDbEntry ) {
+ var row: db.Row( UserDbEntry ) = undefined;
+ row.alloc = arena.*;
+ row.v = entry;
+
+ const a = arena.allocator();
+
+ row.v.uuid = try a.dupe( u8, entry.uuid );
+ row.v.email = try a.dupe( u8, entry.email );
+ row.v.tokens = try a.dupe( u8, entry.tokens );
+ row.v.api_tokens = try a.dupe( u8, entry.api_tokens );
+ row.v.login_token = try a.dupe( u8, entry.login_token );
+ row.v.subscription_data = try a.dupe( u8, entry.subscription_data );
+
+ return row;
+}
+
+///freed by caller
+pub fn generateApiToken( uuid: []const u8 ) ![]const u8 {
+ return net.encodeJWT( ApiToken{
+ .uuid = uuid,
+ .exp = z.time.timestamp() + 10 * 365 * 24 * 60 * 60, // 10 years
+ .iat = z.time.timestamp(),
+ } );
+}
+
+///freed by caller
+pub fn generateLoginToken( uuid: []const u8 ) ![]const u8 {
+ return net.encodeJWT( LoginToken{
+ .uuid = uuid,
+ .exp = z.time.timestamp() + 120 * 60, // 2 hours
+ } );
+}
+
+///freed by caller
+pub fn generateAuthToken( uuid: []const u8, email: []const u8 ) ![]const u8 {
+ return net.encodeJWT( AuthToken{
+ .uuid = uuid,
+ .email = email,
+ .exp = z.time.timestamp() + 30 * 24 * 60 * 60, // 30 days
+ .iat = z.time.timestamp(),
+ } );
+}
+
+///returns parsed token
+pub fn verifyToken( comptime t: type, token: []const u8 ) !JWT(t) {
+ comptime if( !@hasField( t, "uuid" ) )
+ return error.InvalidTokenType;
+
+ var decoded = try net.parseJWT( t, token );
+ errdefer decoded.deinit();
+ const uuid = decoded.claims.uuid;
+
+ if( @hasField( t, "exp" ) ) {
+ const exp = @field( decoded.claims, "exp" );
+ if( exp < z.time.timestamp() )
+ return error.TokenExpired;
+ }
+ if( @hasField( t, "iat" ) ) { //sqlite is so fast that doing this probably wont matter much
+ const iat = decoded.claims.iat;
+ const user = u.getEntry( uuid ) catch {
+ return error.InvalidToken;
+ }; defer user.db_entry.?.deinit();
+
+ if( iat < user.token_resetdate )
+ return error.TokenExpired;
+
+ for( user.tokens ) |tok| {
+ if( memeql( u8, token, tok ) )
+ return decoded;
+ }
+
+ for( user.api_tokens ) |tok| {
+ if( memeql( u8, token, tok ) )
+ return decoded;
+ }
+
+ return error.InvalidToken;
+ }
+
+ return decoded;
+}
+
+///sends an invalid response if the token is invalid
+pub fn tokenFromGetReq( r: Request ) !JWT(LoginToken) {
+ var token = r.getParamStr( alloc, "token", false ) catch {
+ net.sendJson( r, .bad_request, ErrorRes{ .msg = "token invalid or expired" } );
+ return error.MissingToken;
+ } orelse {
+ net.sendJson( r, .bad_request, ErrorRes{ .msg = "token missing" } );
+ return error.MissingToken;
+ }; defer token.deinit();
+
+ const decoded = verifyToken( LoginToken, token.str ) catch {
+ net.sendJson( r, .bad_request, ErrorRes{ .msg = "token invalid or expired" } );
+ return error.TokenExpired;
+ };
+
+ return decoded;
+}
+
+///sends an invalid response if the token is invalid
+pub fn tokenFromPostReq( r: Request ) !JWT(AuthToken) {
+ if( r.body == null ) {
+ net.sendJson( r, .bad_request, ErrorRes{ .msg = "body missing" } );
+ return error.MissingToken;
+ }
+
+ const json = u.jsonParse( struct{
+ token: []const u8,
+ pub const @"getty.db" = u.@"json.ignore.unknown";
+ }, r.body.? ) catch |e| {
+ net.sendJson( r, .bad_request, ErrorRes{ .msg = "invalid json" } );
+ return e;
+ }; defer json.deinit();
+
+ const token = json.v.token;
+ const decoded = verifyToken( AuthToken, token ) catch {
+ net.sendJson( r, .unauthorized, ErrorRes{ .msg = "invalid token" } );
+ return error.Invalidtoken;
+ };
+
+ return decoded;
+}
+
+///sends an invalid response if the token is invalid
+pub fn apiTokenFromGetReq( r: Request ) !JWT(ApiToken) {
+ var token = r.getParamStr( alloc, "token", false ) catch {
+ net.sendJson( r, .bad_request, ErrorRes{ .msg = "token invalid or expired" } );
+ return error.MissingToken;
+ } orelse {
+ net.sendJson( r, .bad_request, ErrorRes{ .msg = "token missing" } );
+ return error.MissingToken;
+ }; defer token.deinit();
+
+ const decoded = verifyToken( ApiToken, token.str ) catch {
+ net.sendJson( r, .bad_request, ErrorRes{ .msg = "token invalid or expired" } );
+ return error.TokenExpired;
+ };
+
+ return decoded;
+}
+
+///sends an invalid response if the token is invalid
+pub fn apiTokenFromPostReq( r: Request ) !JWT(ApiToken) {
+ if( r.body == null ) {
+ net.sendJson( r, .bad_request, ErrorRes{ .msg = "body missing" } );
+ return error.MissingToken;
+ }
+
+ const json = u.jsonParse( struct{
+ token: []const u8,
+ pub const @"getty.db" = u.@"json.ignore.unknown";
+ }, r.body.? ) catch |e| {
+ net.sendJson( r, .bad_request, ErrorRes{ .msg = "invalid json" } );
+ return e;
+ }; defer json.deinit();
+
+ const token = json.v.token;
+ const decoded = verifyToken( ApiToken, token ) catch {
+ net.sendJson( r, .unauthorized, ErrorRes{ .msg = "invalid token" } );
+ return error.Invalidtoken;
+ };
+
+ return decoded;
+}
+
+///returns the uuid from either the auth token or api token, depending on provided.
+///for use with api-accessible routes e.g. chat, generate.
+///returned string must be freed by caller
+///sends an invalid response if the token is invalid
+pub fn uuidFromApiOrAuthToken( r: Request ) ![]const u8 {
+ if( r.body == null ) {
+ net.sendJson( r, .bad_request, ErrorRes{ .msg = "body missing" } );
+ return error.InvalidToken;
+ }
+
+ const TokenRes = struct {
+ token: []const u8,
+ pub const @"getty.db" = u.@"json.ignore.unknown";
+ };
+
+ const auth_token: ?u.JsonResponse(TokenRes) = u.jsonParse( TokenRes, r.body.? ) catch |e| br: {
+ z.debug.print( "error parsing auth token: {s} {any} {any}\n", .{ r.body.?, e, @errorReturnTrace() } );
+ break :br null;
+ };
+ if( auth_token ) |token| {
+ defer token.deinit();
+ var decoded: ?JWT(ApiToken) = verifyToken( ApiToken, token.v.token ) catch |e| br: {
+ z.debug.print( "error verifying auth token: {s} {any} {any}\n", .{ r.body.?, e, @errorReturnTrace() } );
+ break :br null;
+ };
+ if( decoded ) |*d| { // da d
+ defer d.deinit();
+ return alloc.dupe( u8, d.claims.uuid ) catch return "";
+ }
+ }
+
+ net.sendJson( r, .unauthorized, ErrorRes{ .msg = "token missing or expired" } );
+ return error.InvalidToken;
+}
+
+///checks if the db is initialized on current thread
+///if not initializes it
+pub fn checkDbOnThread() !void {
+ db.init( "../data/users.sqlite" ) catch |e| {
+ if( e == error.DbiAlreadyInitialized )
+ return;
+ return e;
+ };
+
+ dbi = &db.dbi;
+}
diff --git a/backend/api/src/util.zig b/backend/api/src/util.zig
new file mode 100644
index 0000000..19fa456
--- /dev/null
+++ b/backend/api/src/util.zig
@@ -0,0 +1,271 @@
+const z = @import( "std" );
+const zap = @import( "zap" );
+const net = @import( "net-util.zig" );
+const json = @import( "json" );
+const getty = @import( "getty" );
+
+const ArenaAllocator = z.heap.ArenaAllocator;
+const GettyResult = getty.de.Result;
+pub var gpa = z.heap.GeneralPurposeAllocator( .{ .thread_safe = true, .stack_trace_frames = 64 } ){};
+pub const alloc = gpa.allocator();
+
+const Status = zap.StatusCode;
+const Request = zap.Request;
+
+const pbkdf2 = z.crypto.pwhash.pbkdf2;
+
+const aes256Encrypt = z.crypto.aead.aes_gcm.Aes256Gcm.encrypt;
+const aes256Decrypt = z.crypto.aead.aes_gcm.Aes256Gcm.decrypt;
+
+pub const @"json.ignore.unknown" = struct {
+ pub const attributes = .{
+ .Container = .{ .ignore_unknown_fields = true },
+ };
+};
+
+pub fn MergeTypes( comptime t1: type, comptime t2: type ) type {
+ const t1info = @typeInfo( t1 ).Struct;
+ const t2info = @typeInfo( t2 ).Struct;
+
+ comptime if( t1info.is_tuple ) return t2;
+ comptime if( t2info.is_tuple ) return t1;
+
+ var t3info = t1info;
+ t3info.fields = t1info.fields ++ t2info.fields;
+
+ var t1s = @typeInfo( t1 );
+ t1s.Struct = t3info;
+
+ return @Type( t1s );
+}
+
+pub fn JsonResponse( comptime t: type ) type {
+ return struct {
+ v: t,
+ @"#alloc": ArenaAllocator,
+ pub fn deinit( self: @This() ) void {
+ self.@"#alloc".deinit();
+ }
+ };
+}
+
+///freed by caller
+pub fn jsonParse( comptime t: type, str: []const u8 ) !JsonResponse(t) {
+ return jsonParseAlloc( t, str, alloc );
+}
+
+///freed by caller
+pub fn jsonParseAlloc( comptime t: type, str: []const u8, a: z.mem.Allocator ) !JsonResponse(t) {
+ var arena = ArenaAllocator.init( a );
+ const allocator = arena.allocator();
+
+ const ret = json.fromSlice( allocator, t, str ) catch |e| {
+ arena.deinit();
+ return e;
+ };
+ var obj: JsonResponse(t) = undefined;
+ obj.v = ret.value;
+ obj.@"#alloc" = arena;
+
+ return obj;
+}
+
+///has to be freed using page_allocator
+pub fn jsonStringify( obj: anytype ) ![]const u8 {
+ var arr = z.ArrayList( u8 ).init( alloc );
+ try json.toWriter( alloc, obj, arr.writer() );
+ return arr.toOwnedSlice();
+}
+
+pub fn jsonStringifyAlloc( obj: anytype, a: z.mem.Allocator ) ![]const u8 {
+ var arr = z.ArrayList( u8 ).init( a );
+ try json.toWriter( alloc, obj, arr.writer() );
+ return arr.toOwnedSlice();
+}
+
+///has to be freed by caller with page_allocator
+///10mb max
+pub fn readFile( path: []const u8 ) ![]const u8 {
+ return readFileAlloc( path, alloc );
+}
+
+pub fn readFileAlloc( path: []const u8, a: z.mem.Allocator ) ![]const u8 {
+ const file = try z.fs.cwd().openFile( path, .{} );
+ defer file.close();
+
+ var reader = z.io.bufferedReader( file.reader() );
+ var stream = reader.reader();
+
+ const contents = try stream.readAllAlloc( a, 1024 * 10000 );
+ return contents[0..contents.len];
+}
+
+pub fn fileExists( path: []const u8 ) bool {
+ const file = z.fs.cwd().openFile( path, .{} ) catch return false;
+ _ = file.stat() catch return false;
+ file.close();
+
+ return true;
+}
+
+pub fn writeFile( path: []const u8, contents: []const u8 ) !void {
+ z.fs.cwd().deleteFile( path ) catch |e| {
+ switch (e) {
+ error.FileNotFound => {},
+ else => return e
+ }
+ };
+ const file = try z.fs.cwd().createFile( path, .{} );
+ defer file.close();
+
+ var writer = file.writer();
+ writer.writeAll( contents ) catch return error.OutOfMemory;
+}
+
+pub fn deleteFile( path: []const u8 ) !void {
+ z.fs.cwd().deleteFile( path ) catch |e| {
+ switch (e) {
+ error.FileNotFound => {},
+ else => return e
+ }
+ };
+}
+
+pub fn hexToBytesAlloc( hex: []const u8, a: z.mem.Allocator ) ![]const u8 {
+ var bytes = try a.alloc( u8, hex.len / 2 );
+ var i: u32 = 0;
+ while( i < hex.len ) : (i += 2) {
+ z.debug.print( "byte: {s}\n", .{ hex[i..i+1] } );
+ const byte = try z.fmt.parseInt( u8, hex[i..i+1], 16 );
+ bytes[i/2] = byte;
+ }
+
+ return bytes;
+}
+
+pub fn hexToBytes( hex: []const u8 ) ![]const u8 {
+ return hexToBytesAlloc( hex, alloc );
+}
+
+///EXTREMELY GAY but zig crypto lib has a bug that makes it not compile.
+///IMPORTANT: FIX THIS!!!!!
+pub fn readFileCrypto( path: []const u8 ) ![]const u8 {
+ const proc = try z.process.Child.run( .{
+ .allocator = alloc,
+ .argv = &.{ "node", "../chat-reader.cjs", "read", path }
+ } );
+ defer alloc.free( proc.stdout );
+ defer alloc.free( proc.stderr );
+
+ if( proc.stdout[0] == '0' )
+ return error.FileNotFound;
+
+ const buf = alloc.dupe( u8, proc.stdout );
+ return buf;
+}
+
+pub fn writeFileCrypto( path: []const u8, buf: []const u8 ) ![]const u8 {
+ const proc = try z.process.Child.run( .{
+ .allocator = alloc,
+ .argv = &.{ "node", "../chat-reader.cjs", "write", path, buf }
+ } );
+ defer alloc.free( proc.stdout );
+ defer alloc.free( proc.stderr );
+
+ const ret = alloc.dupe( u8, proc.stdout );
+ return ret;
+}
+
+pub const Validator = struct {
+ pub fn isValidChar( c: u8 ) bool {
+ if( c >= 'a' and c <= 'z' )
+ return true;
+ if( c >= 'A' and c <= 'Z' )
+ return true;
+ if( c >= '0' and c <= '9' )
+ return true;
+ if( c == '-' or c == '_' )
+ return true;
+ if( c == '.' )
+ return true;
+ if( c == '@' )
+ return true;
+
+ return false;
+ }
+
+ pub fn isValidCharSystem( c: u8 ) bool {
+ const allowlist = ",;:/?=!#$^&*()+~[]{}<>|` ";
+ for( allowlist ) |allowc| {
+ if( allowc == c )
+ return true;
+ }
+
+ return isValidChar( c );
+ }
+
+ pub fn isValidCharFont( c: u8 ) bool {
+ if( c == ' ' )
+ return true;
+ if( !isValidChar( c ) )
+ return false;
+ return true;
+ }
+
+ pub fn isValidCharEmail( c: u8 ) bool {
+ if( !isValidChar( c ) )
+ return false;
+ return true;
+ }
+
+ pub fn isValidStrSystem( str: []const u8 ) bool {
+ if( str.len > 2048 )
+ return false;
+ for( str ) |c| {
+ if( !isValidCharSystem( c ) )
+ return false;
+ }
+ return true;
+ }
+
+ pub fn isValidStrNickname( str: []const u8 ) bool {
+ if( str.len > 24 )
+ return false;
+ if( str.len < 2 )
+ return false;
+ for( str ) |c| {
+ if( !isValidCharEmail( c ) )
+ return false;
+ }
+ return true;
+ }
+
+ pub fn isValidStrEmail( str: []const u8 ) bool {
+ if( str.len > 127 )
+ return false;
+ if( str.len < 5 )
+ return false;
+ var has_at = false;
+ var has_dot = false;
+ for( str ) |c| {
+ if( !isValidCharEmail( c ) )
+ return false;
+
+ if( c == '@' ) has_at = true;
+ if( c == '.' ) has_dot = true;
+ }
+ return true;
+ }
+
+ pub fn isValidStrFont( str: []const u8 ) bool {
+ if( str.len > 64 )
+ return false;
+ if( str.len < 3 )
+ return false;
+ for( str ) |c| {
+ if( !isValidCharFont( c ) )
+ return false;
+ }
+ return true;
+ }
+};