Scratch space for learning atproto app development

Support posting a status when not authenticated

Changed files
+140 -100
src
+4
src/lib/util.ts
···
···
+
export function ifString<T>(value: T): (T & string) | undefined {
+
if (typeof value === 'string') return value
+
return undefined
+
}
+4 -3
src/pages/home.ts
···
const TODAY = new Date().toDateString()
-
const STATUS_OPTIONS = [
'๐Ÿ‘',
'๐Ÿ‘Ž',
'๐Ÿ’™',
···
type Props = {
statuses: Status[]
didHandleMap: Record<string, string | undefined>
profile?: { displayName?: string }
myStatus?: Status
}
···
})
}
-
function content({ statuses, didHandleMap, profile, myStatus }: Props) {
return html`<div id="root">
-
<div class="error"></div>
<div id="header">
<h1>Statusphere</h1>
<p>Set your status on the Atmosphere.</p>
···
</button>`,
)}
</form>
${statuses.map((status, i) => {
const handle = didHandleMap[status.authorDid] || status.authorDid
const date = ts(status)
···
const TODAY = new Date().toDateString()
+
export const STATUS_OPTIONS = [
'๐Ÿ‘',
'๐Ÿ‘Ž',
'๐Ÿ’™',
···
type Props = {
statuses: Status[]
didHandleMap: Record<string, string | undefined>
+
error?: string
profile?: { displayName?: string }
myStatus?: Status
}
···
})
}
+
function content({ error, statuses, didHandleMap, profile, myStatus }: Props) {
return html`<div id="root">
<div id="header">
<h1>Statusphere</h1>
<p>Set your status on the Atmosphere.</p>
···
</button>`,
)}
</form>
+
${error ? html`<div class="error">${error}</div>` : undefined}
${statuses.map((status, i) => {
const handle = didHandleMap[status.authorDid] || status.authorDid
const date = ts(status)
+14 -5
src/pages/login.ts
···
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 serviceName =
!env.PDS_URL || env.PDS_URL === 'https://bsky.social'
? 'Bluesky'
: env.PDS_URL
return html`<div id="root">
<div id="header">
···
placeholder="Enter your handle (eg alice.bsky.social)"
required
/>
<button type="submit">Log in</button>
</form>
-
<a href="/signup" class="button signup-cta">
-
Login or Sign up with a ${serviceName} account
</a>
${error ? html`<p>Error: <i>${error}</i></p>` : undefined}
···
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'
: env.PDS_URL
+
+
const signupUrl = state
+
? `/signup?state=${encodeURIComponent(state)}`
+
: '/signup'
return html`<div id="root">
<div id="header">
···
placeholder="Enter your handle (eg alice.bsky.social)"
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>
${error ? html`<p>Error: <i>${error}</i></p>` : undefined}
-5
src/pages/public/styles.css
···
color: var(--error-500);
text-align: center;
padding: 1rem;
-
display: none;
}
-
.error.visible {
-
display: block;
-
}
-
#header {
background-color: #fff;
text-align: center;
···
color: var(--error-500);
text-align: center;
padding: 1rem;
}
#header {
background-color: #fff;
text-align: center;
+118 -87
src/routes.ts
···
import { env } from '#/env'
import { handler } from '#/lib/http'
import { page } from '#/lib/view'
-
import { home } from '#/pages/home'
import { login } from '#/pages/login'
type Session = { did?: string }
···
handler(async (req: Request, res: Response) => {
const params = new URLSearchParams(req.originalUrl.split('?')[1])
try {
-
const { session } = await ctx.oauthClient.callback(params)
-
const clientSession = await getIronSession<Session>(req, res, {
cookieName: 'sid',
password: env.COOKIE_SECRET,
})
-
assert(!clientSession.did, 'session already exists')
-
clientSession.did = session.did
-
await clientSession.save()
return res.redirect('/')
} catch (err) {
ctx.logger.error({ err }, 'oauth callback failed')
···
app.get(
'/login',
handler((req: Request, res: Response) => {
-
res.type('html').send(page(login({})))
}),
)
···
'/login',
express.urlencoded({ extended: true }),
handler(async (req: Request, res: Response) => {
// Validate
-
const input = req.body?.input
-
if (typeof input !== 'string') {
return void res
.type('html')
.send(page(login({ error: 'invalid input' })))
···
try {
const url = await ctx.oauthClient.authorize(input, {
scope: 'atproto transition:generic',
})
res.redirect(url.toString())
} catch (err) {
ctx.logger.error({ err }, 'oauth authorize failed')
-
res.type('html').send(
-
page(
-
login({
-
error:
-
err instanceof OAuthResolverError
-
? err.message
-
: "couldn't initiate login",
-
}),
-
),
-
)
}
}),
)
···
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) {
···
app.get(
'/',
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)
···
// Serve the logged-out view
return void res
.type('html')
-
.send(page(home({ statuses, didHandleMap })))
}
// Fetch additional information about the logged-in user
···
: {}
// Serve the logged-in view
-
res.type('html').send(
-
page(
-
home({
-
statuses,
-
didHandleMap,
-
profile,
-
myStatus,
-
}),
-
),
-
)
}),
)
···
'/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) {
-
return void res
-
.status(401)
-
.type('html')
-
.send('<h1>Error: Session required</h1>')
}
-
// Construct & validate their status record
-
const rkey = TID.nextStr()
-
const record = {
-
$type: 'xyz.statusphere.status',
-
status: req.body?.status,
-
createdAt: new Date().toISOString(),
-
}
-
if (!Status.validateRecord(record).success) {
-
return void res
-
.status(400)
-
.type('html')
-
.send('<h1>Error: Invalid status</h1>')
-
}
-
-
let uri
try {
-
// 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,
-
})
-
uri = res.data.uri
} catch (err) {
-
ctx.logger.warn({ err }, 'failed to write record')
-
return void res
-
.status(500)
-
.type('html')
-
.send('<h1>Error: Failed to write record</h1>')
}
-
-
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',
-
)
-
}
-
-
return res.redirect('/')
}),
)
return app
}
···
import { env } from '#/env'
import { handler } from '#/lib/http'
import { page } from '#/lib/view'
+
import { home, STATUS_OPTIONS } from '#/pages/home'
import { login } from '#/pages/login'
+
import { ifString } from './lib/util'
type Session = { did?: string }
···
handler(async (req: Request, res: Response) => {
const params = new URLSearchParams(req.originalUrl.split('?')[1])
try {
+
// Load the session cookie
+
const session = await getIronSession<Session>(req, res, {
cookieName: 'sid',
password: env.COOKIE_SECRET,
})
+
+
// If the user is already signed in, destroy the old credentials
+
if (session.did) {
+
try {
+
const oauthSession = await ctx.oauthClient.restore(session.did)
+
if (oauthSession) oauthSession.signOut()
+
} catch (err) {
+
ctx.logger.warn({ err }, 'oauth restore failed')
+
}
+
}
+
+
// Complete the OAuth flow
+
const oauth = await ctx.oauthClient.callback(params)
+
+
// Update the session cookie
+
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) {
ctx.logger.error({ err }, 'oauth callback failed')
···
app.get(
'/login',
handler((req: Request, res: Response) => {
+
const state = ifString(req.query.state)
+
res.type('html').send(page(login({ state })))
}),
)
···
'/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' })))
···
try {
const url = await ctx.oauthClient.authorize(input, {
scope: 'atproto transition:generic',
+
state,
})
res.redirect(url.toString())
} catch (err) {
ctx.logger.error({ err }, 'oauth authorize failed')
+
+
const error =
+
err instanceof OAuthResolverError
+
? err.message
+
: "couldn't initiate login"
+
+
res.type('html').send(page(login({ state, error })))
}
}),
)
···
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) {
···
app.get(
'/',
handler(async (req: Request, res: Response) => {
+
const error = ifString(req.query.error)
+
// If the user is signed in, get an agent which communicates with their server
const agent = await getSessionAgent(req, res, ctx)
···
// Serve the logged-out view
return void res
.type('html')
+
.send(page(home({ error, statuses, didHandleMap })))
}
// Fetch additional information about the logged-in user
···
: {}
// Serve the logged-in view
+
res
+
.type('html')
+
.send(page(home({ error, statuses, didHandleMap, profile, myStatus })))
}),
)
···
'/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',
+
)
+
}
+
}
}