diff options
| author | aura <nw@moneybot.cc> | 2026-02-17 22:39:42 +0100 |
|---|---|---|
| committer | aura <nw@moneybot.cc> | 2026-02-17 22:39:42 +0100 |
| commit | 636b0323075225c584b62719ed51e75521bb7ffb (patch) | |
| tree | 61b02271b6d0695a4beffc23fb6eb062a7da22c3 /backend/api/src | |
push source
Diffstat (limited to 'backend/api/src')
| -rw-r--r-- | backend/api/src/api.zig | 307 | ||||
| -rw-r--r-- | backend/api/src/chat.zig | 586 | ||||
| -rw-r--r-- | backend/api/src/config.zig | 35 | ||||
| -rw-r--r-- | backend/api/src/db.zig | 149 | ||||
| -rw-r--r-- | backend/api/src/mail.zig | 60 | ||||
| -rw-r--r-- | backend/api/src/main.zig | 28 | ||||
| -rw-r--r-- | backend/api/src/model.zig | 123 | ||||
| -rw-r--r-- | backend/api/src/net-util.zig | 140 | ||||
| -rw-r--r-- | backend/api/src/req.zig | 311 | ||||
| -rw-r--r-- | backend/api/src/server.zig | 101 | ||||
| -rw-r--r-- | backend/api/src/stripe.zig | 181 | ||||
| -rw-r--r-- | backend/api/src/user.zig | 533 | ||||
| -rw-r--r-- | backend/api/src/userdata.zig | 568 | ||||
| -rw-r--r-- | backend/api/src/userdefs.zig | 322 | ||||
| -rw-r--r-- | backend/api/src/util.zig | 271 |
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}¤cy={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( ©, 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, ¶ms.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; + } +}; |
