import { Agent } from '@atproto/api'
import type { NodeOAuthClient } from '@atproto/oauth-client-node'
import { extractSubfsUris } from '@wispplace/atproto-utils'
import { createLogger } from '@wispplace/observability'
import { Elysia } from 'elysia'
import { requireAuth, SESSION_COOKIE_NAME } from '../lib/wisp-auth'
const logger = createLogger('main-app')
export const siteRoutes = (client: NodeOAuthClient, cookieSecret: string) =>
new Elysia({
prefix: '/api/site',
cookie: {
secrets: cookieSecret,
sign: [SESSION_COOKIE_NAME],
},
})
.derive(async ({ cookie, request }) => {
const auth = await requireAuth(client, cookie, request.headers.get('cookie'))
return { auth }
})
/**
* DELETE /api/site/:rkey
* Success: { success: true, message }
* Failure: { success: false, error }
*/
.delete('/:rkey', async ({ params, auth, set }) => {
const { rkey } = params
if (!rkey) {
set.status = 400
return {
success: false,
error: 'Site rkey is required',
}
}
try {
// Create agent with OAuth session
const agent = new Agent((url, init) => auth.session.fetchHandler(url, init))
// First, fetch the site record to find any subfs references
let subfsUris: Array<{ uri: string; path: string }> = []
try {
const existingRecord = await agent.com.atproto.repo.getRecord({
repo: auth.did,
collection: 'place.wisp.fs',
rkey: rkey,
})
if (
existingRecord.data.value &&
typeof existingRecord.data.value === 'object' &&
'root' in existingRecord.data.value
) {
const manifest = existingRecord.data.value as any
subfsUris = extractSubfsUris(manifest.root)
if (subfsUris.length > 0) {
logger.info(`[Site] Found ${subfsUris.length} subfs records associated with ${rkey}`)
}
}
} catch (err) {
// Record might not exist, continue with deletion.
logger.warn('[Site] Could not fetch site record for subfs cleanup; continuing', { error: err })
}
// Delete the main record from AT Protocol
try {
await agent.com.atproto.repo.deleteRecord({
repo: auth.did,
collection: 'place.wisp.fs',
rkey: rkey,
})
logger.info(`[Site] Deleted site ${rkey} from PDS for ${auth.did}`)
} catch (err) {
logger.error(`[Site] Failed to delete site ${rkey} from PDS`, err)
throw new Error('Failed to delete site from AT Protocol')
}
// Delete associated subfs records
if (subfsUris.length > 0) {
logger.info(`[Site] Deleting ${subfsUris.length} associated subfs records for ${rkey}`)
await Promise.all(
subfsUris.map(async ({ uri }) => {
try {
// Parse URI: at://did/collection/rkey
const parts = uri.replace('at://', '').split('/')
const subRkey = parts[2]
await agent.com.atproto.repo.deleteRecord({
repo: auth.did,
collection: 'place.wisp.subfs',
rkey: subRkey,
})
logger.info(`[Site] Deleted subfs record: ${uri}`)
} catch (err) {
// Log but don't fail if subfs deletion fails.
logger.error('[Site] Failed to delete subfs record', err, { uri })
}
}),
)
logger.info(`[Site] Deleted ${subfsUris.length} subfs records for ${rkey}`)
}
logger.info(`[Site] Successfully deleted site ${rkey} for ${auth.did}`)
return {
success: true,
message: 'Site deleted successfully',
}
} catch (err) {
logger.error('[Site] Delete error', err)
set.status = 500
return {
success: false,
error: 'Failed to delete site',
}
}
})
/**
* GET /api/site/:rkey/settings
* Success: place.wisp.settings record or default settings object.
* Failure: { success: false, error }
*/
.get('/:rkey/settings', async ({ params, auth, set }) => {
const { rkey } = params
if (!rkey) {
set.status = 400
return {
success: false,
error: 'Site rkey is required',
}
}
try {
// Create agent with OAuth session
const agent = new Agent((url, init) => auth.session.fetchHandler(url, init))
// Fetch settings record
try {
const record = await agent.com.atproto.repo.getRecord({
repo: auth.did,
collection: 'place.wisp.settings',
rkey: rkey,
})
if (record.data.value) {
return record.data.value
}
} catch (err: any) {
// Record doesn't exist, return defaults
if (err?.error === 'RecordNotFound') {
return {
indexFiles: ['index.html'],
cleanUrls: false,
directoryListing: false,
}
}
throw err
}
// Default settings
return {
indexFiles: ['index.html'],
cleanUrls: false,
directoryListing: false,
}
} catch (err) {
logger.error('[Site] Get settings error', err)
set.status = 500
return {
success: false,
error: 'Failed to fetch settings',
}
}
})
/**
* POST /api/site/:rkey/settings
* Success: { success: true, uri, cid }
* Failure: { success: false, error }
*/
.post('/:rkey/settings', async ({ params, body, auth, set }) => {
const { rkey } = params
if (!rkey) {
set.status = 400
return {
success: false,
error: 'Site rkey is required',
}
}
// Validate settings
const settings = body as any
// Ensure mutual exclusivity of routing modes
const modes = [settings.spaMode, settings.directoryListing, settings.custom404].filter(Boolean)
if (modes.length > 1) {
set.status = 400
return {
success: false,
error: 'Only one of spaMode, directoryListing, or custom404 can be enabled',
}
}
try {
// Create agent with OAuth session
const agent = new Agent((url, init) => auth.session.fetchHandler(url, init))
// Create or update settings record
const record = await agent.com.atproto.repo.putRecord({
repo: auth.did,
collection: 'place.wisp.settings',
rkey: rkey,
record: {
$type: 'place.wisp.settings',
...settings,
},
})
logger.info(`[Site] Saved settings for ${rkey} (${auth.did})`)
return {
success: true,
uri: record.data.uri,
cid: record.data.cid,
}
} catch (err) {
logger.error('[Site] Save settings error', err)
set.status = 500
return {
success: false,
error: 'Failed to save settings',
}
}
})