diff --git a/app/components/Header/GitHubModal.client.vue b/app/components/Header/GitHubModal.client.vue
new file mode 100644
index 000000000..49fd1584a
--- /dev/null
+++ b/app/components/Header/GitHubModal.client.vue
@@ -0,0 +1,40 @@
+
+
+
+
+
+
+
+
+
+
+ {{ $t('auth.github.connected_as', { username: user.username }) }}
+
+
+
+
+ {{ $t('auth.modal.disconnect') }}
+
+
+
+
+
+
{{ $t('auth.github.connect_prompt') }}
+
+ {{ $t('auth.modal.connect') }}
+
+
+
+
diff --git a/app/composables/atproto/useAtproto.ts b/app/composables/atproto/useAtproto.ts
index 6282b27bc..122830513 100644
--- a/app/composables/atproto/useAtproto.ts
+++ b/app/composables/atproto/useAtproto.ts
@@ -3,13 +3,13 @@ export const useAtproto = createSharedComposable(function useAtproto() {
data: user,
pending,
clear,
- } = useFetch('/api/auth/session', {
+ } = useFetch('/api/auth/atproto/session', {
server: false,
immediate: !import.meta.test,
})
async function logout() {
- await $fetch('/api/auth/session', {
+ await $fetch('/api/auth/atproto/session', {
method: 'delete',
})
diff --git a/app/composables/github/useGitHub.ts b/app/composables/github/useGitHub.ts
new file mode 100644
index 000000000..dbc19a99f
--- /dev/null
+++ b/app/composables/github/useGitHub.ts
@@ -0,0 +1,37 @@
+function login(redirectTo?: string) {
+ const query: Record
= {}
+ if (redirectTo) {
+ query.returnTo = redirectTo
+ }
+ navigateTo(
+ {
+ path: '/api/auth/github',
+ query,
+ },
+ { external: true },
+ )
+}
+
+export const useGitHub = createSharedComposable(function useGitHub() {
+ const {
+ data: user,
+ pending,
+ clear,
+ refresh,
+ } = useFetch<{ username: string } | null>('/api/auth/github/session', {
+ server: false,
+ immediate: !import.meta.test,
+ })
+
+ const isConnected = computed(() => !!user.value?.username)
+
+ async function logout() {
+ await $fetch('/api/auth/github/session', {
+ method: 'delete',
+ })
+
+ clear()
+ }
+
+ return { user, isConnected, pending, logout, login, refresh }
+})
diff --git a/app/composables/github/useGitHubStar.ts b/app/composables/github/useGitHubStar.ts
new file mode 100644
index 000000000..8edb78ae0
--- /dev/null
+++ b/app/composables/github/useGitHubStar.ts
@@ -0,0 +1,79 @@
+import type { RepoRef } from '#shared/utils/git-providers'
+
+type StarStatus = {
+ starred: boolean
+ connected: boolean
+}
+
+export function useGitHubStar(repoRef: Ref) {
+ const { isConnected } = useGitHub()
+
+ const isGitHubRepo = computed(() => repoRef.value?.provider === 'github')
+ const owner = computed(() => repoRef.value?.owner ?? '')
+ const repo = computed(() => repoRef.value?.repo ?? '')
+
+ const shouldFetch = computed(
+ () => isConnected.value && isGitHubRepo.value && !!owner.value && !!repo.value,
+ )
+
+ const { data: starStatus, refresh } = useFetch(
+ () => `/api/github/starred?owner=${owner.value}&repo=${repo.value}`,
+ {
+ server: false,
+ immediate: false,
+ default: () => ({ starred: false, connected: false }),
+ watch: false,
+ },
+ )
+
+ watch(
+ shouldFetch,
+ async value => {
+ if (value) {
+ await refresh()
+ }
+ },
+ { immediate: true },
+ )
+
+ const isStarred = computed(() => starStatus.value?.starred ?? false)
+ const isStarActionPending = shallowRef(false)
+
+ async function toggleStar() {
+ if (!shouldFetch.value || isStarActionPending.value) return
+
+ const currentlyStarred = isStarred.value
+
+ // Optimistic update
+ starStatus.value = {
+ starred: !currentlyStarred,
+ connected: true,
+ }
+
+ isStarActionPending.value = true
+
+ try {
+ const result = await $fetch<{ starred: boolean }>('/api/github/star', {
+ method: currentlyStarred ? 'DELETE' : 'PUT',
+ body: { owner: owner.value, repo: repo.value },
+ })
+
+ starStatus.value = { starred: result.starred, connected: true }
+ } catch {
+ // Revert on error
+ starStatus.value = {
+ starred: currentlyStarred,
+ connected: true,
+ }
+ } finally {
+ isStarActionPending.value = false
+ }
+ }
+
+ return {
+ isStarred,
+ isStarActionPending,
+ isGitHubRepo,
+ toggleStar,
+ }
+}
diff --git a/app/pages/package/[[org]]/[name].vue b/app/pages/package/[[org]]/[name].vue
index de6c114d9..e616d7f15 100644
--- a/app/pages/package/[[org]]/[name].vue
+++ b/app/pages/package/[[org]]/[name].vue
@@ -399,6 +399,9 @@ const repositoryUrl = computed(() => {
const { meta: repoMeta, repoRef, stars, starsLink, forks, forksLink } = useRepoMeta(repositoryUrl)
+const { isConnected: isGitHubConnected } = useGitHub()
+const { isStarred, isStarActionPending, isGitHubRepo, toggleStar } = useGitHubStar(repoRef)
+
const PROVIDER_ICONS: Record = {
github: 'i-simple-icons:github',
gitlab: 'i-simple-icons:gitlab',
@@ -518,7 +521,7 @@ const canonicalUrl = computed(() => {
// TODO: Maybe set this where it's not loaded here every load?
const { user } = useAtproto()
-const authModal = useModal('auth-modal')
+const atprotoModal = useModal('atproto-modal')
const { data: likesData, status: likeStatus } = useFetch(
() => `/api/social/likes/${packageName.value}`,
@@ -535,7 +538,7 @@ const isLikeActionPending = shallowRef(false)
const likeAction = async () => {
if (user.value?.handle == null) {
- authModal.open()
+ atprotoModal.open()
return
}
@@ -887,7 +890,31 @@ const showSkeleton = shallowRef(false)
-
+
+
+ {{ compactNumberFormatter.format(stars) }}
+
+
+
{{ compactNumberFormatter.format(stars) }}
diff --git a/i18n/locales/en.json b/i18n/locales/en.json
index bd30ad861..f6c8fdf38 100644
--- a/i18n/locales/en.json
+++ b/i18n/locales/en.json
@@ -84,7 +84,8 @@
"appearance": "Appearance",
"display": "Display",
"search": "Data source",
- "language": "Language"
+ "language": "Language",
+ "connections": "Connections"
},
"data_source": {
"label": "Data source",
@@ -214,6 +215,12 @@
"like": "Like this package",
"unlike": "Unlike this package"
},
+ "github_star": {
+ "star": "Star this repository on GitHub",
+ "unstar": "Unstar this repository on GitHub",
+ "connect_github": "Connect GitHub to star repos",
+ "starred": "You starred this repo"
+ },
"docs": {
"not_available": "Docs not available",
"not_available_detail": "We could not generate docs for this version."
@@ -864,6 +871,9 @@
"atmosphere_desc": "Social features & identity",
"connect_npm_cli": "Connect to npm CLI",
"connect_atmosphere": "Connect to Atmosphere",
+ "github": "GitHub",
+ "github_desc": "Star repos & integrations",
+ "connect_github": "Connect to GitHub",
"connecting": "Connecting...",
"ops": "{count} op | {count} ops"
},
@@ -881,6 +891,11 @@
"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"
+ },
+ "github": {
+ "title": "GitHub",
+ "connected_as": "Connected as {username}",
+ "connect_prompt": "Connect your GitHub account to star repositories directly from npmx."
}
},
"header": {
diff --git a/i18n/schema.json b/i18n/schema.json
index 5511972b1..31ad209c6 100644
--- a/i18n/schema.json
+++ b/i18n/schema.json
@@ -258,6 +258,9 @@
},
"language": {
"type": "string"
+ },
+ "connections": {
+ "type": "string"
}
},
"additionalProperties": false
@@ -646,6 +649,24 @@
},
"additionalProperties": false
},
+ "github_star": {
+ "type": "object",
+ "properties": {
+ "star": {
+ "type": "string"
+ },
+ "unstar": {
+ "type": "string"
+ },
+ "connect_github": {
+ "type": "string"
+ },
+ "starred": {
+ "type": "string"
+ }
+ },
+ "additionalProperties": false
+ },
"docs": {
"type": "object",
"properties": {
@@ -2596,6 +2617,15 @@
"connect_atmosphere": {
"type": "string"
},
+ "github": {
+ "type": "string"
+ },
+ "github_desc": {
+ "type": "string"
+ },
+ "connect_github": {
+ "type": "string"
+ },
"connecting": {
"type": "string"
},
@@ -2649,6 +2679,21 @@
}
},
"additionalProperties": false
+ },
+ "github": {
+ "type": "object",
+ "properties": {
+ "title": {
+ "type": "string"
+ },
+ "connected_as": {
+ "type": "string"
+ },
+ "connect_prompt": {
+ "type": "string"
+ }
+ },
+ "additionalProperties": false
}
},
"additionalProperties": false
diff --git a/lunaria/files/en-GB.json b/lunaria/files/en-GB.json
index ddfa1c6a7..1436751c5 100644
--- a/lunaria/files/en-GB.json
+++ b/lunaria/files/en-GB.json
@@ -83,7 +83,8 @@
"appearance": "Appearance",
"display": "Display",
"search": "Data source",
- "language": "Language"
+ "language": "Language",
+ "connections": "Connections"
},
"data_source": {
"label": "Data source",
@@ -213,6 +214,12 @@
"like": "Like this package",
"unlike": "Unlike this package"
},
+ "github_star": {
+ "star": "Star this repository on GitHub",
+ "unstar": "Unstar this repository on GitHub",
+ "connect_github": "Connect GitHub to star repos",
+ "starred": "You starred this repo"
+ },
"docs": {
"not_available": "Docs not available",
"not_available_detail": "We could not generate docs for this version."
@@ -863,6 +870,9 @@
"atmosphere_desc": "Social features & identity",
"connect_npm_cli": "Connect to npm CLI",
"connect_atmosphere": "Connect to Atmosphere",
+ "github": "GitHub",
+ "github_desc": "Star repos & integrations",
+ "connect_github": "Connect to GitHub",
"connecting": "Connecting...",
"ops": "{count} op | {count} ops"
},
@@ -880,6 +890,11 @@
"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"
+ },
+ "github": {
+ "title": "GitHub",
+ "connected_as": "Connected as {username}",
+ "connect_prompt": "Connect your GitHub account to star repositories directly from npmx."
}
},
"header": {
diff --git a/lunaria/files/en-US.json b/lunaria/files/en-US.json
index 42c9f3f20..f92438ece 100644
--- a/lunaria/files/en-US.json
+++ b/lunaria/files/en-US.json
@@ -83,7 +83,8 @@
"appearance": "Appearance",
"display": "Display",
"search": "Data source",
- "language": "Language"
+ "language": "Language",
+ "connections": "Connections"
},
"data_source": {
"label": "Data source",
@@ -213,6 +214,12 @@
"like": "Like this package",
"unlike": "Unlike this package"
},
+ "github_star": {
+ "star": "Star this repository on GitHub",
+ "unstar": "Unstar this repository on GitHub",
+ "connect_github": "Connect GitHub to star repos",
+ "starred": "You starred this repo"
+ },
"docs": {
"not_available": "Docs not available",
"not_available_detail": "We could not generate docs for this version."
@@ -863,6 +870,9 @@
"atmosphere_desc": "Social features & identity",
"connect_npm_cli": "Connect to npm CLI",
"connect_atmosphere": "Connect to Atmosphere",
+ "github": "GitHub",
+ "github_desc": "Star repos & integrations",
+ "connect_github": "Connect to GitHub",
"connecting": "Connecting...",
"ops": "{count} op | {count} ops"
},
@@ -880,6 +890,11 @@
"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"
+ },
+ "github": {
+ "title": "GitHub",
+ "connected_as": "Connected as {username}",
+ "connect_prompt": "Connect your GitHub account to star repositories directly from npmx."
}
},
"header": {
diff --git a/nuxt.config.ts b/nuxt.config.ts
index 2a3629aa3..34b459271 100644
--- a/nuxt.config.ts
+++ b/nuxt.config.ts
@@ -36,6 +36,8 @@ export default defineNuxtConfig({
sessionPassword: '',
github: {
orgToken: '',
+ clientId: process.env.GITHUB_CLIENT_ID || '',
+ clientSecret: process.env.GITHUB_CLIENT_SECRET || '',
},
// Upstash Redis for distributed OAuth token refresh locking in production
upstash: {
@@ -125,6 +127,7 @@ export default defineNuxtConfig({
// never cache
'/api/auth/**': { isr: false, cache: false },
'/api/social/**': { isr: false, cache: false },
+ '/api/github/**': { isr: false, cache: false },
'/api/opensearch/suggestions': {
isr: {
expiration: 60 * 60 * 24 /* one day */,
diff --git a/server/api/auth/atproto.get.ts b/server/api/auth/atproto/index.get.ts
similarity index 98%
rename from server/api/auth/atproto.get.ts
rename to server/api/auth/atproto/index.get.ts
index 0ff4b2eaf..98d0ac45b 100644
--- a/server/api/auth/atproto.get.ts
+++ b/server/api/auth/atproto/index.get.ts
@@ -4,6 +4,7 @@ import { createError, getQuery, sendRedirect, setCookie, getCookie, deleteCookie
import type { H3Event } from 'h3'
import { getOAuthLock } from '#server/utils/atproto/lock'
import { useOAuthStorage } from '#server/utils/atproto/storage'
+import { generateRandomHexString } from '#server/utils/auth'
import { SLINGSHOT_HOST } from '#shared/utils/constants'
import { useServerSession } from '#server/utils/server-session'
import { handleResolver } from '#server/utils/atproto/oauth'
@@ -172,12 +173,6 @@ function encodeOAuthState(event: H3Event, data: OAuthStateData): string {
return JSON.stringify({ data, id })
}
-function generateRandomHexString(byteLength: number = 16): string {
- return Array.from(crypto.getRandomValues(new Uint8Array(byteLength)), byte =>
- byte.toString(16).padStart(2, '0'),
- ).join('')
-}
-
/**
* This function ensures that an oauth state was indeed encoded for the browser
* session performing the oauth callback.
diff --git a/server/api/auth/session.delete.ts b/server/api/auth/atproto/session.delete.ts
similarity index 72%
rename from server/api/auth/session.delete.ts
rename to server/api/auth/atproto/session.delete.ts
index a1d4f90b1..9d40b2b2d 100644
--- a/server/api/auth/session.delete.ts
+++ b/server/api/auth/atproto/session.delete.ts
@@ -2,6 +2,11 @@ export default eventHandlerWithOAuthSession(async (event, oAuthSession, serverSe
// Even tho the signOut also clears part of the server cache should be done in order
// to let the oAuth package do any other clean up it may need
await oAuthSession?.signOut()
- await serverSession.clear()
+ await serverSession.update({
+ public: undefined,
+ oauthSession: undefined,
+ oauthState: undefined,
+ })
+
return 'Session cleared'
})
diff --git a/server/api/auth/session.get.ts b/server/api/auth/atproto/session.get.ts
similarity index 100%
rename from server/api/auth/session.get.ts
rename to server/api/auth/atproto/session.get.ts
diff --git a/server/api/auth/github/index.get.ts b/server/api/auth/github/index.get.ts
new file mode 100644
index 000000000..245d5a017
--- /dev/null
+++ b/server/api/auth/github/index.get.ts
@@ -0,0 +1,157 @@
+import { createError, getQuery, sendRedirect, setCookie, getCookie, deleteCookie } from 'h3'
+import type { H3Event } from 'h3'
+import { generateRandomHexString } from '#server/utils/auth'
+import { useServerSession } from '#server/utils/server-session'
+import { handleApiError } from '#server/utils/error-handler'
+import { UNSET_NUXT_SESSION_PASSWORD } from '#shared/utils/constants'
+// @ts-expect-error virtual file from oauth module
+import { clientUri } from '#oauth/config'
+
+const GITHUB_AUTHORIZE_URL = 'https://github.com/login/oauth/authorize'
+const GITHUB_TOKEN_URL = 'https://github.com/login/oauth/access_token'
+const GITHUB_USER_URL = 'https://api.github.com/user'
+const GITHUB_STATE_COOKIE = 'github_oauth_state'
+
+export default defineEventHandler(async event => {
+ const config = useRuntimeConfig(event)
+ if (!config.sessionPassword) {
+ throw createError({
+ status: 500,
+ message: UNSET_NUXT_SESSION_PASSWORD,
+ })
+ }
+
+ if (!config.github.clientId || !config.github.clientSecret) {
+ throw createError({
+ statusCode: 500,
+ message: 'GitHub OAuth is not configured.',
+ })
+ }
+
+ const query = getQuery(event)
+
+ if (query.error === 'access_denied') {
+ return sendRedirect(event, '/')
+ }
+
+ // If no code, initiate the OAuth flow
+ if (!query.code) {
+ const state = generateRandomHexString()
+
+ // Store returnTo path
+ let redirectPath = '/'
+ try {
+ const clientOrigin = new URL(clientUri).origin
+ const returnToUrl = new URL(query.returnTo?.toString() || '/', clientUri)
+ if (returnToUrl.origin === clientOrigin) {
+ redirectPath = returnToUrl.pathname + returnToUrl.search + returnToUrl.hash
+ }
+ } catch {
+ // Invalid URL, fall back to root
+ }
+
+ setCookie(event, GITHUB_STATE_COOKIE, JSON.stringify({ state, redirectPath }), {
+ maxAge: 60 * 5,
+ httpOnly: true,
+ secure: !import.meta.dev,
+ sameSite: 'lax',
+ path: '/api/auth/github',
+ })
+
+ const params = new URLSearchParams({
+ client_id: config.github.clientId,
+ redirect_uri: `${clientUri}/api/auth/github`,
+ scope: 'public_repo',
+ state,
+ })
+
+ return sendRedirect(event, `${GITHUB_AUTHORIZE_URL}?${params.toString()}`)
+ }
+
+ // Handle callback
+ try {
+ const stateData = decodeGitHubState(event, query.state?.toString() ?? '')
+
+ const tokenResponse = await $fetch<{ access_token: string; token_type: string }>(
+ GITHUB_TOKEN_URL,
+ {
+ method: 'POST',
+ headers: {
+ Accept: 'application/json',
+ },
+ body: {
+ client_id: config.github.clientId,
+ client_secret: config.github.clientSecret,
+ code: query.code,
+ redirect_uri: `${clientUri}/api/auth/github`,
+ },
+ },
+ )
+
+ if (!tokenResponse.access_token) {
+ throw createError({
+ statusCode: 401,
+ message: 'Failed to obtain GitHub access token.',
+ })
+ }
+
+ const githubUser = await $fetch<{ login: string }>(GITHUB_USER_URL, {
+ headers: {
+ 'Authorization': `Bearer ${tokenResponse.access_token}`,
+ 'Accept': 'application/vnd.github+json',
+ 'User-Agent': 'npmx',
+ },
+ })
+
+ const session = await useServerSession(event)
+ await session.update({
+ github: {
+ accessToken: tokenResponse.access_token,
+ username: githubUser.login,
+ },
+ })
+
+ return sendRedirect(event, stateData.redirectPath)
+ } catch (error) {
+ // User cancelled
+ if (query.error === 'access_denied') {
+ return sendRedirect(event, '/')
+ }
+
+ const message = error instanceof Error ? error.message : 'GitHub authentication failed.'
+ return handleApiError(error, {
+ statusCode: 401,
+ statusMessage: 'Unauthorized',
+ message: `${message}. Please try again.`,
+ })
+ }
+})
+
+function decodeGitHubState(event: H3Event, state: string): { redirectPath: string } {
+ const cookieValue = getCookie(event, GITHUB_STATE_COOKIE)
+
+ if (!cookieValue) {
+ throw createError({
+ statusCode: 400,
+ message: 'Missing GitHub authentication state. Please enable cookies and try again.',
+ })
+ }
+
+ const stored = JSON.parse(cookieValue) as { state: string; redirectPath: string }
+
+ if (stored.state !== state) {
+ throw createError({
+ statusCode: 400,
+ message: 'Invalid authentication state. Please try again.',
+ })
+ }
+
+ deleteCookie(event, GITHUB_STATE_COOKIE, {
+ httpOnly: true,
+ secure: !import.meta.dev,
+ sameSite: 'lax',
+ path: '/api/auth/github',
+ })
+
+ return { redirectPath: stored.redirectPath }
+}
diff --git a/server/api/auth/github/session.delete.ts b/server/api/auth/github/session.delete.ts
new file mode 100644
index 000000000..8323cd49b
--- /dev/null
+++ b/server/api/auth/github/session.delete.ts
@@ -0,0 +1,11 @@
+import { useServerSession } from '#server/utils/server-session'
+
+export default defineEventHandler(async event => {
+ const session = await useServerSession(event)
+
+ await session.update({
+ github: undefined,
+ })
+
+ return 'GitHub disconnected'
+})
diff --git a/server/api/auth/github/session.get.ts b/server/api/auth/github/session.get.ts
new file mode 100644
index 000000000..b82f5167b
--- /dev/null
+++ b/server/api/auth/github/session.get.ts
@@ -0,0 +1,12 @@
+import { useServerSession } from '#server/utils/server-session'
+
+export default defineEventHandler(async event => {
+ const session = await useServerSession(event)
+ const github = session.data.github
+
+ if (!github?.username || !github.accessToken) {
+ return null
+ }
+
+ return { username: github.username }
+})
diff --git a/server/api/github/star.delete.ts b/server/api/github/star.delete.ts
new file mode 100644
index 000000000..8ae5800cf
--- /dev/null
+++ b/server/api/github/star.delete.ts
@@ -0,0 +1,46 @@
+import { createError, readBody } from 'h3'
+import * as v from 'valibot'
+import { GitHubStarBodySchema } from '#shared/schemas/github'
+import { useServerSession } from '#server/utils/server-session'
+import { handleApiError } from '#server/utils/error-handler'
+
+export default defineEventHandler(async event => {
+ const session = await useServerSession(event)
+ const github = session.data.github
+
+ if (!github?.accessToken) {
+ throw createError({
+ statusCode: 401,
+ message: 'GitHub account not connected.',
+ })
+ }
+
+ const { owner, repo } = v.parse(GitHubStarBodySchema, await readBody(event))
+
+ if (!owner || !repo) {
+ throw createError({
+ statusCode: 400,
+ message: 'Missing owner or repo in request body.',
+ })
+ }
+
+ try {
+ await $fetch(`https://api.github.com/user/starred/${owner}/${repo}`, {
+ method: 'DELETE',
+ headers: {
+ 'Authorization': `Bearer ${github.accessToken}`,
+ 'Accept': 'application/vnd.github+json',
+ 'User-Agent': 'npmx',
+ 'X-GitHub-Api-Version': '2022-11-28',
+ },
+ })
+
+ return { starred: false }
+ } catch (error) {
+ return handleApiError(error, {
+ statusCode: 500,
+ statusMessage: 'Internal Server Error',
+ message: 'Failed to unstar repository.',
+ })
+ }
+})
diff --git a/server/api/github/star.put.ts b/server/api/github/star.put.ts
new file mode 100644
index 000000000..31566dd7b
--- /dev/null
+++ b/server/api/github/star.put.ts
@@ -0,0 +1,47 @@
+import { createError, readBody } from 'h3'
+import * as v from 'valibot'
+import { GitHubStarBodySchema } from '#shared/schemas/github'
+import { useServerSession } from '#server/utils/server-session'
+import { handleApiError } from '#server/utils/error-handler'
+
+export default defineEventHandler(async event => {
+ const session = await useServerSession(event)
+ const github = session.data.github
+
+ if (!github?.accessToken) {
+ throw createError({
+ statusCode: 401,
+ message: 'GitHub account not connected.',
+ })
+ }
+
+ const { owner, repo } = v.parse(GitHubStarBodySchema, await readBody(event))
+
+ if (!owner || !repo) {
+ throw createError({
+ statusCode: 400,
+ message: 'Missing owner or repo in request body.',
+ })
+ }
+
+ try {
+ await $fetch(`https://api.github.com/user/starred/${owner}/${repo}`, {
+ method: 'PUT',
+ headers: {
+ 'Authorization': `Bearer ${github.accessToken}`,
+ 'Accept': 'application/vnd.github+json',
+ 'User-Agent': 'npmx',
+ 'X-GitHub-Api-Version': '2022-11-28',
+ 'Content-Length': '0',
+ },
+ })
+
+ return { starred: true }
+ } catch (error) {
+ return handleApiError(error, {
+ statusCode: 500,
+ statusMessage: 'Internal Server Error',
+ message: 'Failed to star repository.',
+ })
+ }
+})
diff --git a/server/api/github/starred.get.ts b/server/api/github/starred.get.ts
new file mode 100644
index 000000000..7464013f1
--- /dev/null
+++ b/server/api/github/starred.get.ts
@@ -0,0 +1,45 @@
+import { createError, getQuery } from 'h3'
+import { useServerSession } from '#server/utils/server-session'
+import { handleApiError } from '#server/utils/error-handler'
+
+export default defineEventHandler(async event => {
+ const session = await useServerSession(event)
+ const github = session.data.github
+
+ if (!github?.accessToken) {
+ return { starred: false, connected: false }
+ }
+
+ const query = getQuery(event)
+ const owner = query.owner?.toString()
+ const repo = query.repo?.toString()
+
+ if (!owner || !repo) {
+ throw createError({
+ statusCode: 400,
+ message: 'Missing owner or repo query parameter.',
+ })
+ }
+
+ try {
+ // returns 204 if starred, 404 if not
+ const response = await $fetch.raw(`https://api.github.com/user/starred/${owner}/${repo}`, {
+ method: 'GET',
+ headers: {
+ 'Authorization': `Bearer ${github.accessToken}`,
+ 'Accept': 'application/vnd.github+json',
+ 'User-Agent': 'npmx',
+ 'X-GitHub-Api-Version': '2022-11-28',
+ },
+ ignoreResponseError: true,
+ })
+
+ return { starred: response.status === 204, connected: true }
+ } catch (error) {
+ return handleApiError(error, {
+ statusCode: 500,
+ statusMessage: 'Internal Server Error',
+ message: 'Failed to check star status.',
+ })
+ }
+})
diff --git a/server/utils/auth.ts b/server/utils/auth.ts
new file mode 100644
index 000000000..4d6e4f1eb
--- /dev/null
+++ b/server/utils/auth.ts
@@ -0,0 +1,5 @@
+export function generateRandomHexString(byteLength: number = 16): string {
+ return Array.from(crypto.getRandomValues(new Uint8Array(byteLength)), byte =>
+ byte.toString(16).padStart(2, '0'),
+ ).join('')
+}
diff --git a/shared/schemas/github.ts b/shared/schemas/github.ts
new file mode 100644
index 000000000..4d9549fac
--- /dev/null
+++ b/shared/schemas/github.ts
@@ -0,0 +1,6 @@
+import * as v from 'valibot'
+
+export const GitHubStarBodySchema = v.object({
+ owner: v.string(),
+ repo: v.string(),
+})
diff --git a/shared/types/userSession.ts b/shared/types/userSession.ts
index d761c1336..d3a20c58f 100644
--- a/shared/types/userSession.ts
+++ b/shared/types/userSession.ts
@@ -14,4 +14,10 @@ export interface UserServerSession {
// multiple did logins per server session
oauthSession?: NodeSavedSession | undefined
oauthState?: NodeSavedState | undefined
+ github?:
+ | {
+ accessToken: string
+ username: string
+ }
+ | undefined
}