diff --git a/app/components/Header/AuthModal.client.vue b/app/components/Header/AuthModal.client.vue
index 6f28f2aed..100de6638 100644
--- a/app/components/Header/AuthModal.client.vue
+++ b/app/components/Header/AuthModal.client.vue
@@ -3,6 +3,8 @@ import { useAtproto } from '~/composables/atproto/useAtproto'
import { authRedirect } from '~/utils/atproto/helpers'
import { isAtIdentifierString } from '@atproto/lex'
+const authModal = useModal('auth-modal')
+
const handleInput = shallowRef('')
const errorMessage = shallowRef('')
const route = useRoute()
@@ -72,9 +74,22 @@ watch(user, async newUser => {
-
- {{ $t('auth.modal.disconnect') }}
-
+
+
+
+ {{ $t('auth.modal.profile') }}
+
+
+
+ {{ $t('auth.modal.disconnect') }}
+
+
diff --git a/app/components/Package/LikeCard.vue b/app/components/Package/LikeCard.vue
new file mode 100644
index 000000000..87fcff0ef
--- /dev/null
+++ b/app/components/Package/LikeCard.vue
@@ -0,0 +1,110 @@
+
+
+
+
+
+ {{ name }}
+
+
+
+
+
+
+
↗
+
+
+
+
diff --git a/app/composables/atproto/useProfileLikes.ts b/app/composables/atproto/useProfileLikes.ts
new file mode 100644
index 000000000..30bbba1c9
--- /dev/null
+++ b/app/composables/atproto/useProfileLikes.ts
@@ -0,0 +1,13 @@
+export type LikesResult = {
+ records: {
+ value: {
+ subjectRef: string
+ }
+ }[]
+}
+
+export function useProfileLikes(handle: MaybeRefOrGetter) {
+ const asyncData = useLazyFetch(() => `/api/social/profile/${toValue(handle)}/likes`)
+
+ return asyncData
+}
diff --git a/app/pages/profile/[handle]/index.vue b/app/pages/profile/[handle]/index.vue
new file mode 100644
index 000000000..09be00dd5
--- /dev/null
+++ b/app/pages/profile/[handle]/index.vue
@@ -0,0 +1,215 @@
+
+
+
+
+
+
+
+
+
+ {{ $t('profile.likes') }}
+ ({{ likes.records?.length ?? 0 }})
+
+
+
+
+
+
{{ $t('common.error') }}
+
+
+
+
+
+
+ {{ $t('profile.invite.message') }}
+
+
+ {{ $t('profile.invite.share_button') }}
+
+
+
+
+
diff --git a/app/utils/atproto/profile.ts b/app/utils/atproto/profile.ts
new file mode 100644
index 000000000..43968ea5b
--- /dev/null
+++ b/app/utils/atproto/profile.ts
@@ -0,0 +1,36 @@
+import { FetchError } from 'ofetch'
+import { handleAuthError } from './helpers'
+
+export type UpdateProfileResult = {
+ success: boolean
+ error?: Error
+}
+
+/**
+ * Update an NPMX Profile via the API
+ */
+export async function updateProfile(
+ userHandle: string,
+ {
+ displayName,
+ description,
+ website,
+ }: {
+ displayName: string
+ description?: string
+ website?: string
+ },
+): Promise {
+ try {
+ await $fetch(`/api/social/profile/${userHandle}`, {
+ method: 'PUT',
+ body: { displayName, description, website },
+ })
+ return { success: true }
+ } catch (e) {
+ if (e instanceof FetchError) {
+ await handleAuthError(e, userHandle)
+ }
+ return { success: false, error: e as Error }
+ }
+}
diff --git a/i18n/locales/en.json b/i18n/locales/en.json
index c34838ccf..34d88abaa 100644
--- a/i18n/locales/en.json
+++ b/i18n/locales/en.json
@@ -144,7 +144,28 @@
"role": "role",
"members": "members"
},
- "scroll_to_top": "Scroll to top"
+ "scroll_to_top": "Scroll to top",
+ "cancel": "Cancel",
+ "save": "Save",
+ "edit": "Edit",
+ "error": "Error"
+ },
+ "profile": {
+ "display_name": "Display Name",
+ "description": "Description",
+ "no_description": "No description",
+ "website": "Website",
+ "website_placeholder": "https://example.com",
+ "likes": "Likes",
+ "seo_title": "{handle} - npmx",
+ "seo_description": "npmx profile by {handle}",
+ "not_found": "Profile Not Found",
+ "not_found_message": "The profile for {handle} could not be found.",
+ "invite": {
+ "message": "It doesn't look like they're using npmx yet. Want to tell them about it?",
+ "share_button": "Share on Bluesky",
+ "compose_text": "Hey {'@'}{handle}! Have you checked out npmx.dev yet? It's a browser for the npm registry that's fast, modern, and open-source.\nhttps://npmx.dev"
+ }
},
"package": {
"not_found": "Package Not Found",
@@ -933,7 +954,8 @@
"connect_bluesky": "Connect with Bluesky",
"what_is_atmosphere": "What is an Atmosphere account?",
"atmosphere_explanation": "{npmx} uses the {atproto} to power many of its social features, allowing users to own their data and use one account for all compatible applications. Once you create an account, you can use other apps like {bluesky} and {tangled} with the same account.",
- "default_input_error": "Please enter a valid handle, DID, or a full PDS URL"
+ "default_input_error": "Please enter a valid handle, DID, or a full PDS URL",
+ "profile": "Profile"
}
},
"header": {
diff --git a/i18n/schema.json b/i18n/schema.json
index 5871b157f..36795db2b 100644
--- a/i18n/schema.json
+++ b/i18n/schema.json
@@ -438,6 +438,69 @@
},
"scroll_to_top": {
"type": "string"
+ },
+ "cancel": {
+ "type": "string"
+ },
+ "save": {
+ "type": "string"
+ },
+ "edit": {
+ "type": "string"
+ },
+ "error": {
+ "type": "string"
+ }
+ },
+ "additionalProperties": false
+ },
+ "profile": {
+ "type": "object",
+ "properties": {
+ "display_name": {
+ "type": "string"
+ },
+ "description": {
+ "type": "string"
+ },
+ "no_description": {
+ "type": "string"
+ },
+ "website": {
+ "type": "string"
+ },
+ "website_placeholder": {
+ "type": "string"
+ },
+ "likes": {
+ "type": "string"
+ },
+ "seo_title": {
+ "type": "string"
+ },
+ "seo_description": {
+ "type": "string"
+ },
+ "not_found": {
+ "type": "string"
+ },
+ "not_found_message": {
+ "type": "string"
+ },
+ "invite": {
+ "type": "object",
+ "properties": {
+ "message": {
+ "type": "string"
+ },
+ "share_button": {
+ "type": "string"
+ },
+ "compose_text": {
+ "type": "string"
+ }
+ },
+ "additionalProperties": false
}
},
"additionalProperties": false
@@ -2805,6 +2868,9 @@
},
"default_input_error": {
"type": "string"
+ },
+ "profile": {
+ "type": "string"
}
},
"additionalProperties": false
diff --git a/knip.ts b/knip.ts
index 3022d4fbd..e11270865 100644
--- a/knip.ts
+++ b/knip.ts
@@ -33,6 +33,7 @@ const config: KnipConfig = {
'!test/test-utils/**',
'!test/e2e/helpers/**',
'!cli/src/**',
+ '!lexicons/**',
],
ignoreDependencies: [
'@iconify-json/*',
diff --git a/lexicons/blue/microcosm/identity/resolveMiniDoc.json b/lexicons/blue/microcosm/identity/resolveMiniDoc.json
new file mode 100644
index 000000000..fad411d62
--- /dev/null
+++ b/lexicons/blue/microcosm/identity/resolveMiniDoc.json
@@ -0,0 +1,50 @@
+{
+ "id": "blue.microcosm.identity.resolveMiniDoc",
+ "defs": {
+ "main": {
+ "type": "query",
+ "description": "Slingshot: like com.atproto.identity.resolveIdentity but instead of the full didDoc it returns an atproto-relevant subset",
+ "parameters": {
+ "type": "params",
+ "required": ["identifier"],
+ "properties": {
+ "identifier": {
+ "type": "string",
+ "format": "at-identifier",
+ "description": "handle or DID to resolve"
+ }
+ }
+ },
+ "output": {
+ "encoding": "application/json",
+ "schema": {
+ "type": "object",
+ "required": ["did", "handle", "pds", "signing_key"],
+ "properties": {
+ "did": {
+ "type": "string",
+ "format": "did",
+ "description": "DID, bi-directionally verified if a handle was provided in the query"
+ },
+ "handle": {
+ "type": "string",
+ "format": "handle",
+ "description": "the validated handle of the account or 'handle.invalid' if the handle did not bi-directionally match the DID document"
+ },
+ "pds": {
+ "type": "string",
+ "format": "uri",
+ "description": "the identity's PDS URL"
+ },
+ "signing_key": {
+ "type": "string",
+ "description": "the atproto signing key publicKeyMultibase"
+ }
+ }
+ }
+ }
+ }
+ },
+ "$type": "com.atproto.lexicon.schema",
+ "lexicon": 1
+}
diff --git a/lexicons/blue/microcosm/links/getBacklinks.json b/lexicons/blue/microcosm/links/getBacklinks.json
new file mode 100644
index 000000000..e6e3d9d02
--- /dev/null
+++ b/lexicons/blue/microcosm/links/getBacklinks.json
@@ -0,0 +1,87 @@
+{
+ "id": "blue.microcosm.links.getBacklinks",
+ "defs": {
+ "main": {
+ "type": "query",
+ "description": "Constellation: list records linking to any record, identity, or uri",
+ "parameters": {
+ "type": "params",
+ "required": ["subject", "source"],
+ "properties": {
+ "subject": {
+ "type": "string",
+ "format": "uri",
+ "description": "the target being linked to (at-uri, did, or uri)"
+ },
+ "source": {
+ "type": "string",
+ "description": "collection and path specification (e.g., 'app.bsky.feed.like:subject.uri')"
+ },
+ "did": {
+ "type": "array",
+ "description": "filter links to those from specific users",
+ "items": {
+ "type": "string",
+ "format": "did"
+ }
+ },
+ "limit": {
+ "type": "integer",
+ "minimum": 1,
+ "maximum": 100,
+ "default": 16,
+ "description": "number of results to return"
+ }
+ }
+ },
+ "output": {
+ "encoding": "application/json",
+ "schema": {
+ "type": "object",
+ "required": ["total", "records"],
+ "properties": {
+ "total": {
+ "type": "integer",
+ "description": "total number of matching links"
+ },
+ "records": {
+ "type": "array",
+ "items": {
+ "type": "ref",
+ "ref": "#linkRecord"
+ }
+ },
+ "cursor": {
+ "type": "string",
+ "description": "pagination cursor"
+ }
+ }
+ }
+ }
+ },
+ "linkRecord": {
+ "type": "object",
+ "description": "a record linking to the subject",
+ "required": ["did", "collection", "rkey"],
+ "properties": {
+ "did": {
+ "type": "string",
+ "format": "did",
+ "description": "the DID of the linking record's repository"
+ },
+ "collection": {
+ "type": "string",
+ "format": "nsid",
+ "description": "the collection of the linking record"
+ },
+ "rkey": {
+ "type": "string",
+ "format": "record-key",
+ "description": "the record key of the linking record"
+ }
+ }
+ }
+ },
+ "$type": "com.atproto.lexicon.schema",
+ "lexicon": 1
+}
diff --git a/lexicons/blue/microcosm/links/getBacklinksCount.json b/lexicons/blue/microcosm/links/getBacklinksCount.json
new file mode 100644
index 000000000..d8ee34f7b
--- /dev/null
+++ b/lexicons/blue/microcosm/links/getBacklinksCount.json
@@ -0,0 +1,39 @@
+{
+ "id": "blue.microcosm.links.getBacklinksCount",
+ "defs": {
+ "main": {
+ "type": "query",
+ "description": "Constellation: count records that link to another record",
+ "parameters": {
+ "type": "params",
+ "required": ["subject", "source"],
+ "properties": {
+ "subject": {
+ "type": "string",
+ "format": "at-uri",
+ "description": "the target being linked to (at-uri, did, or uri)"
+ },
+ "source": {
+ "type": "string",
+ "description": "collection and path specification for the primary link"
+ }
+ }
+ },
+ "output": {
+ "encoding": "application/json",
+ "schema": {
+ "type": "object",
+ "required": ["total"],
+ "properties": {
+ "total": {
+ "type": "integer",
+ "description": "total number of matching links"
+ }
+ }
+ }
+ }
+ }
+ },
+ "$type": "com.atproto.lexicon.schema",
+ "lexicon": 1
+}
diff --git a/lexicons/blue/microcosm/links/getManyToManyCounts.json b/lexicons/blue/microcosm/links/getManyToManyCounts.json
new file mode 100644
index 000000000..3363509ae
--- /dev/null
+++ b/lexicons/blue/microcosm/links/getManyToManyCounts.json
@@ -0,0 +1,91 @@
+{
+ "id": "blue.microcosm.links.getManyToManyCounts",
+ "defs": {
+ "main": {
+ "type": "query",
+ "description": "Constellation: count many-to-many relationships with secondary link paths",
+ "parameters": {
+ "type": "params",
+ "required": ["subject", "source", "pathToOther"],
+ "properties": {
+ "subject": {
+ "type": "string",
+ "format": "uri",
+ "description": "the primary target being linked to (at-uri, did, or uri)"
+ },
+ "source": {
+ "type": "string",
+ "description": "collection and path specification for the primary link"
+ },
+ "pathToOther": {
+ "type": "string",
+ "description": "path to the secondary link in the many-to-many record (e.g., 'otherThing.uri')"
+ },
+ "did": {
+ "type": "array",
+ "description": "filter links to those from specific users",
+ "items": {
+ "type": "string",
+ "format": "did"
+ }
+ },
+ "otherSubject": {
+ "type": "array",
+ "description": "filter secondary links to specific subjects",
+ "items": {
+ "type": "string"
+ }
+ },
+ "limit": {
+ "type": "integer",
+ "minimum": 1,
+ "maximum": 100,
+ "default": 16,
+ "description": "number of results to return"
+ }
+ }
+ },
+ "output": {
+ "encoding": "application/json",
+ "schema": {
+ "type": "object",
+ "required": ["counts_by_other_subject"],
+ "properties": {
+ "counts_by_other_subject": {
+ "type": "array",
+ "items": {
+ "type": "ref",
+ "ref": "#countBySubject"
+ }
+ },
+ "cursor": {
+ "type": "string",
+ "description": "pagination cursor"
+ }
+ }
+ }
+ }
+ },
+ "countBySubject": {
+ "type": "object",
+ "description": "count of links to a secondary subject",
+ "required": ["subject", "total", "distinct"],
+ "properties": {
+ "subject": {
+ "type": "string",
+ "description": "the secondary subject being counted"
+ },
+ "total": {
+ "type": "integer",
+ "description": "total number of links to this subject"
+ },
+ "distinct": {
+ "type": "integer",
+ "description": "number of distinct DIDs linking to this subject"
+ }
+ }
+ }
+ },
+ "$type": "com.atproto.lexicon.schema",
+ "lexicon": 1
+}
diff --git a/lexicons/blue/microcosm/repo/getRecordByUri.json b/lexicons/blue/microcosm/repo/getRecordByUri.json
new file mode 100644
index 000000000..e99472e0b
--- /dev/null
+++ b/lexicons/blue/microcosm/repo/getRecordByUri.json
@@ -0,0 +1,50 @@
+{
+ "id": "blue.microcosm.repo.getRecordByUri",
+ "defs": {
+ "main": {
+ "type": "query",
+ "description": "Slingshot: ergonomic complement to com.atproto.repo.getRecord which accepts an at-uri instead of individual repo/collection/rkey params",
+ "parameters": {
+ "type": "params",
+ "required": ["at_uri"],
+ "properties": {
+ "at_uri": {
+ "type": "string",
+ "format": "at-uri",
+ "description": "the at-uri of the record (identifier can be a DID or handle)"
+ },
+ "cid": {
+ "type": "string",
+ "format": "cid",
+ "description": "optional CID of the version of the record. if not specified, return the most recent version. if specified and a newer version exists, returns 404."
+ }
+ }
+ },
+ "output": {
+ "encoding": "application/json",
+ "schema": {
+ "type": "object",
+ "required": ["uri", "value"],
+ "properties": {
+ "uri": {
+ "type": "string",
+ "format": "at-uri",
+ "description": "at-uri for this record"
+ },
+ "cid": {
+ "type": "string",
+ "format": "cid",
+ "description": "CID for this exact version of the record"
+ },
+ "value": {
+ "type": "unknown",
+ "description": "the record itself"
+ }
+ }
+ }
+ }
+ }
+ },
+ "$type": "com.atproto.lexicon.schema",
+ "lexicon": 1
+}
diff --git a/lexicons/dev/npmx/actor/profile.json b/lexicons/dev/npmx/actor/profile.json
new file mode 100644
index 000000000..34a4a14f3
--- /dev/null
+++ b/lexicons/dev/npmx/actor/profile.json
@@ -0,0 +1,38 @@
+{
+ "lexicon": 1,
+ "id": "dev.npmx.actor.profile",
+ "defs": {
+ "main": {
+ "key": "literal:self",
+ "type": "record",
+ "record": {
+ "type": "object",
+ "required": ["displayName"],
+ "properties": {
+ "avatar": {
+ "type": "blob",
+ "accept": ["image/png", "image/jpeg"],
+ "maxSize": 1000000,
+ "description": "AKA, 'profile picture'"
+ },
+ "website": {
+ "type": "string",
+ "format": "uri"
+ },
+ "description": {
+ "type": "string",
+ "maxLength": 2560,
+ "description": "Free-form profile description text.",
+ "maxGraphemes": 256
+ },
+ "displayName": {
+ "type": "string",
+ "maxLength": 640,
+ "maxGraphemes": 64
+ }
+ }
+ },
+ "description": "A declaration of an npmx account profile."
+ }
+ }
+}
diff --git a/lunaria/files/en-GB.json b/lunaria/files/en-GB.json
index 2ca555b8c..42611bf9e 100644
--- a/lunaria/files/en-GB.json
+++ b/lunaria/files/en-GB.json
@@ -143,7 +143,28 @@
"role": "role",
"members": "members"
},
- "scroll_to_top": "Scroll to top"
+ "scroll_to_top": "Scroll to top",
+ "cancel": "Cancel",
+ "save": "Save",
+ "edit": "Edit",
+ "error": "Error"
+ },
+ "profile": {
+ "display_name": "Display Name",
+ "description": "Description",
+ "no_description": "No description",
+ "website": "Website",
+ "website_placeholder": "https://example.com",
+ "likes": "Likes",
+ "seo_title": "{handle} - npmx",
+ "seo_description": "npmx profile by {handle}",
+ "not_found": "Profile Not Found",
+ "not_found_message": "The profile for {handle} could not be found.",
+ "invite": {
+ "message": "It doesn't look like they're using npmx yet. Want to tell them about it?",
+ "share_button": "Share on Bluesky",
+ "compose_text": "Hey {'@'}{handle}! Have you checked out npmx.dev yet? It's a browser for the npm registry that's fast, modern, and open-source.\nhttps://npmx.dev"
+ }
},
"package": {
"not_found": "Package Not Found",
@@ -932,7 +953,8 @@
"connect_bluesky": "Connect with Bluesky",
"what_is_atmosphere": "What is an Atmosphere account?",
"atmosphere_explanation": "{npmx} uses the {atproto} to power many of its social features, allowing users to own their data and use one account for all compatible applications. Once you create an account, you can use other apps like {bluesky} and {tangled} with the same account.",
- "default_input_error": "Please enter a valid handle, DID, or a full PDS URL"
+ "default_input_error": "Please enter a valid handle, DID, or a full PDS URL",
+ "profile": "Profile"
}
},
"header": {
diff --git a/lunaria/files/en-US.json b/lunaria/files/en-US.json
index 2fc0c472c..a63a6cba4 100644
--- a/lunaria/files/en-US.json
+++ b/lunaria/files/en-US.json
@@ -143,7 +143,28 @@
"role": "role",
"members": "members"
},
- "scroll_to_top": "Scroll to top"
+ "scroll_to_top": "Scroll to top",
+ "cancel": "Cancel",
+ "save": "Save",
+ "edit": "Edit",
+ "error": "Error"
+ },
+ "profile": {
+ "display_name": "Display Name",
+ "description": "Description",
+ "no_description": "No description",
+ "website": "Website",
+ "website_placeholder": "https://example.com",
+ "likes": "Likes",
+ "seo_title": "{handle} - npmx",
+ "seo_description": "npmx profile by {handle}",
+ "not_found": "Profile Not Found",
+ "not_found_message": "The profile for {handle} could not be found.",
+ "invite": {
+ "message": "It doesn't look like they're using npmx yet. Want to tell them about it?",
+ "share_button": "Share on Bluesky",
+ "compose_text": "Hey {'@'}{handle}! Have you checked out npmx.dev yet? It's a browser for the npm registry that's fast, modern, and open-source.\nhttps://npmx.dev"
+ }
},
"package": {
"not_found": "Package Not Found",
@@ -932,7 +953,8 @@
"connect_bluesky": "Connect with Bluesky",
"what_is_atmosphere": "What is an Atmosphere account?",
"atmosphere_explanation": "{npmx} uses the {atproto} to power many of its social features, allowing users to own their data and use one account for all compatible applications. Once you create an account, you can use other apps like {bluesky} and {tangled} with the same account.",
- "default_input_error": "Please enter a valid handle, DID, or a full PDS URL"
+ "default_input_error": "Please enter a valid handle, DID, or a full PDS URL",
+ "profile": "Profile"
}
},
"header": {
diff --git a/server/api/auth/atproto.get.ts b/server/api/auth/atproto.get.ts
index 2c7836d0d..9473780bf 100644
--- a/server/api/auth/atproto.get.ts
+++ b/server/api/auth/atproto.get.ts
@@ -6,15 +6,18 @@ import { SLINGSHOT_HOST } from '#shared/utils/constants'
import { useServerSession } from '#server/utils/server-session'
import { handleApiError } from '#server/utils/error-handler'
import type { DidString } from '@atproto/lex'
-import { Client } from '@atproto/lex'
-import * as com from '#shared/types/lexicons/com'
+import { Client, isAtUriString } from '@atproto/lex'
import * as app from '#shared/types/lexicons/app'
+import * as blue from '#shared/types/lexicons/blue'
import { isAtIdentifierString } from '@atproto/lex'
import { scope } from '#server/utils/atproto/oauth'
import { UNSET_NUXT_SESSION_PASSWORD } from '#shared/utils/constants'
// @ts-expect-error virtual file from oauth module
import { clientUri } from '#oauth/config'
+const OAUTH_REQUEST_COOKIE_PREFIX = 'atproto_oauth_req'
+const slingshotClient = new Client({ service: `https://${SLINGSHOT_HOST}` })
+
export default defineEventHandler(async event => {
const config = useRuntimeConfig(event)
if (!config.sessionPassword) {
@@ -81,8 +84,12 @@ export default defineEventHandler(async event => {
try {
const state = decodeOAuthState(event, result.state)
const profile = await getMiniProfile(result.session)
+ const npmxProfile = await getNpmxProfile(profile.handle, result.session)
- await session.update({ public: profile })
+ await session.update({
+ public: profile,
+ profile: npmxProfile,
+ })
return sendRedirect(event, state.redirectPath)
} catch (error) {
// If we are unable to cleanly handle the callback, meaning that the
@@ -118,8 +125,6 @@ type OAuthStateData = {
redirectPath: string
}
-const OAUTH_REQUEST_COOKIE_PREFIX = 'atproto_oauth_req'
-
/**
* This function encodes the OAuth state by generating a random SID, storing it
* in a cookie, and returning a JSON string containing the original state and
@@ -220,8 +225,7 @@ function decodeOAuthState(event: H3Event, state: string | null): OAuthStateData
* @returns An object containing the user's DID, handle, PDS, and avatar URL (if available)
*/
async function getMiniProfile(authSession: OAuthSession) {
- const client = new Client({ service: `https://${SLINGSHOT_HOST}` })
- const response = await client.xrpcSafe(com['bad-example'].identity.resolveMiniDoc, {
+ const response = await slingshotClient.xrpcSafe(blue.microcosm.identity.resolveMiniDoc, {
headers: { 'User-Agent': 'npmx' },
params: { identifier: authSession.did },
})
@@ -279,3 +283,38 @@ async function getAvatar(did: DidString, pds: string) {
}
return avatar
}
+
+async function getNpmxProfile(handle: string, authSession: OAuthSession) {
+ const client = new Client(authSession)
+
+ // get existing npmx profile OR create a new one
+ const profileUri = `at://${client.did}/dev.npmx.actor.profile/self`
+ if (!isAtUriString(profileUri)) {
+ throw new Error(`Invalid at-uri: ${profileUri}`)
+ }
+
+ const profileResult = await slingshotClient.xrpcSafe(blue.microcosm.repo.getRecordByUri, {
+ headers: { 'User-Agent': 'npmx' },
+ params: { at_uri: profileUri },
+ })
+
+ if (profileResult.success) {
+ return profileResult.body.value
+ } else {
+ const profile = {
+ website: '',
+ displayName: handle,
+ description: '',
+ }
+
+ await client.createRecord(
+ {
+ $type: 'dev.npmx.actor.profile',
+ ...profile,
+ },
+ 'self',
+ )
+
+ return profile
+ }
+}
diff --git a/server/api/social/profile/[identifier]/index.get.ts b/server/api/social/profile/[identifier]/index.get.ts
new file mode 100644
index 000000000..73667f443
--- /dev/null
+++ b/server/api/social/profile/[identifier]/index.get.ts
@@ -0,0 +1,13 @@
+export default defineEventHandler(async event => {
+ const identifier = getRouterParam(event, 'identifier')
+ if (!identifier) {
+ throw createError({
+ status: 400,
+ message: 'identifier not provided',
+ })
+ }
+
+ const profileUtil = new ProfileUtils()
+ const profile = await profileUtil.getProfile(identifier)
+ return profile
+})
diff --git a/server/api/social/profile/[identifier]/index.put.ts b/server/api/social/profile/[identifier]/index.put.ts
new file mode 100644
index 000000000..a93e196ac
--- /dev/null
+++ b/server/api/social/profile/[identifier]/index.put.ts
@@ -0,0 +1,45 @@
+import { parse } from 'valibot'
+import { Client } from '@atproto/lex'
+import * as dev from '#shared/types/lexicons/dev'
+import type { NPMXProfile } from '#shared/types/social'
+import { ProfileEditBodySchema } from '#shared/schemas/social'
+
+export default eventHandlerWithOAuthSession(async (event, oAuthSession) => {
+ const loggedInUsersDid = oAuthSession?.did.toString()
+
+ if (!oAuthSession || !loggedInUsersDid) {
+ throw createError({ statusCode: 401, statusMessage: 'Unauthorized' })
+ }
+
+ await throwOnMissingOAuthScope(oAuthSession, PROFILE_SCOPE)
+
+ const body = parse(ProfileEditBodySchema, await readBody(event))
+ const client = new Client(oAuthSession)
+
+ const profile = dev.npmx.actor.profile.$build({
+ displayName: body.displayName,
+ ...(body.description
+ ? {
+ description: body.description,
+ }
+ : {}),
+ ...(body.website
+ ? {
+ website: body.website as `${string}:${string}`,
+ }
+ : {}),
+ })
+
+ const result = await client.put(dev.npmx.actor.profile, profile, { rkey: 'self' })
+ if (!result) {
+ throw createError({
+ status: 500,
+ message: 'Failed to update the profile',
+ })
+ }
+
+ const profileUtil = new ProfileUtils()
+ await profileUtil.updateProfileCache(loggedInUsersDid, body as NPMXProfile)
+
+ return result.validationStatus
+})
diff --git a/server/api/social/profile/[identifier]/likes.get.ts b/server/api/social/profile/[identifier]/likes.get.ts
new file mode 100644
index 000000000..85cd11621
--- /dev/null
+++ b/server/api/social/profile/[identifier]/likes.get.ts
@@ -0,0 +1,17 @@
+import { IdentityUtils } from '#server/utils/atproto/utils/identity'
+
+export default defineEventHandler(async event => {
+ const identifier = getRouterParam(event, 'identifier')
+ if (!identifier) {
+ throw createError({
+ status: 400,
+ message: 'identifier not provided',
+ })
+ }
+
+ const utils = new IdentityUtils()
+ const minidoc = await utils.getMiniDoc(identifier)
+ const likesUtil = new PackageLikesUtils()
+
+ return likesUtil.getUserLikes(minidoc)
+})
diff --git a/server/utils/atproto/oauth.ts b/server/utils/atproto/oauth.ts
index 25ef84e6d..58152fd2b 100644
--- a/server/utils/atproto/oauth.ts
+++ b/server/utils/atproto/oauth.ts
@@ -9,13 +9,13 @@ import type { EventHandlerRequest, H3Event, SessionManager } from 'h3'
import { NodeOAuthClient, AtprotoDohHandleResolver } from '@atproto/oauth-client-node'
import { getOAuthLock } from '#server/utils/atproto/lock'
import { useOAuthStorage } from '#server/utils/atproto/storage'
-import { LIKES_SCOPE } from '#shared/utils/constants'
+import { LIKES_SCOPE, PROFILE_SCOPE } from '#shared/utils/constants'
import type { UserServerSession } from '#shared/types/userSession'
// @ts-expect-error virtual file from oauth module
import { clientUri } from '#oauth/config'
// TODO: If you add writing a new record you will need to add a scope for it
-export const scope = `atproto ${LIKES_SCOPE}`
+export const scope = `atproto ${LIKES_SCOPE} ${PROFILE_SCOPE}`
/**
* Resolves a did to a handle via DoH or via the http website calls
diff --git a/server/utils/atproto/utils/identity.ts b/server/utils/atproto/utils/identity.ts
new file mode 100644
index 000000000..f62a2f8aa
--- /dev/null
+++ b/server/utils/atproto/utils/identity.ts
@@ -0,0 +1,44 @@
+import { Client, isAtIdentifierString } from '@atproto/lex'
+import * as blue from '#shared/types/lexicons/blue'
+
+const HEADERS = { 'User-Agent': 'npmx' }
+
+// Aggressive cache on identity since that doesn't change a ton
+const CACHE_MAX_AGE_IDENTITY = CACHE_MAX_AGE_ONE_HOUR * 6
+
+const CACHE_KEY_IDENTITY = (identity: string) => `identity:${identity}`
+
+export class IdentityUtils {
+ private readonly cache: CacheAdapter
+ private readonly slingShotClient: Client
+ constructor() {
+ this.cache = getCacheAdapter('generic')
+ this.slingShotClient = new Client(`https://${SLINGSHOT_HOST}`, {
+ headers: HEADERS,
+ })
+ }
+
+ /**
+ * Gets the user's mini doc from slingshot
+ * @param identifier - A users did or handle
+ * @returns
+ */
+ async getMiniDoc(
+ identifier: string,
+ ): Promise {
+ if (!isAtIdentifierString(identifier)) {
+ throw createError({ status: 400, message: 'Invalid AT Protocol identifier' })
+ }
+ const cacheKey = CACHE_KEY_IDENTITY(identifier)
+ const cached =
+ await this.cache.get(cacheKey)
+ if (cached) {
+ return cached
+ }
+ const result = await this.slingShotClient.call(blue.microcosm.identity.resolveMiniDoc, {
+ identifier,
+ })
+ await this.cache.set(cacheKey, result, CACHE_MAX_AGE_IDENTITY)
+ return result
+ }
+}
diff --git a/server/utils/atproto/utils/likes.ts b/server/utils/atproto/utils/likes.ts
index ac042d707..a95810caa 100644
--- a/server/utils/atproto/utils/likes.ts
+++ b/server/utils/atproto/utils/likes.ts
@@ -1,5 +1,8 @@
import { $nsid as likeNsid } from '#shared/types/lexicons/dev/npmx/feed/like.defs'
import type { Backlink } from '#shared/utils/constellation'
+import type * as blue from '#shared/types/lexicons/blue'
+import * as dev from '#shared/types/lexicons/dev'
+import { Client } from '@atproto/lex'
import { TID } from '@atproto/common'
//Cache keys and helpers
@@ -281,6 +284,26 @@ export class PackageLikesUtils {
}
/**
+ * Gets a list of likes for a user. Newest first
+ * @param miniDoc
+ * @param limit
+ * @returns
+ */
+ async getUserLikes(
+ miniDoc: blue.microcosm.identity.resolveMiniDoc.$OutputBody,
+ limit: number = 10,
+ ) {
+ const client = new Client(miniDoc.pds, {
+ headers: { 'User-Agent': 'npmx' },
+ })
+ const result = await client.list(dev.npmx.feed.like, {
+ limit,
+ repo: miniDoc.did,
+ })
+ return result
+ }
+
+ /*
* Gets the likes evolution for a package as daily {day, likes} points.
* Fetches ALL backlinks via paginated constellation calls, decodes TID
* timestamps from each rkey, and groups by day.
diff --git a/server/utils/atproto/utils/profile.ts b/server/utils/atproto/utils/profile.ts
new file mode 100644
index 000000000..0ead2d5a9
--- /dev/null
+++ b/server/utils/atproto/utils/profile.ts
@@ -0,0 +1,110 @@
+import type { MiniDoc, NPMXProfile } from '#shared/types/social'
+import * as blue from '#shared/types/lexicons/blue'
+import * as dev from '#shared/types/lexicons/dev'
+import { Client, isAtIdentifierString, isAtUriString } from '@atproto/lex'
+
+//Cache keys and helpers
+const CACHE_PREFIX = 'atproto-profile:'
+const CACHE_PROFILE_MINI_DOC = (handle: string) => `${CACHE_PREFIX}${handle}:minidoc`
+const CACHE_PROFILE_KEY = (did: string) => `${CACHE_PREFIX}${did}:profile`
+
+const CACHE_MAX_AGE = CACHE_MAX_AGE_ONE_MINUTE * 5
+
+/**
+ * Logic to handle and update profile queries
+ */
+export class ProfileUtils {
+ private readonly cache: CacheAdapter
+ private readonly slingshotClient: Client
+
+ constructor() {
+ this.cache = getCacheAdapter('generic')
+ this.slingshotClient = new Client({ service: `https://${SLINGSHOT_HOST}` })
+ }
+
+ private async slingshotMiniDoc(handle: string) {
+ const miniDocKey = CACHE_PROFILE_MINI_DOC(handle)
+ const cachedMiniDoc = await this.cache.get(miniDocKey)
+
+ let miniDoc
+ if (cachedMiniDoc) {
+ miniDoc = cachedMiniDoc
+ } else {
+ if (!isAtIdentifierString(handle)) {
+ throw createError({
+ status: 400,
+ message: `Invalid at-identifier: ${handle}`,
+ })
+ }
+
+ const response = await this.slingshotClient.xrpcSafe(blue.microcosm.identity.resolveMiniDoc, {
+ headers: { 'User-Agent': 'npmx' },
+ params: { identifier: handle },
+ })
+ if (!response.success) {
+ // Not always, but usually this will mean the profile cannot be found
+ // and can be assumed most of the time it does not exists
+ throw createError({
+ status: 404,
+ message: `Failed to resolve MiniDoc for ${handle}`,
+ })
+ }
+
+ miniDoc = response.body
+ await this.cache.set(miniDocKey, miniDoc, CACHE_MAX_AGE)
+ }
+
+ return miniDoc
+ }
+
+ /**
+ * Gets an npmx profile based on a handle
+ * @param handle
+ * @returns
+ */
+ async getProfile(handle: string): Promise {
+ const miniDoc = await this.slingshotMiniDoc(handle)
+ const profileKey = CACHE_PROFILE_KEY(miniDoc.did)
+ const cachedProfile = await this.cache.get(profileKey)
+
+ let profile: NPMXProfile | undefined
+ if (cachedProfile) {
+ profile = cachedProfile
+ } else {
+ const profileUri = `at://${miniDoc.did}/dev.npmx.actor.profile/self`
+ if (!isAtUriString(profileUri)) {
+ throw new Error(`Invalid at-uri: ${profileUri}`)
+ }
+
+ const response = await this.slingshotClient.xrpcSafe(blue.microcosm.repo.getRecordByUri, {
+ headers: { 'User-Agent': 'npmx' },
+ params: { at_uri: profileUri },
+ })
+
+ if (response.success) {
+ const validationResult = dev.npmx.actor.profile.$validate(response.body.value)
+ profile = { recordExists: true, ...validationResult }
+ await this.cache.set(profileKey, profile, CACHE_MAX_AGE)
+ } else {
+ if (response.error === 'RecordNotFound') {
+ return {
+ recordExists: false,
+ displayName: miniDoc.handle,
+ description: '',
+ website: '',
+ }
+ }
+ throw new Error(`Failed to fetch profile: ${response.error}`)
+ }
+ }
+
+ return profile
+ }
+
+ async updateProfileCache(handle: string, profile: NPMXProfile): Promise {
+ const miniDoc = await this.slingshotMiniDoc(handle)
+ const profileKey = CACHE_PROFILE_KEY(miniDoc.did)
+ await this.cache.set(profileKey, profile, CACHE_MAX_AGE)
+ return profile
+ }
+}
diff --git a/shared/schemas/social.ts b/shared/schemas/social.ts
index 942ee9120..f0c018e11 100644
--- a/shared/schemas/social.ts
+++ b/shared/schemas/social.ts
@@ -9,3 +9,12 @@ export const PackageLikeBodySchema = v.object({
})
export type PackageLikeBody = v.InferOutput
+
+// TODO: add 'avatar'
+export const ProfileEditBodySchema = v.object({
+ displayName: v.pipe(v.string(), v.maxLength(640)),
+ website: v.optional(v.union([v.literal(''), v.pipe(v.string(), v.url())])),
+ description: v.optional(v.pipe(v.string(), v.maxLength(2560))),
+})
+
+export type ProfileEditBody = v.InferOutput
diff --git a/shared/types/social.ts b/shared/types/social.ts
index a40c1e7b5..834c8ffea 100644
--- a/shared/types/social.ts
+++ b/shared/types/social.ts
@@ -7,3 +7,26 @@ export type PackageLikes = {
// If the logged in user has liked the package, false if not logged in
userHasLiked: boolean
}
+
+/**
+ * A shortened DID Doc for AT Protocol accounts
+ * Returned by Slingshot's `/xrpc/blue.microcosm.identity.resolveMiniDoc` endpoint
+ */
+export type MiniDoc = {
+ did: string
+ handle: string
+ pds: string
+ signing_key: string
+}
+
+/**
+ * NPMX Profile details
+ * TODO: avatar
+ */
+export type NPMXProfile = {
+ displayName: string
+ website?: string
+ description?: string
+ // If the atproto record exists for the profile
+ recordExists: boolean
+}
diff --git a/shared/types/userSession.ts b/shared/types/userSession.ts
index e088d2127..53bb09d71 100644
--- a/shared/types/userSession.ts
+++ b/shared/types/userSession.ts
@@ -11,6 +11,12 @@ export interface UserServerSession {
}
| undefined
+ profile: {
+ website?: string
+ description?: string
+ displayName?: string
+ }
+
// DO NOT USE
// Here for historic reasons to redirect users logged in with the previous oauth to login again
oauthSession?: NodeSavedSession | undefined
diff --git a/shared/utils/constants.ts b/shared/utils/constants.ts
index eb18e8cef..4b80623c4 100644
--- a/shared/utils/constants.ts
+++ b/shared/utils/constants.ts
@@ -45,6 +45,7 @@ export const PACKAGE_SUBJECT_REF = (packageName: string) =>
`https://npmx.dev/package/${packageName}`
// OAuth scopes as we add new ones we need to check these on certain actions. If not redirect the user to login again to upgrade the scopes
export const LIKES_SCOPE = `repo:${dev.npmx.feed.like.$nsid}`
+export const PROFILE_SCOPE = `repo:${dev.npmx.actor.profile.$nsid}`
// Theming
export const ACCENT_COLORS = {
diff --git a/test/nuxt/a11y.spec.ts b/test/nuxt/a11y.spec.ts
index c797b2577..744e3c00c 100644
--- a/test/nuxt/a11y.spec.ts
+++ b/test/nuxt/a11y.spec.ts
@@ -217,6 +217,7 @@ import HeaderAccountMenuServer from '~/components/Header/AccountMenu.server.vue'
import ToggleServer from '~/components/Settings/Toggle.server.vue'
import SearchProviderToggleServer from '~/components/SearchProviderToggle.server.vue'
import PackageTrendsChart from '~/components/Package/TrendsChart.vue'
+import PackageLikeCard from '~/components/Package/LikeCard.vue'
import SizeIncrease from '~/components/Package/SizeIncrease.vue'
describe('component accessibility audits', () => {
@@ -688,6 +689,16 @@ describe('component accessibility audits', () => {
})
})
+ describe('PackageLikeCard', () => {
+ it('should have no accessibility violations', async () => {
+ const component = await mountSuspended(PackageLikeCard, {
+ props: { packageUrl: 'https://npmx.dev/package/vue' },
+ })
+ const results = await runAxe(component)
+ expect(results.violations).toEqual([])
+ })
+ })
+
// Note: PackageWeeklyDownloadStats tests are skipped because vue-data-ui VueUiSparkline
// component has issues in the test environment (requires DOM measurements that aren't
// available during SSR-like test mounting).