zig pds
0

Configure Feed

Select the types of activity you want to include in your feed.

Align preference storage with references

+312 -27
+90 -3
src/atproto/preferences.zig
··· 6 6 const http = std.http; 7 7 8 8 const app_bsky_namespace = "app.bsky"; 9 + const max_preference_size = 10_000; 10 + const personal_details_pref = "app.bsky.actor.defs#personalDetailsPref"; 11 + const declared_age_pref = "app.bsky.actor.defs#declaredAgePref"; 9 12 10 13 pub fn getPreferences(request: *http.Server.Request) !void { 11 14 var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); ··· 13 16 const allocator = arena.allocator(); 14 17 15 18 const account = requireAccount(request, allocator) catch return; 16 - const preferences = try store.getAppPreferencesJson(allocator, account.did, app_bsky_namespace); 17 - const body = try std.fmt.allocPrint(allocator, "{{\"preferences\":{s}}}", .{preferences}); 19 + const rows = try store.getAppPreferences(allocator, account.did, app_bsky_namespace); 20 + 21 + var out: std.Io.Writer.Allocating = .init(allocator); 22 + defer out.deinit(); 23 + try out.writer.writeAll("{\"preferences\":["); 24 + var first = true; 25 + var personal_details: ?std.json.Value = null; 26 + for (rows) |row| { 27 + const parsed = std.json.parseFromSlice(std.json.Value, allocator, row.value_json, .{}) catch continue; 28 + if (std.mem.eql(u8, row.name, declared_age_pref)) continue; 29 + if (std.mem.eql(u8, row.name, personal_details_pref)) personal_details = parsed.value; 30 + if (!first) try out.writer.writeByte(','); 31 + first = false; 32 + try out.writer.writeAll(row.value_json); 33 + } 34 + if (declaredAgeJson(allocator, personal_details)) |age_json| { 35 + if (!first) try out.writer.writeByte(','); 36 + try out.writer.writeAll(age_json); 37 + } 38 + try out.writer.writeAll("]}"); 39 + const body = try out.toOwnedSlice(); 18 40 return http_api.json(request, .ok, body); 19 41 } 20 42 ··· 38 60 if (preferences != .array) { 39 61 return http_api.xrpcError(request, .bad_request, "InvalidRequest", "preferences must be an array"); 40 62 } 63 + var preference_rows: std.ArrayList(store.AppPreference) = .empty; 41 64 for (preferences.array.items) |preference| { 42 65 if (preference != .object) { 43 66 return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Preference must be an object"); ··· 48 71 if (type_name.len == 0) { 49 72 return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Preference is missing a $type"); 50 73 } 74 + if (!std.mem.startsWith(u8, type_name, app_bsky_namespace)) { 75 + return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Some preferences are not in the app.bsky namespace"); 76 + } 77 + const preference_json = try stringifyJson(allocator, preference); 78 + if (preference_json.len > max_preference_size) { 79 + return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Preference is too large"); 80 + } 81 + if (std.mem.eql(u8, type_name, declared_age_pref)) continue; 82 + try preference_rows.append(allocator, .{ 83 + .name = type_name, 84 + .value_json = preference_json, 85 + }); 51 86 } 52 87 53 - try store.setAppPreferencesJson(allocator, account.did, app_bsky_namespace, preferences); 88 + try store.replaceAppPreferences(allocator, account.did, app_bsky_namespace, preference_rows.items); 54 89 return http_api.json(request, .ok, "{}"); 90 + } 91 + 92 + fn stringifyJson(allocator: std.mem.Allocator, value: std.json.Value) ![]const u8 { 93 + var out: std.Io.Writer.Allocating = .init(allocator); 94 + defer out.deinit(); 95 + try std.json.Stringify.value(value, .{}, &out.writer); 96 + return out.toOwnedSlice(); 97 + } 98 + 99 + fn declaredAgeJson(allocator: std.mem.Allocator, personal_details: ?std.json.Value) ?[]const u8 { 100 + const personal = personal_details orelse return null; 101 + const birth_date = http_api.valueString(personal, "birthDate") orelse return null; 102 + const age = ageFromDateString(birth_date) orelse return null; 103 + return std.fmt.allocPrint( 104 + allocator, 105 + "{{\"$type\":\"{s}\",\"isOverAge13\":{},\"isOverAge16\":{},\"isOverAge18\":{}}}", 106 + .{ declared_age_pref, age >= 13, age >= 16, age >= 18 }, 107 + ) catch null; 108 + } 109 + 110 + fn ageFromDateString(date: []const u8) ?i32 { 111 + if (date.len < 10) return null; 112 + const year = std.fmt.parseInt(i32, date[0..4], 10) catch return null; 113 + const month = std.fmt.parseInt(u8, date[5..7], 10) catch return null; 114 + const day = std.fmt.parseInt(u8, date[8..10], 10) catch return null; 115 + if (date[4] != '-' or date[7] != '-' or month == 0 or month > 12 or day == 0 or day > 31) return null; 116 + 117 + const today = civilFromDays(@divTrunc(@divTrunc(store.nowMs(), 1000), 86400)); 118 + var age = today.year - year; 119 + if (today.month < month or (today.month == month and today.day < day)) age -= 1; 120 + return age; 121 + } 122 + 123 + const CivilDate = struct { 124 + year: i32, 125 + month: u8, 126 + day: u8, 127 + }; 128 + 129 + fn civilFromDays(days_since_unix_epoch: i64) CivilDate { 130 + const z = days_since_unix_epoch + 719468; 131 + const era = if (z >= 0) @divTrunc(z, 146097) else @divTrunc(z - 146096, 146097); 132 + const doe: u32 = @intCast(z - era * 146097); 133 + const yoe: u32 = @intCast(@divTrunc(doe - @divTrunc(doe, 1460) + @divTrunc(doe, 36524) - @divTrunc(doe, 146096), 365)); 134 + var year: i32 = @intCast(yoe); 135 + year += @intCast(era * 400); 136 + const doy: u32 = doe - (365 * yoe + @divTrunc(yoe, 4) - @divTrunc(yoe, 100)); 137 + const mp: u32 = @divTrunc(5 * doy + 2, 153); 138 + const day: u8 = @intCast(doy - @divTrunc(153 * mp + 2, 5) + 1); 139 + const month: u8 = @intCast(if (mp < 10) mp + 3 else mp - 9); 140 + if (month <= 2) year += 1; 141 + return .{ .year = year, .month = month, .day = day }; 55 142 } 56 143 57 144 fn requireAccount(request: *http.Server.Request, allocator: std.mem.Allocator) !auth.Account {
+87 -1
src/atproto/proxy.zig
··· 274 274 } 275 275 276 276 fn isProtectedMethod(method: []const u8) bool { 277 - if (std.ascii.startsWithIgnoreCase(method, "com.atproto.")) return true; 278 277 const protected = [_][]const u8{ 279 278 "app.bsky.actor.getPreferences", 280 279 "app.bsky.actor.putPreferences", 280 + "com.atproto.admin.deleteAccount", 281 + "com.atproto.admin.disableAccountInvites", 282 + "com.atproto.admin.disableInviteCodes", 283 + "com.atproto.admin.enableAccountInvites", 284 + "com.atproto.admin.getAccountInfo", 285 + "com.atproto.admin.getAccountInfos", 286 + "com.atproto.admin.getInviteCodes", 287 + "com.atproto.admin.getSubjectStatus", 288 + "com.atproto.admin.searchAccounts", 289 + "com.atproto.admin.sendEmail", 290 + "com.atproto.admin.updateAccountEmail", 291 + "com.atproto.admin.updateAccountHandle", 292 + "com.atproto.admin.updateAccountPassword", 293 + "com.atproto.admin.updateSubjectStatus", 294 + "com.atproto.identity.getRecommendedDidCredentials", 295 + "com.atproto.identity.requestPlcOperationSignature", 296 + "com.atproto.identity.signPlcOperation", 297 + "com.atproto.identity.submitPlcOperation", 298 + "com.atproto.identity.updateHandle", 299 + "com.atproto.repo.applyWrites", 300 + "com.atproto.repo.createRecord", 301 + "com.atproto.repo.deleteRecord", 302 + "com.atproto.repo.importRepo", 303 + "com.atproto.repo.putRecord", 304 + "com.atproto.repo.uploadBlob", 305 + "com.atproto.server.activateAccount", 306 + "com.atproto.server.checkAccountStatus", 307 + "com.atproto.server.confirmEmail", 308 + "com.atproto.server.confirmSignup", 309 + "com.atproto.server.createAccount", 310 + "com.atproto.server.createAppPassword", 311 + "com.atproto.server.createInviteCode", 312 + "com.atproto.server.createInviteCodes", 313 + "com.atproto.server.createSession", 314 + "com.atproto.server.createTotpSecret", 315 + "com.atproto.server.deactivateAccount", 316 + "com.atproto.server.deleteAccount", 317 + "com.atproto.server.deletePasskey", 318 + "com.atproto.server.deleteSession", 319 + "com.atproto.server.describeServer", 320 + "com.atproto.server.disableTotp", 321 + "com.atproto.server.enableTotp", 322 + "com.atproto.server.finishPasskeyRegistration", 323 + "com.atproto.server.getAccountInviteCodes", 324 + "com.atproto.server.getServiceAuth", 325 + "com.atproto.server.getSession", 326 + "com.atproto.server.getTotpStatus", 327 + "com.atproto.server.listAppPasswords", 328 + "com.atproto.server.listPasskeys", 329 + "com.atproto.server.refreshSession", 330 + "com.atproto.server.regenerateBackupCodes", 331 + "com.atproto.server.requestAccountDelete", 332 + "com.atproto.server.requestEmailConfirmation", 333 + "com.atproto.server.requestEmailUpdate", 334 + "com.atproto.server.requestPasswordReset", 335 + "com.atproto.server.resendMigrationVerification", 336 + "com.atproto.server.resendVerification", 337 + "com.atproto.server.reserveSigningKey", 338 + "com.atproto.server.resetPassword", 339 + "com.atproto.server.revokeAppPassword", 340 + "com.atproto.server.startPasskeyRegistration", 341 + "com.atproto.server.updateEmail", 342 + "com.atproto.server.updatePasskey", 343 + "com.atproto.server.verifyMigrationEmail", 344 + "com.atproto.sync.getBlob", 345 + "com.atproto.sync.getBlocks", 346 + "com.atproto.sync.getCheckout", 347 + "com.atproto.sync.getHead", 348 + "com.atproto.sync.getLatestCommit", 349 + "com.atproto.sync.getRecord", 350 + "com.atproto.sync.getRepo", 351 + "com.atproto.sync.getRepoStatus", 352 + "com.atproto.sync.listBlobs", 353 + "com.atproto.sync.listRepos", 354 + "com.atproto.sync.notifyOfUpdate", 355 + "com.atproto.sync.requestCrawl", 356 + "com.atproto.sync.subscribeRepos", 357 + "com.atproto.temp.checkSignupQueue", 358 + "com.atproto.temp.dereferenceScope", 281 359 }; 282 360 for (protected) |item| { 283 361 if (std.ascii.eqlIgnoreCase(method, item)) return true; 284 362 } 285 363 return false; 364 + } 365 + 366 + test "proxy protected methods are explicit" { 367 + try std.testing.expect(isProtectedMethod("com.atproto.repo.applyWrites")); 368 + try std.testing.expect(isProtectedMethod("com.atproto.sync.subscribeRepos")); 369 + try std.testing.expect(!isProtectedMethod("com.atproto.identity.resolveHandle")); 370 + try std.testing.expect(!isProtectedMethod("com.atproto.repo.getRecord")); 371 + try std.testing.expect(!isProtectedMethod("com.atproto.repo.listRecords")); 286 372 } 287 373 288 374 test "parses feed generator at-uri" {
+122 -23
src/storage/store.zig
··· 79 79 revoked: bool, 80 80 }; 81 81 82 + pub const AppPreference = struct { 83 + name: []const u8, 84 + value_json: []const u8, 85 + }; 86 + 82 87 pub const ImportedRecord = struct { 83 88 collection: []const u8, 84 89 rkey: []const u8, ··· 867 872 return out.toOwnedSlice(); 868 873 } 869 874 870 - pub fn getAppPreferencesJson(allocator: std.mem.Allocator, did: []const u8, namespace: []const u8) ![]const u8 { 875 + pub fn getAppPreferences(allocator: std.mem.Allocator, did: []const u8, namespace: []const u8) ![]AppPreference { 871 876 mutex.lockUncancelable(store_io); 872 877 defer mutex.unlock(store_io); 873 878 try requireInitialized(); 874 879 875 - const row = try conn.row( 876 - \\SELECT preferences_json 880 + const like_pattern = try std.fmt.allocPrint(allocator, "{s}.%", .{namespace}); 881 + var rows = try conn.rows( 882 + \\SELECT name, value_json 877 883 \\FROM app_preferences 878 - \\WHERE did = ? AND namespace = ? 879 - , .{ did, namespace }); 880 - if (row == null) return allocator.dupe(u8, "[]"); 881 - defer row.?.deinit(); 882 - return allocator.dupe(u8, row.?.text(0)); 884 + \\WHERE did = ? AND (name = ? OR name LIKE ?) 885 + \\ORDER BY id ASC 886 + , .{ did, namespace, like_pattern }); 887 + defer rows.deinit(); 888 + 889 + var preferences: std.ArrayList(AppPreference) = .empty; 890 + while (rows.next()) |row| { 891 + try preferences.append(allocator, .{ 892 + .name = try allocator.dupe(u8, row.text(0)), 893 + .value_json = try allocator.dupe(u8, row.text(1)), 894 + }); 895 + } 896 + if (rows.err) |err| return err; 897 + return preferences.toOwnedSlice(allocator); 883 898 } 884 899 885 - pub fn setAppPreferencesJson( 900 + pub fn replaceAppPreferences( 886 901 allocator: std.mem.Allocator, 887 902 did: []const u8, 888 903 namespace: []const u8, 889 - preferences: std.json.Value, 904 + preferences: []const AppPreference, 890 905 ) !void { 891 - const preferences_json = try stringifyValue(allocator, preferences); 892 - defer allocator.free(preferences_json); 893 - 894 906 mutex.lockUncancelable(store_io); 895 907 defer mutex.unlock(store_io); 896 908 try requireInitialized(); 909 + const like_pattern = try std.fmt.allocPrint(allocator, "{s}.%", .{namespace}); 910 + try conn.exclusiveTransaction(); 911 + errdefer conn.rollback(); 897 912 try conn.exec( 898 - \\INSERT INTO app_preferences (did, namespace, preferences_json, updated_at) 899 - \\VALUES (?, ?, ?, unixepoch()) 900 - \\ON CONFLICT(did, namespace) DO UPDATE SET 901 - \\ preferences_json = excluded.preferences_json, 902 - \\ updated_at = excluded.updated_at 903 - , .{ did, namespace, preferences_json }); 913 + \\DELETE FROM app_preferences 914 + \\WHERE did = ? AND (name = ? OR name LIKE ?) 915 + , .{ did, namespace, like_pattern }); 916 + for (preferences) |preference| { 917 + try conn.exec( 918 + \\INSERT INTO app_preferences (did, name, value_json, updated_at) 919 + \\VALUES (?, ?, ?, unixepoch()) 920 + , .{ did, preference.name, preference.value_json }); 921 + } 922 + try conn.commit(); 904 923 } 905 924 906 925 pub fn putBlob( ··· 1331 1350 conn.execNoArgs("ALTER TABLE accounts ADD COLUMN signing_key BLOB") catch {}; 1332 1351 conn.execNoArgs("ALTER TABLE oauth_requests ADD COLUMN login_hint TEXT") catch {}; 1333 1352 try migrateBlobTable(); 1353 + try migrateAppPreferencesTable(); 1334 1354 inline for (post_schema_statements) |sql| try conn.execNoArgs(sql); 1335 1355 } 1336 1356 1357 + fn migrateAppPreferencesTable() !void { 1358 + if (!try appPreferencesTableHasPreferencesJsonColumn()) return; 1359 + try conn.execNoArgs( 1360 + \\CREATE TABLE IF NOT EXISTS app_preferences_next ( 1361 + \\ id INTEGER PRIMARY KEY AUTOINCREMENT, 1362 + \\ did TEXT NOT NULL REFERENCES accounts(did) ON DELETE CASCADE, 1363 + \\ name TEXT NOT NULL, 1364 + \\ value_json BLOB NOT NULL, 1365 + \\ updated_at INTEGER NOT NULL DEFAULT (unixepoch()) 1366 + \\) 1367 + ); 1368 + var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); 1369 + defer arena.deinit(); 1370 + const allocator = arena.allocator(); 1371 + 1372 + { 1373 + var rows = try conn.rows( 1374 + \\SELECT did, namespace, preferences_json 1375 + \\FROM app_preferences 1376 + , .{}); 1377 + defer rows.deinit(); 1378 + 1379 + var preferences: std.ArrayList(AppPreference) = .empty; 1380 + while (rows.next()) |row| { 1381 + const did = try allocator.dupe(u8, row.text(0)); 1382 + const namespace = try allocator.dupe(u8, row.text(1)); 1383 + const preferences_json = try allocator.dupe(u8, row.text(2)); 1384 + const parsed = std.json.parseFromSlice(std.json.Value, allocator, preferences_json, .{}) catch continue; 1385 + if (parsed.value != .array) continue; 1386 + for (parsed.value.array.items) |preference| { 1387 + if (preference != .object) continue; 1388 + const name = jsonValueString(preference, "$type") orelse continue; 1389 + if (!preferenceNameInNamespace(name, namespace)) continue; 1390 + const value_json = try stringifyValue(allocator, preference); 1391 + try preferences.append(allocator, .{ 1392 + .name = name, 1393 + .value_json = value_json, 1394 + }); 1395 + } 1396 + for (preferences.items) |preference| { 1397 + try conn.exec( 1398 + \\INSERT INTO app_preferences_next (did, name, value_json, updated_at) 1399 + \\VALUES (?, ?, ?, unixepoch()) 1400 + , .{ did, preference.name, preference.value_json }); 1401 + } 1402 + preferences.clearRetainingCapacity(); 1403 + } 1404 + if (rows.err) |err| return err; 1405 + } 1406 + 1407 + try conn.execNoArgs("DROP TABLE app_preferences"); 1408 + try conn.execNoArgs("ALTER TABLE app_preferences_next RENAME TO app_preferences"); 1409 + } 1410 + 1411 + fn appPreferencesTableHasPreferencesJsonColumn() !bool { 1412 + var rows = try conn.rows("PRAGMA table_info(app_preferences)", .{}); 1413 + defer rows.deinit(); 1414 + while (rows.next()) |row| { 1415 + if (std.mem.eql(u8, row.text(1), "preferences_json")) return true; 1416 + } 1417 + if (rows.err) |err| return err; 1418 + return false; 1419 + } 1420 + 1421 + fn preferenceNameInNamespace(name: []const u8, namespace: []const u8) bool { 1422 + return std.mem.eql(u8, name, namespace) or 1423 + (std.mem.startsWith(u8, name, namespace) and name.len > namespace.len and name[namespace.len] == '.'); 1424 + } 1425 + 1426 + fn jsonValueString(value: std.json.Value, key: []const u8) ?[]const u8 { 1427 + return switch (value) { 1428 + .object => |object| switch (object.get(key) orelse return null) { 1429 + .string => |string| string, 1430 + else => null, 1431 + }, 1432 + else => null, 1433 + }; 1434 + } 1435 + 1337 1436 fn migrateBlobTable() !void { 1338 1437 if (!try blobTableHasDataColumn()) return; 1339 1438 try conn.execNoArgs( ··· 2264 2363 \\) 2265 2364 , 2266 2365 \\CREATE TABLE IF NOT EXISTS app_preferences ( 2366 + \\ id INTEGER PRIMARY KEY AUTOINCREMENT, 2267 2367 \\ did TEXT NOT NULL REFERENCES accounts(did) ON DELETE CASCADE, 2268 - \\ namespace TEXT NOT NULL, 2269 - \\ preferences_json BLOB NOT NULL, 2270 - \\ updated_at INTEGER NOT NULL DEFAULT (unixepoch()), 2271 - \\ PRIMARY KEY (did, namespace) 2368 + \\ name TEXT NOT NULL, 2369 + \\ value_json BLOB NOT NULL, 2370 + \\ updated_at INTEGER NOT NULL DEFAULT (unixepoch()) 2272 2371 \\) 2273 2372 , 2274 2373 \\CREATE TABLE IF NOT EXISTS oauth_requests (
+13
tools/smoke.sh
··· 55 55 token=$(printf '%s' "$session" | sed -n 's/.*"accessJwt":"\([^"]*\)".*/\1/p') 56 56 test -n "$token" 57 57 58 + curl -fsS -X POST "$base/xrpc/app.bsky.actor.putPreferences" \ 59 + -H "authorization: Bearer $token" \ 60 + -H 'content-type: application/json' \ 61 + --data '{"preferences":[{"$type":"app.bsky.actor.defs#contentLabelPref","label":"dogs","visibility":"show"},{"$type":"app.bsky.actor.defs#contentLabelPref","label":"cats","visibility":"warn"},{"$type":"app.bsky.actor.defs#personalDetailsPref","birthDate":"1970-01-01"},{"$type":"app.bsky.actor.defs#declaredAgePref","isOverAge13":false,"isOverAge16":false,"isOverAge18":false}]}' >/dev/null 62 + prefs=$(curl -fsS -H "authorization: Bearer $token" "$base/xrpc/app.bsky.actor.getPreferences") 63 + printf '%s' "$prefs" | grep -q '"label":"dogs"' 64 + printf '%s' "$prefs" | grep -q '"label":"cats"' 65 + printf '%s' "$prefs" | grep -q '"birthDate":"1970-01-01"' 66 + printf '%s' "$prefs" | grep -q '"isOverAge13":true' 67 + printf '%s' "$prefs" | grep -q '"isOverAge16":true' 68 + printf '%s' "$prefs" | grep -q '"isOverAge18":true' 69 + test "$(printf '%s' "$prefs" | grep -o 'declaredAgePref' | wc -l | tr -d ' ')" = "1" 70 + 58 71 create=$(curl -fsS -X POST "$base/xrpc/com.atproto.repo.createRecord" \ 59 72 -H "authorization: Bearer $token" \ 60 73 -H 'content-type: application/json' \