See the best posts from any Bluesky account
0
fork

Configure Feed

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

Respect !no-unauthenticated label on profiles

Profiles that self-apply the !no-unauthenticated label now render a
sign-in prompt for signed-out viewers instead of exposing their posts.
Extends AtprotoClient.getProfile() to surface labels and checks them
before rendering the profile page.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

+217 -17
+9
.adonisjs/server/routes.d.ts
··· 24 24 'og.profile': { paramsTuple: [ParamValue]; params: {'handle': ParamValue} } 25 25 'sitemap.index': { paramsTuple?: []; params?: {} } 26 26 'sitemap.chunk': { paramsTuple: [ParamValue]; params: {'n': ParamValue} } 27 + 'feeds.did': { paramsTuple?: []; params?: {} } 28 + 'feeds.describe': { paramsTuple?: []; params?: {} } 29 + 'feeds.getSkeleton': { paramsTuple?: []; params?: {} } 27 30 'health.live': { paramsTuple?: []; params?: {} } 28 31 'health.ready': { paramsTuple?: []; params?: {} } 29 32 } ··· 43 46 'og.profile': { paramsTuple: [ParamValue]; params: {'handle': ParamValue} } 44 47 'sitemap.index': { paramsTuple?: []; params?: {} } 45 48 'sitemap.chunk': { paramsTuple: [ParamValue]; params: {'n': ParamValue} } 49 + 'feeds.did': { paramsTuple?: []; params?: {} } 50 + 'feeds.describe': { paramsTuple?: []; params?: {} } 51 + 'feeds.getSkeleton': { paramsTuple?: []; params?: {} } 46 52 'health.live': { paramsTuple?: []; params?: {} } 47 53 'health.ready': { paramsTuple?: []; params?: {} } 48 54 } ··· 62 68 'og.profile': { paramsTuple: [ParamValue]; params: {'handle': ParamValue} } 63 69 'sitemap.index': { paramsTuple?: []; params?: {} } 64 70 'sitemap.chunk': { paramsTuple: [ParamValue]; params: {'n': ParamValue} } 71 + 'feeds.did': { paramsTuple?: []; params?: {} } 72 + 'feeds.describe': { paramsTuple?: []; params?: {} } 73 + 'feeds.getSkeleton': { paramsTuple?: []; params?: {} } 65 74 'health.live': { paramsTuple?: []; params?: {} } 66 75 'health.ready': { paramsTuple?: []; params?: {} } 67 76 }
+21 -9
app/controllers/profile_controller.ts
··· 270 270 displayName: user.displayName, 271 271 avatarUrl: user.avatarUrl, 272 272 } 273 + let profileLabels: { val: string; neg?: boolean }[] = [] 273 274 try { 274 275 const cacheKey = `topPosts:${user.did}:${kind}:${daysWindow ?? 'all'}` 275 276 const [postsResult, profileResult] = await Promise.all([ ··· 283 284 daysWindow, 284 285 }), 285 286 }), 286 - this.atprotoClient 287 - .getProfile(user.did) 288 - .then(({ displayName, avatarUrl }) => ({ displayName, avatarUrl })) 289 - .catch((err) => { 290 - logger.error({ err, handle: canonicalHandle }, 'Failed to fetch live Bluesky profile') 291 - return null 292 - }), 287 + this.atprotoClient.getProfile(user.did).catch((err) => { 288 + logger.error({ err, handle: canonicalHandle }, 'Failed to fetch live Bluesky profile') 289 + return null 290 + }), 293 291 ]) 294 292 // Re-hydrate Date fields that survive cache serialization as strings 295 293 posts = postsResult.map((p) => ({ ··· 297 295 postCreatedAt: new Date(p.postCreatedAt), 298 296 })) 299 297 if (profileResult) { 300 - profile = profileResult 298 + profile = { 299 + displayName: profileResult.displayName, 300 + avatarUrl: profileResult.avatarUrl, 301 + } 302 + profileLabels = profileResult.labels ?? [] 301 303 } 302 304 } catch (_err) { 303 305 response.status(503) 304 306 return view.render('pages/errors/server_error', { 305 307 message: "We're having a moment, try again in a sec.", 308 + }) 309 + } 310 + 311 + const isAuthenticated = await ctx.auth.check() 312 + 313 + // Respect the Bluesky !no-unauthenticated self-label: require sign-in. 314 + const requiresAuth = profileLabels.some((l) => l.val === '!no-unauthenticated' && !l.neg) 315 + if (requiresAuth && !isAuthenticated) { 316 + response.header('Cache-Control', 'public, max-age=60') 317 + return view.render('pages/profile/auth_required', { 318 + handle: canonicalHandle, 306 319 }) 307 320 } 308 321 ··· 325 338 let viewer: Record<string, { likeUri: string | null; repostUri: string | null }> | null = null 326 339 const cidMap = new Map<string, string>() 327 340 328 - const isAuthenticated = await ctx.auth.check() 329 341 if (isAuthenticated) { 330 342 const viewerDid = ctx.auth.user!.did 331 343 const agent = await this.atprotoOAuthService.getAgent(viewerDid)
+8 -3
app/lib/atproto/client.ts
··· 363 363 * @returns Object with `postsCount` 364 364 * @throws BlueskyRateLimitedError on retry exhaustion 365 365 */ 366 - async getProfile( 367 - did: string 368 - ): Promise<{ postsCount: number; displayName: string | null; avatarUrl: string | null }> { 366 + async getProfile(did: string): Promise<{ 367 + postsCount: number 368 + displayName: string | null 369 + avatarUrl: string | null 370 + labels: { val: string; neg?: boolean }[] 371 + }> { 369 372 const { 370 373 value: response, 371 374 attempts, ··· 382 385 status: 200, 383 386 headers: pickRateLimitHeaders(headers), 384 387 }) 388 + const rawLabels = (response.data.labels ?? []) as { val: string; neg?: boolean }[] 385 389 return { 386 390 postsCount: response.data.postsCount ?? 0, 387 391 displayName: response.data.displayName ?? null, 388 392 avatarUrl: response.data.avatar ?? null, 393 + labels: rawLabels, 389 394 } 390 395 } 391 396
+23
resources/views/pages/profile/auth_required.edge
··· 1 + @component('components/layout') 2 + @slot('title') 3 + Sign in to view {{ '@' + handle }}'s posts — favs.blue 4 + @endslot 5 + 6 + @slot('main') 7 + <div class="pt-12 pb-4 text-center"> 8 + <div class="mb-6"> 9 + <i class="ph-fill ph-lock-key text-5xl text-gray-300 dark:text-gray-600"></i> 10 + </div> 11 + <h1 class="font-heading text-2xl font-bold mb-3"> 12 + Sign in to view 13 + </h1> 14 + <p class="text-gray-600 dark:text-gray-400 mb-8 max-w-md mx-auto"> 15 + {{ '@' + handle }} only shares their posts with signed-in users. 16 + </p> 17 + <a href="/oauth/login" class="inline-flex items-center gap-2 px-5 py-2.5 bg-blue-600 text-white rounded-full font-medium hover:bg-blue-700 transition-colors duration-200"> 18 + <i class="ph-bold ph-sign-in"></i> 19 + Sign in with Bluesky 20 + </a> 21 + </div> 22 + @endslot 23 + @endcomponent
+122
tests/functional/profile_controller.spec.ts
··· 15 15 import { ClickHouseStore } from '#lib/clickhouse/index' 16 16 import { AtprotoClient } from '#lib/atproto/index' 17 17 import { HandleResolver } from '#services/handle_resolver' 18 + import AtprotoOAuthService from '#services/atproto_oauth' 18 19 import TrackedProfile from '#models/tracked_profile' 19 20 import Account from '#models/account' 20 21 ··· 553 554 const html = response.text() 554 555 assert.include(html, 'Most reposted') 555 556 assert.include(html, 'A popular repost') 557 + }).skip(async () => !(await isClickHouseAvailable()), 'ClickHouse not available') 558 + 559 + // Test 22: !no-unauthenticated label blocks unauthenticated viewers 560 + test('GET /profile/:handle/likes with !no-unauthenticated label shows sign-in prompt', async ({ 561 + client, 562 + assert, 563 + swap, 564 + }) => { 565 + swap(ClickHouseStore, store) 566 + swap(AtprotoClient, { 567 + async getProfile() { 568 + return { 569 + displayName: 'Private User', 570 + avatarUrl: null, 571 + labels: [{ val: '!no-unauthenticated' }], 572 + } 573 + }, 574 + } as unknown as AtprotoClient) 575 + 576 + await TrackedProfile.create({ 577 + did: 'did:plc:private001', 578 + handle: 'private.bsky.social', 579 + firstSeenAt: Date.now(), 580 + backfilledAt: Date.now(), 581 + }) 582 + 583 + await store.insertPostSnapshots([ 584 + { 585 + postUri: 'at://did:plc:private001/app.bsky.feed.post/rk1', 586 + postAuthorDid: 'did:plc:private001', 587 + postText: 'Secret post', 588 + postCreatedAt: new Date(), 589 + snapshotLikes: 100, 590 + snapshotReposts: 10, 591 + snapshotQuotes: 0, 592 + snapshotTakenAt: new Date(), 593 + embed: null, 594 + facets: [], 595 + replyParentUri: null, 596 + replyParentAuthorHandle: null, 597 + }, 598 + ]) 599 + 600 + const response = await client.get('/profile/private.bsky.social/likes') 601 + response.assertStatus(200) 602 + const html = response.text() 603 + assert.include(html, 'Sign in to view') 604 + assert.notInclude(html, 'Secret post') 605 + }).skip(async () => !(await isClickHouseAvailable()), 'ClickHouse not available') 606 + 607 + // Test 23: !no-unauthenticated label allows authenticated viewers 608 + test('GET /profile/:handle/likes with !no-unauthenticated label shows posts when authenticated', async ({ 609 + client, 610 + assert, 611 + swap, 612 + }) => { 613 + swap(ClickHouseStore, store) 614 + swap(AtprotoClient, { 615 + async getProfile() { 616 + return { 617 + displayName: 'Private User', 618 + avatarUrl: null, 619 + labels: [{ val: '!no-unauthenticated' }], 620 + } 621 + }, 622 + } as unknown as AtprotoClient) 623 + // Authenticated render path calls getPosts via the viewer's OAuth agent 624 + // to fetch viewer.like / viewer.repost state. Stub with an empty response. 625 + swap(AtprotoOAuthService, { 626 + async getAgent() { 627 + return { 628 + app: { 629 + bsky: { 630 + feed: { 631 + async getPosts() { 632 + return { data: { posts: [] } } 633 + }, 634 + }, 635 + }, 636 + }, 637 + } 638 + }, 639 + } as unknown as AtprotoOAuthService) 640 + 641 + await TrackedProfile.create({ 642 + did: 'did:plc:private002', 643 + handle: 'private.bsky.social', 644 + firstSeenAt: Date.now(), 645 + backfilledAt: Date.now(), 646 + }) 647 + 648 + await store.insertPostSnapshots([ 649 + { 650 + postUri: 'at://did:plc:private002/app.bsky.feed.post/rk1', 651 + postAuthorDid: 'did:plc:private002', 652 + postText: 'Secret post for followers', 653 + postCreatedAt: new Date(), 654 + snapshotLikes: 100, 655 + snapshotReposts: 10, 656 + snapshotQuotes: 0, 657 + snapshotTakenAt: new Date(), 658 + embed: null, 659 + facets: [], 660 + replyParentUri: null, 661 + replyParentAuthorHandle: null, 662 + }, 663 + ]) 664 + 665 + const account = await Account.create({ 666 + did: 'did:plc:viewer001', 667 + handle: 'viewer.bsky.social', 668 + sessionData: '{}', 669 + createdAt: Date.now(), 670 + updatedAt: Date.now(), 671 + }) 672 + 673 + const response = await client.get('/profile/private.bsky.social/likes').loginAs(account) 674 + response.assertStatus(200) 675 + const html = response.text() 676 + assert.include(html, 'Secret post for followers') 677 + assert.notInclude(html, 'Sign in to view') 556 678 }).skip(async () => !(await isClickHouseAvailable()), 'ClickHouse not available') 557 679 }) 558 680
+2 -2
tests/functional/profile_controller_dispatch.spec.ts
··· 228 228 const response = await client.get('/profile/nope.bsky.social/likes') 229 229 230 230 response.assertStatus(404) 231 - assert.include(response.text(), "couldn") 231 + assert.include(response.text(), 'couldn') 232 232 233 233 // No rows created 234 234 const user = await TrackedProfile.findBy('handle', 'nope.bsky.social') ··· 355 355 const response = await client.get(`/profile/${TEST_HANDLE}/likes`) 356 356 357 357 response.assertStatus(404) 358 - assert.include(response.text(), "couldn") 358 + assert.include(response.text(), 'couldn') 359 359 360 360 const user = await TrackedProfile.findBy('handle', TEST_HANDLE) 361 361 assert.isNull(user)
+31
tests/unit/atproto/client.spec.ts
··· 358 358 assert.equal(result.postsCount, 0) 359 359 }) 360 360 361 + test('returns labels array from the AppView response', async ({ assert }) => { 362 + const agent = makeMockAgent({ 363 + getProfileResponse: { 364 + data: { 365 + postsCount: 1, 366 + labels: [{ val: '!no-unauthenticated' }, { val: 'porn', neg: true }], 367 + }, 368 + headers: EMPTY_HEADERS, 369 + }, 370 + }) 371 + 372 + const client = new AtprotoClient(agent as never, noopSleep) 373 + const result = await client.getProfile('did:plc:abc123') 374 + 375 + assert.deepEqual(result.labels, [{ val: '!no-unauthenticated' }, { val: 'porn', neg: true }]) 376 + }) 377 + 378 + test('returns empty labels array when response omits labels', async ({ assert }) => { 379 + const agent = makeMockAgent({ 380 + getProfileResponse: { 381 + data: { postsCount: 1 }, 382 + headers: EMPTY_HEADERS, 383 + }, 384 + }) 385 + 386 + const client = new AtprotoClient(agent as never, noopSleep) 387 + const result = await client.getProfile('did:plc:abc123') 388 + 389 + assert.deepEqual(result.labels, []) 390 + }) 391 + 361 392 test('calls the agent with { actor: did }', async ({ assert }) => { 362 393 const agent = makeMockAgent({ 363 394 getProfileResponse: {
+1 -3
tests/unit/feed_generator.spec.ts
··· 251 251 assert.isUndefined(result.cursor) 252 252 }) 253 253 254 - test('skeleton query uses the threshold/fired_at index instead of a scan', async ({ 255 - assert, 256 - }) => { 254 + test('skeleton query uses the threshold/fired_at index instead of a scan', async ({ assert }) => { 257 255 // Verifies the migration-created composite index is actually picked by 258 256 // SQLite's planner for the page-1 query shape. If someone drops the index 259 257 // or changes the query shape into something that can't use it, this test