diff --git a/app/pages/pds.vue b/app/pages/pds.vue new file mode 100644 index 000000000..abddeef34 --- /dev/null +++ b/app/pages/pds.vue @@ -0,0 +1,172 @@ + + + diff --git a/i18n/locales/en.json b/i18n/locales/en.json index 1436ab296..d9c543ce4 100644 --- a/i18n/locales/en.json +++ b/i18n/locales/en.json @@ -1165,6 +1165,32 @@ "close_files_panel": "Close files panel", "filter_files_label": "Filter files by change type" }, + "pds": { + "title": "npmx.social", + "meta_description": "The official AT Protocol Personal Data Server (PDS) for the npmx community.", + "join": { + "title": "Join the Community", + "description": "Whether you are creating your first Bluesky account or migrating an existing one, you belong here. You can migrate your current account without losing your handle, your posts, or your followers.", + "migrate": "Migrate with PDS MOOver" + }, + "server": { + "title": "Server Details", + "location_label": "Location:", + "location_value": "Nuremberg, Germany", + "infrastructure_label": "Infrastructure:", + "infrastructure_value": "Hosted on Hetzner", + "privacy_label": "Privacy:", + "privacy_value": "Subject to strict EU Data Protection laws" + }, + "community": { + "title": "Who is here", + "description": "They are already calling npmx.social home.", + "loading": "Loading PDS community...", + "error": "Failed to load PDS community.", + "empty": "No community members to display.", + "view_profile": "View {handle}'s profile" + } + }, "privacy_policy": { "title": "privacy policy", "last_updated": "Last updated: {date}", diff --git a/i18n/schema.json b/i18n/schema.json index 42f4ee4d9..0034d9c1e 100644 --- a/i18n/schema.json +++ b/i18n/schema.json @@ -3499,6 +3499,84 @@ }, "additionalProperties": false }, + "pds": { + "type": "object", + "properties": { + "title": { + "type": "string" + }, + "meta_description": { + "type": "string" + }, + "join": { + "type": "object", + "properties": { + "title": { + "type": "string" + }, + "description": { + "type": "string" + }, + "migrate": { + "type": "string" + } + }, + "additionalProperties": false + }, + "server": { + "type": "object", + "properties": { + "title": { + "type": "string" + }, + "location_label": { + "type": "string" + }, + "location_value": { + "type": "string" + }, + "infrastructure_label": { + "type": "string" + }, + "infrastructure_value": { + "type": "string" + }, + "privacy_label": { + "type": "string" + }, + "privacy_value": { + "type": "string" + } + }, + "additionalProperties": false + }, + "community": { + "type": "object", + "properties": { + "title": { + "type": "string" + }, + "description": { + "type": "string" + }, + "loading": { + "type": "string" + }, + "error": { + "type": "string" + }, + "empty": { + "type": "string" + }, + "view_profile": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, "privacy_policy": { "type": "object", "properties": { diff --git a/lunaria/files/en-GB.json b/lunaria/files/en-GB.json index 52d265f36..f4dcc5f05 100644 --- a/lunaria/files/en-GB.json +++ b/lunaria/files/en-GB.json @@ -1164,6 +1164,32 @@ "close_files_panel": "Close files panel", "filter_files_label": "Filter files by change type" }, + "pds": { + "title": "npmx.social", + "meta_description": "The official AT Protocol Personal Data Server (PDS) for the npmx community.", + "join": { + "title": "Join the Community", + "description": "Whether you are creating your first Bluesky account or migrating an existing one, you belong here. You can migrate your current account without losing your handle, your posts, or your followers.", + "migrate": "Migrate with PDS MOOver" + }, + "server": { + "title": "Server Details", + "location_label": "Location:", + "location_value": "Nuremberg, Germany", + "infrastructure_label": "Infrastructure:", + "infrastructure_value": "Hosted on Hetzner", + "privacy_label": "Privacy:", + "privacy_value": "Subject to strict EU Data Protection laws" + }, + "community": { + "title": "Who is here", + "description": "They are already calling npmx.social home.", + "loading": "Loading PDS community...", + "error": "Failed to load PDS community.", + "empty": "No community members to display.", + "view_profile": "View {handle}'s profile" + } + }, "privacy_policy": { "title": "privacy policy", "last_updated": "Last updated: {date}", diff --git a/lunaria/files/en-US.json b/lunaria/files/en-US.json index fd04a690b..4fc475cb1 100644 --- a/lunaria/files/en-US.json +++ b/lunaria/files/en-US.json @@ -1164,6 +1164,32 @@ "close_files_panel": "Close files panel", "filter_files_label": "Filter files by change type" }, + "pds": { + "title": "npmx.social", + "meta_description": "The official AT Protocol Personal Data Server (PDS) for the npmx community.", + "join": { + "title": "Join the Community", + "description": "Whether you are creating your first Bluesky account or migrating an existing one, you belong here. You can migrate your current account without losing your handle, your posts, or your followers.", + "migrate": "Migrate with PDS MOOver" + }, + "server": { + "title": "Server Details", + "location_label": "Location:", + "location_value": "Nuremberg, Germany", + "infrastructure_label": "Infrastructure:", + "infrastructure_value": "Hosted on Hetzner", + "privacy_label": "Privacy:", + "privacy_value": "Subject to strict EU Data Protection laws" + }, + "community": { + "title": "Who is here", + "description": "They are already calling npmx.social home.", + "loading": "Loading PDS community...", + "error": "Failed to load PDS community.", + "empty": "No community members to display.", + "view_profile": "View {handle}'s profile" + } + }, "privacy_policy": { "title": "privacy policy", "last_updated": "Last updated: {date}", diff --git a/nuxt.config.ts b/nuxt.config.ts index 9598679fd..c85624b41 100644 --- a/nuxt.config.ts +++ b/nuxt.config.ts @@ -161,6 +161,7 @@ export default defineNuxtConfig({ '/search': { isr: false, cache: false }, // never cache '/settings': { prerender: true }, '/recharging': { prerender: true }, + '/pds': { prerender: true }, // proxy for insights '/blog/**': { prerender: true }, '/_v/script.js': { diff --git a/server/api/atproto/pds-graphs.get.ts b/server/api/atproto/pds-graphs.get.ts new file mode 100644 index 000000000..167011515 --- /dev/null +++ b/server/api/atproto/pds-graphs.get.ts @@ -0,0 +1,88 @@ +import type { AtprotoProfile } from '#shared/types/atproto' + +import { + ONE_THOUSAND_NPMX_USER_ACCOUNTS_XRPC, + BSKY_APP_VIEW_USER_PROFILES_XRPC, + ERROR_PDS_FETCH_FAILED, +} from '#shared/utils/constants' + +interface GraphLink { + source: string + target: string +} + +const USER_BATCH_AMOUNT = 25 + +export default defineCachedEventHandler( + async (): Promise<{ nodes: AtprotoProfile[]; links: GraphLink[] }> => { + const response = await fetch(ONE_THOUSAND_NPMX_USER_ACCOUNTS_XRPC) + + if (!response.ok) { + throw createError({ + statusCode: response.status, + message: ERROR_PDS_FETCH_FAILED, + }) + } + + const listRepos = (await response.json()) as { repos: { did: string }[] } + const dids = listRepos.repos.map(repo => repo.did) + const localDids = new Set(dids) + + const nodes: AtprotoProfile[] = [] + const links: GraphLink[] = [] + + for (let i = 0; i < dids.length; i += USER_BATCH_AMOUNT) { + const batch = dids.slice(i, i + USER_BATCH_AMOUNT) + + const url = new URL(BSKY_APP_VIEW_USER_PROFILES_XRPC) + for (const did of batch) { + url.searchParams.append('actors', did) + } + + try { + const profilesResponse = await fetch(url.toString()) + + if (!profilesResponse.ok) { + console.warn(`Failed to fetch atproto profiles: ${profilesResponse.status}`) + continue + } + + const profilesData = (await profilesResponse.json()) as { profiles: AtprotoProfile[] } + + if (profilesData.profiles) { + nodes.push(...profilesData.profiles) + } + } catch (error) { + console.warn('Failed to fetch atproto profiles:', error) + } + } + + for (const did of dids) { + const followResponse = await fetch( + `https://public.api.bsky.app/xrpc/app.bsky.graph.getFollows?actor=${did}`, + ) + + if (!followResponse.ok) { + console.warn(`Failed to fetch follows: ${followResponse.status}`) + continue + } + + const followData = await followResponse.json() + + for (const followedUser of followData.follows) { + if (localDids.has(followedUser.did)) { + links.push({ source: did, target: followedUser.did }) + } + } + } + return { + nodes, + links, + } + }, + { + maxAge: 3600, + name: 'pds-graphs', + getKey: () => 'pds-graphs', + }, +) diff --git a/server/api/atproto/pds-users.get.ts b/server/api/atproto/pds-users.get.ts new file mode 100644 index 000000000..ea7f14c3f --- /dev/null +++ b/server/api/atproto/pds-users.get.ts @@ -0,0 +1,57 @@ +import { + ONE_THOUSAND_NPMX_USER_ACCOUNTS_XRPC, + BSKY_APP_VIEW_USER_PROFILES_XRPC, + ERROR_PDS_FETCH_FAILED, +} from '#shared/utils/constants' +import type { AtprotoProfile } from '#shared/types/atproto' + +const USER_BATCH_AMOUNT = 25 + +export default defineCachedEventHandler( + async (): Promise => { + // INFO: Request npmx.social PDS for every hosted user account + const response = await fetch(ONE_THOUSAND_NPMX_USER_ACCOUNTS_XRPC) + + if (!response.ok) { + throw createError({ + statusCode: response.status, + message: ERROR_PDS_FETCH_FAILED, + }) + } + + const listRepos = (await response.json()) as { repos: { did: string }[] } + const dids = listRepos.repos.map(repo => repo.did) + + // INFO: Request the list of user profiles from the Bluesky AppView + const batchPromises: Promise[] = [] + + for (let i = 0; i < dids.length; i += USER_BATCH_AMOUNT) { + const batch = dids.slice(i, i + USER_BATCH_AMOUNT) + const url = new URL(BSKY_APP_VIEW_USER_PROFILES_XRPC) + + for (const did of batch) url.searchParams.append('actors', did) + + batchPromises.push( + fetch(url.toString()) + .then(res => { + if (!res.ok) throw new Error(`Status ${res.status}`) + return res.json() as Promise<{ profiles: AtprotoProfile[] }> + }) + .then(data => data.profiles || []) + .catch(err => { + console.warn('Failed to fetch batch:', err) + // Return empty array on failure so Promise.all doesn't crash + return [] + }), + ) + } + + // INFO: Await all batches in parallel and flatten the results + return (await Promise.all(batchPromises)).flat() + }, + { + maxAge: 3600, + name: 'pds-users', + getKey: () => 'pds-users', + }, +) diff --git a/server/middleware/canonical-redirects.global.ts b/server/middleware/canonical-redirects.global.ts index 274f54474..c6f01ae1f 100644 --- a/server/middleware/canonical-redirects.global.ts +++ b/server/middleware/canonical-redirects.global.ts @@ -24,6 +24,7 @@ const pages = [ '/package', '/package-code', '/package-docs', + '/pds', '/privacy', '/search', '/settings', diff --git a/shared/types/atproto.ts b/shared/types/atproto.ts new file mode 100644 index 000000000..e74109fe9 --- /dev/null +++ b/shared/types/atproto.ts @@ -0,0 +1,13 @@ +/** + * The lightweight view of a public profile for an AT Protocol user + */ +export type AtprotoProfile = { + // The unique Decentralized Identifier (DID) + did: string + // User handle (e.g. user.bsky.social or user.com) + handle: string + // Display name, if present + displayName?: string + // URL of the avatar image, if present + avatar?: string +} diff --git a/shared/utils/constants.ts b/shared/utils/constants.ts index a6bb67ab9..f94eb1264 100644 --- a/shared/utils/constants.ts +++ b/shared/utils/constants.ts @@ -15,6 +15,12 @@ export const BLUESKY_API = 'https://public.api.bsky.app' export const BLUESKY_COMMENTS_REQUEST = '/api/atproto/bluesky-comments' export const NPM_REGISTRY = 'https://registry.npmjs.org' export const NPM_API = 'https://api.npmjs.org' +export const ONE_THOUSAND_NPMX_USER_ACCOUNTS_XRPC = + 'https://npmx.social/xrpc/com.atproto.sync.listRepos?limit=1000' +export const BSKY_APP_VIEW_USER_PROFILES_XRPC = + 'https://public.api.bsky.app/xrpc/app.bsky.actor.getProfiles' + +// Error Messages export const ERROR_PACKAGE_ANALYSIS_FAILED = 'Failed to analyze package.' export const ERROR_PACKAGE_VERSION_AND_FILE_FAILED = 'Version and file path are required.' export const ERROR_PACKAGE_REQUIREMENTS_FAILED = @@ -28,6 +34,7 @@ export const NPM_MISSING_README_SENTINEL = 'ERROR: No README data found!' export const NPM_README_TRUNCATION_THRESHOLD = 64_000 export const ERROR_JSR_FETCH_FAILED = 'Failed to fetch package from JSR registry.' export const ERROR_NPM_FETCH_FAILED = 'Failed to fetch package from npm registry.' +export const ERROR_PDS_FETCH_FAILED = 'Failed to fetch PDS repos.' export const ERROR_PROVENANCE_FETCH_FAILED = 'Failed to fetch provenance.' export const UNSET_NUXT_SESSION_PASSWORD = 'NUXT_SESSION_PASSWORD not set' export const ERROR_SUGGESTIONS_FETCH_FAILED = 'Failed to fetch suggestions.'