Scratch space for learning atproto app development

style changes

Changed files
+85 -108
src
+3 -11
src/pages/login.ts
···
import { html } from '../lib/view'
import { shell } from './shell'
-
type Props = { error?: string; state?: string }
export function login(props: Props) {
return shell({
···
})
}
-
function content({ error, state }: Props) {
const signupService =
!env.PDS_URL || env.PDS_URL === 'https://bsky.social'
? 'Bluesky'
: new URL(env.PDS_URL).hostname
-
const signupUrl = state
-
? `/signup?state=${encodeURIComponent(state)}`
-
: '/signup'
-
return html`<div id="root">
<div id="header">
<h1>Statusphere</h1>
···
required
/>
-
${state != null
-
? html`<input type="hidden" name="state" value="${state}" />`
-
: undefined}
-
<button type="submit">Log in</button>
</form>
-
<a href="${signupUrl}" class="button signup-cta">
Login or Sign up with a ${signupService} account
</a>
···
import { html } from '../lib/view'
import { shell } from './shell'
+
type Props = { error?: string }
export function login(props: Props) {
return shell({
···
})
}
+
function content({ error }: Props) {
const signupService =
!env.PDS_URL || env.PDS_URL === 'https://bsky.social'
? 'Bluesky'
: new URL(env.PDS_URL).hostname
return html`<div id="root">
<div id="header">
<h1>Statusphere</h1>
···
required
/>
<button type="submit">Log in</button>
</form>
+
<a href="/signup" class="button signup-cta">
Login or Sign up with a ${signupService} account
</a>
+82 -97
src/routes.ts
···
}
export function createRouter(ctx: AppContext): RequestListener {
-
const app = express()
// Static assets
-
app.use('/public', express.static(path.join(__dirname, 'pages', 'public')))
// OAuth metadata
-
app.get(
'/oauth-client-metadata.json',
handler((req: Request, res: Response) => {
res.json(ctx.oauthClient.clientMetadata)
···
)
// Public keys
-
app.get(
'/.well-known/jwks.json',
handler((req: Request, res: Response) => {
res.json(ctx.oauthClient.jwks)
···
)
// OAuth callback to complete session creation
-
app.get(
'/oauth/callback',
handler(async (req: Request, res: Response) => {
const params = new URLSearchParams(req.originalUrl.split('?')[1])
···
session.did = oauth.session.did
await session.save()
-
if (oauth.state?.startsWith('status:')) {
-
const status = oauth.state.slice(7)
-
const agent = new Agent(oauth.session)
-
try {
-
await updateStatus(agent, status)
-
} catch (err) {
-
const message = err instanceof Error ? err.message : 'Unknown error'
-
return res.redirect(`/?error=${encodeURIComponent(message)}`)
-
}
-
}
-
// Redirect to the homepage
return res.redirect('/')
} catch (err) {
···
)
// Login page
-
app.get(
'/login',
handler((req: Request, res: Response) => {
-
const state = ifString(req.query.state)
-
res.type('html').send(page(login({ state })))
}),
)
// Login handler
-
app.post(
'/login',
express.urlencoded({ extended: true }),
handler(async (req: Request, res: Response) => {
const input = ifString(req.body.input)
-
const state = ifString(req.body.state)
// Validate
if (!input) {
-
return void res
-
.type('html')
-
.send(page(login({ error: 'invalid input' })))
}
// Initiate the OAuth flow
try {
const url = await ctx.oauthClient.authorize(input, {
scope: 'atproto transition:generic',
-
state,
})
res.redirect(url.toString())
} catch (err) {
···
? err.message
: "couldn't initiate login"
-
res.type('html').send(page(login({ state, error })))
}
}),
)
// Signup
-
app.get(
'/signup',
handler(async (req: Request, res: Response) => {
try {
const service = env.PDS_URL ?? 'https://bsky.social'
const url = await ctx.oauthClient.authorize(service, {
scope: 'atproto transition:generic',
-
state: ifString(req.query.state),
})
res.redirect(url.toString())
} catch (err) {
···
)
// Logout handler
-
app.post(
'/logout',
handler(async (req: Request, res: Response) => {
const session = await getIronSession<Session>(req, res, {
···
)
// Homepage
-
app.get(
'/',
handler(async (req: Request, res: Response) => {
const error = ifString(req.query.error)
···
if (!agent) {
// Serve the logged-out view
-
return void res
-
.type('html')
-
.send(page(home({ error, statuses, didHandleMap })))
}
// Fetch additional information about the logged-in user
···
)
// "Set status" handler
-
app.post(
'/status',
express.urlencoded({ extended: true }),
handler(async (req: Request, res: Response) => {
-
const status = req.body?.status
-
// If the user is signed in, get an agent which communicates with their server
const agent = await getSessionAgent(req, res, ctx)
if (!agent) {
-
return void res.redirect(
-
`/login?state=status:${encodeURIComponent(status)}`,
-
)
}
try {
-
await updateStatus(agent, status)
-
return res.redirect('/')
} catch (err) {
const message = err instanceof Error ? err.message : 'Unknown error'
-
return res.redirect(`/?error=${encodeURIComponent(message)}`)
}
}),
)
-
return app
-
-
async function updateStatus(agent: Agent, status: unknown) {
-
if (typeof status !== 'string' || !STATUS_OPTIONS.includes(status)) {
-
throw new Error('Invalid status')
-
}
-
-
// Construct & validate their status record
-
const rkey = TID.nextStr()
-
const record = {
-
$type: 'xyz.statusphere.status',
-
status,
-
createdAt: new Date().toISOString(),
-
}
-
-
if (!Status.validateRecord(record).success) {
-
throw new Error('Invalid status record')
-
}
-
-
// Write the status record to the user's repository
-
const res = await agent.com.atproto.repo
-
.putRecord({
-
repo: agent.assertDid,
-
collection: 'xyz.statusphere.status',
-
rkey,
-
record,
-
validate: false,
-
})
-
.catch((err) => {
-
ctx.logger.error({ err }, 'failed to write record')
-
throw new Error('Failed to write record', { cause: err })
-
})
-
-
try {
-
// Optimistically update our SQLite
-
// This isn't strictly necessary because the write event will be
-
// handled in #/firehose/ingestor.ts, but it ensures that future reads
-
// will be up-to-date after this method finishes.
-
await ctx.db
-
.insertInto('status')
-
.values({
-
uri: res.data.uri,
-
authorDid: agent.assertDid,
-
status: record.status,
-
createdAt: record.createdAt,
-
indexedAt: new Date().toISOString(),
-
})
-
.execute()
-
} catch (err) {
-
ctx.logger.warn(
-
{ err },
-
'failed to update computed view; ignoring as it should be caught by the firehose',
-
)
-
}
-
}
}
···
}
export function createRouter(ctx: AppContext): RequestListener {
+
const router = express()
// Static assets
+
router.use('/public', express.static(path.join(__dirname, 'pages', 'public')))
// OAuth metadata
+
router.get(
'/oauth-client-metadata.json',
handler((req: Request, res: Response) => {
res.json(ctx.oauthClient.clientMetadata)
···
)
// Public keys
+
router.get(
'/.well-known/jwks.json',
handler((req: Request, res: Response) => {
res.json(ctx.oauthClient.jwks)
···
)
// OAuth callback to complete session creation
+
router.get(
'/oauth/callback',
handler(async (req: Request, res: Response) => {
const params = new URLSearchParams(req.originalUrl.split('?')[1])
···
session.did = oauth.session.did
await session.save()
// Redirect to the homepage
return res.redirect('/')
} catch (err) {
···
)
// Login page
+
router.get(
'/login',
handler((req: Request, res: Response) => {
+
res.type('html').send(page(login({})))
}),
)
// Login handler
+
router.post(
'/login',
express.urlencoded({ extended: true }),
handler(async (req: Request, res: Response) => {
const input = ifString(req.body.input)
// Validate
if (!input) {
+
res.type('html').send(page(login({ error: 'invalid input' })))
+
return
}
// Initiate the OAuth flow
try {
const url = await ctx.oauthClient.authorize(input, {
scope: 'atproto transition:generic',
})
res.redirect(url.toString())
} catch (err) {
···
? err.message
: "couldn't initiate login"
+
res.type('html').send(page(login({ error })))
}
}),
)
// Signup
+
router.get(
'/signup',
handler(async (req: Request, res: Response) => {
try {
const service = env.PDS_URL ?? 'https://bsky.social'
const url = await ctx.oauthClient.authorize(service, {
scope: 'atproto transition:generic',
})
res.redirect(url.toString())
} catch (err) {
···
)
// Logout handler
+
router.post(
'/logout',
handler(async (req: Request, res: Response) => {
const session = await getIronSession<Session>(req, res, {
···
)
// Homepage
+
router.get(
'/',
handler(async (req: Request, res: Response) => {
const error = ifString(req.query.error)
···
if (!agent) {
// Serve the logged-out view
+
res.type('html').send(page(home({ error, statuses, didHandleMap })))
+
return
}
// Fetch additional information about the logged-in user
···
)
// "Set status" handler
+
router.post(
'/status',
express.urlencoded({ extended: true }),
handler(async (req: Request, res: Response) => {
// If the user is signed in, get an agent which communicates with their server
const agent = await getSessionAgent(req, res, ctx)
if (!agent) {
+
res.redirect(`/login}`)
+
return
}
try {
+
const status = req.body?.status
+
if (typeof status !== 'string' || !STATUS_OPTIONS.includes(status)) {
+
throw new Error('Invalid status')
+
}
+
+
// Construct & validate their status record
+
const rkey = TID.nextStr()
+
const record = {
+
$type: 'xyz.statusphere.status',
+
status,
+
createdAt: new Date().toISOString(),
+
}
+
+
if (!Status.validateRecord(record).success) {
+
res.status(400).type('html').send('<h1>Error: Invalid status</h1>')
+
return
+
}
+
+
// Write the status record to the user's repository
+
let uri
+
try {
+
const res = await agent.com.atproto.repo.putRecord({
+
repo: agent.assertDid,
+
collection: 'xyz.statusphere.status',
+
rkey,
+
record,
+
validate: false,
+
})
+
uri = res.data.uri
+
} catch (err) {
+
ctx.logger.error({ err }, 'failed to write record')
+
res
+
.status(500)
+
.type('html')
+
.send('<h1>Error: Failed to write record</h1>')
+
return
+
}
+
+
try {
+
// Optimistically update our SQLite
+
// This isn't strictly necessary because the write event will be
+
// handled in #/firehose/ingestor.ts, but it ensures that future reads
+
// will be up-to-date after this method finishes.
+
await ctx.db
+
.insertInto('status')
+
.values({
+
uri,
+
authorDid: agent.assertDid,
+
status: record.status,
+
createdAt: record.createdAt,
+
indexedAt: new Date().toISOString(),
+
})
+
.execute()
+
} catch (err) {
+
ctx.logger.warn(
+
{ err },
+
'failed to update computed view; ignoring as it should be caught by the firehose',
+
)
+
}
+
+
res.redirect('/')
} catch (err) {
const message = err instanceof Error ? err.message : 'Unknown error'
+
res.redirect(`/?error=${encodeURIComponent(message)}`)
}
}),
)
+
return router
}