···11import { Agent } from '@atproto/api'
22import { NodeOAuthClient } from '@atproto/oauth-client-node'
33import { createError, getQuery, sendRedirect } from 'h3'
44+import { getOAuthLock } from '#server/utils/atproto/lock'
45import { useOAuthStorage } from '#server/utils/atproto/storage'
56import { SLINGSHOT_HOST } from '#shared/utils/constants'
67import { useServerSession } from '#server/utils/server-session'
···1112 if (!config.sessionPassword) {
1213 throw createError({
1314 status: 500,
1414- message: 'NUXT_SESSION_PASSWORD not set',
1515+ message: UNSET_NUXT_SESSION_PASSWORD,
1516 })
1617 }
1718···2425 stateStore,
2526 sessionStore,
2627 clientMetadata,
2828+ requestLock: getOAuthLock(),
2729 })
28302931 if (!query.code) {
+41
server/api/social/like.delete.ts
···11+import * as v from 'valibot'
22+import { Client } from '@atproto/lex'
33+import * as dev from '#shared/types/lexicons/dev'
44+import { PackageLikeBodySchema } from '#shared/schemas/social'
55+import { throwOnMissingOAuthScope } from '#server/utils/atproto/oauth'
66+77+export default eventHandlerWithOAuthSession(async (event, oAuthSession) => {
88+ const loggedInUsersDid = oAuthSession?.did.toString()
99+1010+ if (!oAuthSession || !loggedInUsersDid) {
1111+ throw createError({ statusCode: 401, statusMessage: 'Unauthorized' })
1212+ }
1313+1414+ //Checks if the user has a scope to like packages
1515+ await throwOnMissingOAuthScope(oAuthSession, LIKES_SCOPE)
1616+1717+ const body = v.parse(PackageLikeBodySchema, await readBody(event))
1818+1919+ const likesUtil = new PackageLikesUtils()
2020+2121+ const getTheUsersLikedRecord = await likesUtil.getTheUsersLikedRecord(
2222+ body.packageName,
2323+ loggedInUsersDid,
2424+ )
2525+2626+ if (getTheUsersLikedRecord) {
2727+ const client = new Client(oAuthSession)
2828+2929+ await client.delete(dev.npmx.feed.like, {
3030+ rkey: getTheUsersLikedRecord.rkey,
3131+ })
3232+ const result = await likesUtil.unlikeAPackageAndReturnLikes(body.packageName, loggedInUsersDid)
3333+ return result
3434+ }
3535+3636+ console.warn(
3737+ `User ${loggedInUsersDid} tried to unlike a package ${body.packageName} but it was not liked by them.`,
3838+ )
3939+4040+ return await likesUtil.getLikes(body.packageName, loggedInUsersDid)
4141+})
+46
server/api/social/like.post.ts
···11+import * as v from 'valibot'
22+import { Client } from '@atproto/lex'
33+import * as dev from '#shared/types/lexicons/dev'
44+import type { UriString } from '@atproto/lex'
55+import { LIKES_SCOPE } from '#shared/utils/constants'
66+import { PackageLikeBodySchema } from '#shared/schemas/social'
77+import { throwOnMissingOAuthScope } from '#server/utils/atproto/oauth'
88+99+export default eventHandlerWithOAuthSession(async (event, oAuthSession) => {
1010+ const loggedInUsersDid = oAuthSession?.did.toString()
1111+1212+ if (!oAuthSession || !loggedInUsersDid) {
1313+ throw createError({ statusCode: 401, statusMessage: 'Unauthorized' })
1414+ }
1515+1616+ //Checks if the user has a scope to like packages
1717+ await throwOnMissingOAuthScope(oAuthSession, LIKES_SCOPE)
1818+1919+ const body = v.parse(PackageLikeBodySchema, await readBody(event))
2020+2121+ const likesUtil = new PackageLikesUtils()
2222+2323+ // Checks to see if the user has liked the package already
2424+ const likesResult = await likesUtil.getLikes(body.packageName, loggedInUsersDid)
2525+ if (likesResult.userHasLiked) {
2626+ return likesResult
2727+ }
2828+2929+ const subjectRef = PACKAGE_SUBJECT_REF(body.packageName)
3030+ const client = new Client(oAuthSession)
3131+3232+ const like = dev.npmx.feed.like.$build({
3333+ createdAt: new Date().toISOString(),
3434+ subjectRef: subjectRef as UriString,
3535+ })
3636+3737+ const result = await client.create(dev.npmx.feed.like, like)
3838+ if (!result) {
3939+ throw createError({
4040+ status: 500,
4141+ message: 'Failed to create a like',
4242+ })
4343+ }
4444+4545+ return await likesUtil.likeAPackageAndReturnLikes(body.packageName, loggedInUsersDid, result.uri)
4646+})
···44import { parse } from 'valibot'
55import { getOAuthLock } from '#server/utils/atproto/lock'
66import { useOAuthStorage } from '#server/utils/atproto/storage'
77+import { LIKES_SCOPE } from '#shared/utils/constants'
78import { OAuthMetadataSchema } from '#shared/schemas/oauth'
89// @ts-expect-error virtual file from oauth module
910import { clientUri } from '#oauth/config'
1010-import { useServerSession } from '#server/utils/server-session'
1111-// TODO: limit scope as features gets added. atproto just allows login so no scary login screen till we have scopes
1212-export const scope = 'atproto'
1111+// TODO: If you add writing a new record you will need to add a scope for it
1212+export const scope = `atproto ${LIKES_SCOPE}`
13131414export function getOauthClientMetadata() {
1515 const dev = import.meta.dev
···59596060 // restore using the subject
6161 return await client.restore(currentSession.tokenSet.sub)
6262+}
6363+6464+/**
6565+ * Throws if the logged in OAuth Session does not have the required scopes.
6666+ * As we add new scopes we need to check if the client has the ability to use it.
6767+ * If not need to let the client know to redirect the user to the PDS to upgrade their scopes.
6868+ * @param oAuthSession - The current OAuth session from the event
6969+ * @param requiredScopes - The required scope you are checking if you can use
7070+ */
7171+export async function throwOnMissingOAuthScope(oAuthSession: OAuthSession, requiredScopes: string) {
7272+ const tokenInfo = await oAuthSession.getTokenInfo()
7373+ if (!tokenInfo.scope.includes(requiredScopes)) {
7474+ throw createError({
7575+ status: 403,
7676+ message: ERROR_NEED_REAUTH,
7777+ })
7878+ }
6279}
63806481export function eventHandlerWithOAuthSession<T extends EventHandlerRequest, D>(
+241
server/utils/atproto/utils/likes.ts
···11+import { $nsid as likeNsid } from '#shared/types/lexicons/dev/npmx/feed/like.defs'
22+import type { Backlink } from '#shared/utils/constellation'
33+44+//Cache keys and helpers
55+const CACHE_PREFIX = 'atproto-likes:'
66+const CACHE_PACKAGE_TOTAL_KEY = (packageName: string) => `${CACHE_PREFIX}${packageName}:total`
77+const CACHE_USER_LIKES_KEY = (packageName: string, did: string) =>
88+ `${CACHE_PREFIX}${packageName}:users:${did}:liked`
99+const CACHE_USERS_BACK_LINK = (packageName: string, did: string) =>
1010+ `${CACHE_PREFIX}${packageName}:users:${did}:backlink`
1111+1212+const CACHE_MAX_AGE = CACHE_MAX_AGE_ONE_MINUTE * 5
1313+1414+/**
1515+ * Logic to handle liking, unliking, and seeing if a user has liked a package on npmx
1616+ */
1717+export class PackageLikesUtils {
1818+ private readonly constellation: Constellation
1919+ private readonly cache: CacheAdapter
2020+2121+ constructor() {
2222+ this.constellation = new Constellation(
2323+ // Passes in a fetch wrapped as cachedfetch since are already doing some heavy caching here
2424+ async <T = unknown>(
2525+ url: string,
2626+ options: Parameters<typeof $fetch>[1] = {},
2727+ _ttl?: number,
2828+ ): Promise<CachedFetchResult<T>> => {
2929+ const data = (await $fetch<T>(url, options)) as T
3030+ return { data, isStale: false, cachedAt: null }
3131+ },
3232+ )
3333+ this.cache = getCacheAdapter('generic')
3434+ }
3535+3636+ /**
3737+ * Gets the true total count of likes for a npm package from the network
3838+ * @param subjectRef
3939+ * @returns
4040+ */
4141+ private async constellationLikes(subjectRef: string) {
4242+ const { data: totalLinks } = await this.constellation.getLinksDistinctDids(
4343+ subjectRef,
4444+ likeNsid,
4545+ '.subjectRef',
4646+ //Limit doesn't matter here since we are just counting the total likes
4747+ 1,
4848+ undefined,
4949+ 0,
5050+ )
5151+ return totalLinks.total
5252+ }
5353+5454+ /**
5555+ * Checks if the user has liked the npm package from the network
5656+ * @param subjectRef
5757+ * @param usersDid
5858+ * @returns
5959+ */
6060+ private async constellationUserHasLiked(subjectRef: string, usersDid: string) {
6161+ const { data: userLikes } = await this.constellation.getBackLinks(
6262+ subjectRef,
6363+ likeNsid,
6464+ 'subjectRef',
6565+ //Limit doesn't matter here since we are just counting the total likes
6666+ 1,
6767+ undefined,
6868+ false,
6969+ [[usersDid]],
7070+ 0,
7171+ )
7272+ return userLikes.total > 0
7373+ }
7474+7575+ /**
7676+ * Gets the likes for a npm package on npmx. Tries a local cahce first, if not found uses constellation
7777+ * @param packageName
7878+ * @param usersDid
7979+ * @returns
8080+ */
8181+ async getLikes(packageName: string, usersDid?: string | undefined): Promise<PackageLikes> {
8282+ //TODO: May need to do some clean up on the package name, and maybe even hash it? some of the charcteres may be a bit odd as keys
8383+ const totalLikesKey = CACHE_PACKAGE_TOTAL_KEY(packageName)
8484+ const subjectRef = PACKAGE_SUBJECT_REF(packageName)
8585+8686+ const cachedLikes = await this.cache.get<number>(totalLikesKey)
8787+ let totalLikes = 0
8888+ if (cachedLikes) {
8989+ totalLikes = cachedLikes
9090+ } else {
9191+ totalLikes = await this.constellationLikes(subjectRef)
9292+ await this.cache.set(totalLikesKey, totalLikes, CACHE_MAX_AGE)
9393+ }
9494+9595+ let userHasLiked = false
9696+ if (usersDid) {
9797+ const userCachedLike = await this.cache.get<boolean>(
9898+ CACHE_USER_LIKES_KEY(packageName, usersDid),
9999+ )
100100+ if (userCachedLike) {
101101+ userHasLiked = userCachedLike
102102+ } else {
103103+ userHasLiked = await this.constellationUserHasLiked(subjectRef, usersDid)
104104+ await this.cache.set(
105105+ CACHE_USER_LIKES_KEY(packageName, usersDid),
106106+ userHasLiked,
107107+ CACHE_MAX_AGE,
108108+ )
109109+ }
110110+ }
111111+112112+ return {
113113+ totalLikes: totalLikes,
114114+ userHasLiked,
115115+ }
116116+ }
117117+118118+ /**
119119+ * Gets the definite answer if the user has liked a npm package. Either from the cache or the network
120120+ * @param packageName
121121+ * @param usersDid
122122+ * @returns
123123+ */
124124+ async hasTheUserLikedThePackage(packageName: string, usersDid: string) {
125125+ const cached = await this.cache.get<boolean>(CACHE_USER_LIKES_KEY(packageName, usersDid))
126126+ if (cached !== undefined) {
127127+ return cached
128128+ }
129129+ const subjectRef = PACKAGE_SUBJECT_REF(packageName)
130130+131131+ const userHasLiked = await this.constellationUserHasLiked(subjectRef, usersDid)
132132+ await this.cache.set(CACHE_USER_LIKES_KEY(packageName, usersDid), userHasLiked, CACHE_MAX_AGE)
133133+ return userHasLiked
134134+ }
135135+136136+ /**
137137+ * It is asummed it has been checked by this point that if a user has liked a package and the new like was made as a record
138138+ * to the user's atproto repostiory
139139+ * @param packageName
140140+ * @param usersDid
141141+ * @param atUri - The URI of the like record
142142+ */
143143+ async likeAPackageAndReturnLikes(
144144+ packageName: string,
145145+ usersDid: string,
146146+ atUri: string,
147147+ ): Promise<PackageLikes> {
148148+ const totalLikesKey = CACHE_PACKAGE_TOTAL_KEY(packageName)
149149+ const subjectRef = PACKAGE_SUBJECT_REF(packageName)
150150+151151+ const splitAtUri = atUri.replace('at://', '').split('/')
152152+ const collection = splitAtUri[1]
153153+ const rkey = splitAtUri[2]
154154+155155+ if (!collection || !rkey) {
156156+ throw new Error(`Invalid atUri given: ${atUri}`)
157157+ }
158158+ const backLink: Backlink = {
159159+ did: usersDid,
160160+ collection,
161161+ rkey,
162162+ }
163163+164164+ // We store the backlink incase a user is liking and unlikign rapidly. constellation takes a few seconds to capture the backlink
165165+ const usersBackLinkKey = CACHE_USERS_BACK_LINK(packageName, usersDid)
166166+ await this.cache.set(usersBackLinkKey, backLink, CACHE_MAX_AGE)
167167+168168+ let totalLikes = await this.cache.get<number>(totalLikesKey)
169169+ if (!totalLikes) {
170170+ totalLikes = await this.constellationLikes(subjectRef)
171171+ totalLikes = totalLikes + 1
172172+ await this.cache.set(totalLikesKey, totalLikes, CACHE_MAX_AGE)
173173+ }
174174+ // We already know the user has not liked the package before so set in the cache
175175+ await this.cache.set(CACHE_USER_LIKES_KEY(packageName, usersDid), true, CACHE_MAX_AGE)
176176+ return {
177177+ totalLikes: totalLikes,
178178+ userHasLiked: true,
179179+ }
180180+ }
181181+182182+ /**
183183+ * We need to get the record the user has that they liked the package
184184+ * @param packageName
185185+ * @param usersDid
186186+ * @returns
187187+ */
188188+ async getTheUsersLikedRecord(
189189+ packageName: string,
190190+ usersDid: string,
191191+ ): Promise<Backlink | undefined> {
192192+ const usersBackLinkKey = CACHE_USERS_BACK_LINK(packageName, usersDid)
193193+ const backLink = await this.cache.get<Backlink>(usersBackLinkKey)
194194+ if (backLink) {
195195+ return backLink
196196+ }
197197+198198+ const subjectRef = PACKAGE_SUBJECT_REF(packageName)
199199+ const { data: userLikes } = await this.constellation.getBackLinks(
200200+ subjectRef,
201201+ likeNsid,
202202+ 'subjectRef',
203203+ //Limit doesn't matter here since we are just counting the total likes
204204+ 1,
205205+ undefined,
206206+ false,
207207+ [[usersDid]],
208208+ 0,
209209+ )
210210+ if (userLikes.total > 0 && userLikes.records.length > 0) {
211211+ return userLikes.records[0]
212212+ }
213213+ }
214214+215215+ /**
216216+ * At this point you should have checked if the user had a record for the package on the network and removed it before updating the cache
217217+ * @param packageName
218218+ * @param usersDid
219219+ * @returns
220220+ */
221221+ async unlikeAPackageAndReturnLikes(packageName: string, usersDid: string): Promise<PackageLikes> {
222222+ const totalLikesKey = CACHE_PACKAGE_TOTAL_KEY(packageName)
223223+ const subjectRef = PACKAGE_SUBJECT_REF(packageName)
224224+225225+ let totalLikes = await this.cache.get<number>(totalLikesKey)
226226+ if (!totalLikes) {
227227+ totalLikes = await this.constellationLikes(subjectRef)
228228+ }
229229+ totalLikes = Math.max(totalLikes - 1, 0)
230230+ await this.cache.set(totalLikesKey, totalLikes, CACHE_MAX_AGE)
231231+232232+ //Clean up
233233+ await this.cache.set(CACHE_USER_LIKES_KEY(packageName, usersDid), false, CACHE_MAX_AGE)
234234+ await this.cache.delete(CACHE_USERS_BACK_LINK(packageName, usersDid))
235235+236236+ return {
237237+ totalLikes: totalLikes,
238238+ userHasLiked: false,
239239+ }
240240+ }
241241+}
+14
server/utils/cache/adapter.ts
···11+import { Redis } from '@upstash/redis'
22+33+export function getCacheAdapter(prefix: string): CacheAdapter {
44+ const config = useRuntimeConfig()
55+66+ if (!import.meta.dev && config.upstash?.redisRestUrl && config.upstash?.redisRestToken) {
77+ const redis = new Redis({
88+ url: config.upstash.redisRestUrl,
99+ token: config.upstash.redisRestToken,
1010+ })
1111+ return new RedisCacheAdapter(redis, prefix)
1212+ }
1313+ return new LocalCacheAdapter()
1414+}
+45
server/utils/cache/local.ts
···11+/**
22+ * Local cache data entry
33+ */
44+interface LocalCachedEntry<T = unknown> {
55+ value: T
66+ ttl?: number
77+ cachedAt: number
88+}
99+1010+/**
1111+ * Checks to see if a cache entry is stale locally
1212+ * @param entry - The entry from the locla cache
1313+ * @returns
1414+ */
1515+function isCacheEntryStale(entry: LocalCachedEntry): boolean {
1616+ if (!entry.ttl) return false
1717+ const now = Date.now()
1818+ const expiresAt = entry.cachedAt + entry.ttl * 1000
1919+ return now > expiresAt
2020+}
2121+2222+/**
2323+ * Local implmentation of a cache to be used during development
2424+ */
2525+export class LocalCacheAdapter implements CacheAdapter {
2626+ private readonly storage = useStorage('atproto:generic')
2727+2828+ async get<T>(key: string): Promise<T | undefined> {
2929+ const result = await this.storage.getItem<LocalCachedEntry<T>>(key)
3030+ if (!result) return
3131+ if (isCacheEntryStale(result)) {
3232+ await this.storage.removeItem(key)
3333+ return
3434+ }
3535+ return result.value
3636+ }
3737+3838+ async set<T>(key: string, value: T, ttl?: number): Promise<void> {
3939+ await this.storage.setItem(key, { value, ttl, cachedAt: Date.now() })
4040+ }
4141+4242+ async delete(key: string): Promise<void> {
4343+ await this.storage.removeItem(key)
4444+ }
4545+}
···11+/**
22+ * Generic cache adapter to allow using a local cache during development and redis in production
33+ */
44+export interface CacheAdapter {
55+ get<T>(key: string): Promise<T | undefined>
66+ set<T>(key: string, value: T, ttl?: number): Promise<void>
77+ delete(key: string): Promise<void>
88+}
+11
shared/schemas/social.ts
···11+import * as v from 'valibot'
22+import { PackageNameSchema } from './package'
33+44+/**
55+ * Schema for liking/unliking a package
66+ */
77+export const PackageLikeBodySchema = v.object({
88+ packageName: PackageNameSchema,
99+})
1010+1111+export type PackageLikeBody = v.InferOutput<typeof PackageLikeBodySchema>
+9
shared/types/social.ts
···11+/**
22+ * Likes for a npm package on npmx
33+ */
44+export type PackageLikes = {
55+ // The total likes found for the package
66+ totalLikes: number
77+ // If the logged in user has liked the package, false if not logged in
88+ userHasLiked: boolean
99+}
+10
shared/utils/constants.ts
···11+import * as dev from '#shared/types/lexicons/dev'
22+13// Duration
24export const CACHE_MAX_AGE_ONE_MINUTE = 60
35export const CACHE_MAX_AGE_FIVE_MINUTES = 60 * 5
···2527export const ERROR_GRAVATAR_FETCH_FAILED = 'Failed to fetch Gravatar profile.'
2628/** @public */
2729export const ERROR_GRAVATAR_EMAIL_UNAVAILABLE = "User's email not accessible."
3030+export const ERROR_NEED_REAUTH = 'User needs to reauthenticate'
28312932// microcosm services
3033export const CONSTELLATION_HOST = 'constellation.microcosm.blue'
3134export const SLINGSHOT_HOST = 'slingshot.microcosm.blue'
3535+3636+// ATProtocol
3737+// Refrences used to link packages to things that are not inherently atproto
3838+export const PACKAGE_SUBJECT_REF = (packageName: string) =>
3939+ `https://npmx.dev/package/${packageName}`
4040+// 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
4141+export const LIKES_SCOPE = `repo:${dev.npmx.feed.like.$nsid}`
32423343// Theming
3444export const ACCENT_COLORS = {