summaryrefslogtreecommitdiff
path: root/backend/api/src/userdata.zig
diff options
context:
space:
mode:
Diffstat (limited to 'backend/api/src/userdata.zig')
-rw-r--r--backend/api/src/userdata.zig568
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, &params.v, &user ) catch |e| {
+ switch( e ) {
+ error.JsonParseError => return net.sendJson( r, .bad_request, ErrorRes{ .msg = "invalid request body" } ),
+ error.InvalidNickname => return net.sendJson( r, .bad_request, ErrorRes{ .msg = "invalid field: nickname" } ),
+ error.InvalidSiteFont => return net.sendJson( r, .bad_request, ErrorRes{ .msg = "invalid field: site_font" } ),
+ error.InvalidSystemPrompt => return net.sendJson( r, .bad_request, ErrorRes{ .msg = "invalid field: system_prompt" } ),
+ error.InvalidModel => return net.sendJson( r, .bad_request, ErrorRes{ .msg = "invalid field: model" } ),
+ else => {
+ z.debug.print( "error updating settings: {s} {any}", .{ entry.uuid, @errorReturnTrace() } );
+ return net.sendJson( r, .internal_server_error, ErrorRes{.msg = "internal server error" } );
+ }
+ }
+ };
+
+ return net.sendJson( r, .ok, OkRes( .{
+ .msg = "settings updated successfully",
+ .userprefs = PrefsResponse.fromPrefs( &entry, &user )
+ } ) );
+}
+
+fn readNotesFile( uuid: []const u8 ) !u.JsonResponse([]NoteEntry) {
+ var buf: [256]u8 = undefined;
+ const path = z.fmt.bufPrint( &buf, "../data/notes/{s}-notes.json", .{ uuid } ) catch "";
+
+ const contents = u.readFileCrypto( path ) catch {
+ return error.IOError;
+ }; defer alloc.free( contents );
+
+ const json = u.jsonParse( []NoteEntry, contents ) catch {
+ return error.ParseError;
+ };
+
+ return json;
+}
+
+fn saveNotesFile( uuid: []const u8, notes: []NoteEntry ) !void {
+ var buf: [256]u8 = undefined;
+ const path = z.fmt.bufPrint( &buf, "../data/notes/{s}-notes.json", .{ uuid } ) catch "";
+
+ const contents = u.jsonStringify( notes ) catch {
+ return error.EncodeError;
+ }; defer alloc.free( contents );
+
+ const stdout = u.writeFileCrypto( path, contents ) catch |e| {
+ z.debug.print( "Failed to write file: {any}", .{ e } );
+ return error.IOError;
+ };
+
+ alloc.free( stdout );
+}
+
+///route @/get-notes
+fn getNotes( r: zap.Request ) void {
+ u.checkDbOnThread() catch {
+ return net.sendJson( r, .internal_server_error, ErrorRes{ .msg = "internal server error" } );
+ };
+ if( net.handleInvalidPostReq( r ) ) return;
+
+ const uuid = u.uuidFromApiOrAuthToken( r ) catch return;
+ defer alloc.free( uuid );
+
+ const notes = readNotesFile( uuid ) catch |e| {
+ switch( e ) {
+ error.IOError => return net.sendJson( r, .ok, OkRes( .{ .notes = [_]NoteEntry{} } ) ),
+ error.ParseError => return net.sendJson( r, .internal_server_error, ErrorRes{ .msg = "error parsing notes file" } )
+ }
+ }; defer notes.deinit();
+
+ return net.sendJson( r, .ok, OkRes( .{ .notes = notes.v } ) );
+}
+
+///route @/delete-note
+fn deleteNote( r: zap.Request ) void {
+ u.checkDbOnThread() catch {
+ return net.sendJson( r, .internal_server_error, ErrorRes{ .msg = "internal server error" } );
+ };
+ if( net.handleInvalidPostReq( r ) ) return;
+
+ const uuid = u.uuidFromApiOrAuthToken( r ) catch return;
+ defer alloc.free( uuid );
+
+ const params = u.jsonParse( NoteTargetReq, r.body.? ) catch {
+ z.debug.print( "{s}\n", .{ r.body.? } );
+ return net.sendJson( r, .bad_request, ErrorRes{ .msg = "invalid request format" } );
+ }; defer params.deinit();
+
+ const notes = readNotesFile( uuid ) catch |e| {
+ switch( e ) {
+ error.IOError => return net.sendJson( r, .not_found, ErrorRes{ .msg = "note not found" } ),
+ error.ParseError => return net.sendJson( r, .internal_server_error, ErrorRes{ .msg = "error parsing notes file" } )
+ }
+ }; defer notes.deinit();
+
+ var list = ArrayList( NoteEntry ).init( alloc );
+ defer list.deinit();
+ for( notes.v ) |note| {
+ if( !memeql( u8, note.id, params.v.noteId ) )
+ list.append( note ) catch {};
+ }
+
+ saveNotesFile( uuid, list.items ) catch {
+ return net.sendJson( r, .internal_server_error, ErrorRes{ .msg = "error saving notes file" } );
+ };
+
+ return net.sendJson( r, .ok, OkRes( .{ .msg = "note deleted" } ) );
+}
+
+///route @/delete-notes
+fn deleteNotes( r: zap.Request ) void {
+ u.checkDbOnThread() catch {
+ return net.sendJson( r, .internal_server_error, ErrorRes{ .msg = "internal server error" } );
+ };
+ if( net.handleInvalidPostReq( r ) ) return;
+
+ const uuid = u.uuidFromApiOrAuthToken( r ) catch return;
+ defer alloc.free( uuid );
+
+ var buf: [256]u8 = undefined;
+ const path = z.fmt.bufPrint( &buf, "../data/notes/{s}-notes.json", .{ uuid } ) catch "";
+
+ z.fs.cwd().deleteFile( path ) catch |e| {
+ switch ( e ) {
+ error.FileNotFound => return net.sendJson( r, .not_found, ErrorRes{ .msg = "no notes found" } ),
+ else => return net.sendJson( r, .internal_server_error, ErrorRes{ .msg = "internal server error" } ),
+ }
+ };
+
+ net.sendJson( r, .ok, OkRes( .{ .msg = "notes deleted" } ) );
+}
+
+fn aggregateUserData( uuid: []const u8 ) ![]const u8 {
+ const userdata = try getSettings( uuid );
+ defer userdata.db_entry.?.deinit();
+
+ const json = try u.jsonStringify( userdata );
+ defer alloc.free( json );
+
+ const wrapped_json = try u.jsonStringify( json );
+ defer alloc.free( wrapped_json );
+
+ const proc = try z.process.Child.run( .{
+ .allocator = alloc,
+ .argv = &.{ "node", "../data-aggregate.cjs", wrapped_json }
+ } );
+ defer alloc.free( proc.stdout );
+ defer alloc.free( proc.stderr );
+
+ while( true ) {
+ if( proc.term == .Exited )
+ break;
+ z.Thread.yield() catch {};
+ }
+
+ if( proc.stderr.len > 0 )
+ z.debug.print( "{s}\n", .{ proc.stderr } );
+ if( proc.stdout.len > 0 and proc.stdout[0] == '0' ) {
+ z.debug.print( "{s}\n", .{ proc.stdout } );
+ return error.ReadError;
+ }
+
+ const copy = alloc.dupe( u8, proc.stdout );
+ return copy;
+}
+
+///route @/getalldata
+fn getAllData( r: zap.Request ) void {
+ if( net.handleInvalidPostReq( r ) ) return;
+ u.checkDbOnThread() catch return;
+
+ var token = u.tokenFromPostReq( r ) catch return;
+ defer token.deinit();
+ const user = u.getEntry( token.claims.uuid ) catch {
+ return net.sendJson( r, .unauthorized, ErrorRes{ .msg = "user not found" } );
+ };
+ defer user.db_entry.?.deinit();
+
+ const data = aggregateUserData( user.uuid ) catch |e| {
+ z.debug.print( "error aggregating data: {any} {any}", .{ e, @errorReturnTrace() } );
+ return net.sendJson( r, .internal_server_error, ErrorRes{ .msg = "internal server error" } );
+ };
+ defer alloc.free( data );
+
+ var path_buf: [256]u8 = undefined;
+ const path = z.fmt.bufPrint( &path_buf, "/tmp/{s}.zip", .{ user.uuid } ) catch {
+ return net.sendJson( r, .internal_server_error, ErrorRes{ .msg = "internal server error" } );
+ };
+
+ u.writeFile( path, data ) catch |e| {
+ z.debug.print( "error writing file: {any} {any}", .{ e, @errorReturnTrace() } );
+ return net.sendJson( r, .internal_server_error, ErrorRes{ .msg = "internal server error" } );
+ };
+ r.setHeader( "Content-Type", "application/zip" ) catch {};
+ r.setHeader( "Content-Disposition", "attachment; filename=userdata.zip" ) catch {};
+ r.sendFile( path ) catch |e| {
+ z.debug.print( "error sending file: {any} {any}", .{ e, @errorReturnTrace() } );
+ };
+}