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/userdata.zig | |
push source
Diffstat (limited to 'backend/api/src/userdata.zig')
| -rw-r--r-- | backend/api/src/userdata.zig | 568 |
1 files changed, 568 insertions, 0 deletions
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() } ); + }; +} |
