@profullstack/builtindie 0.0.0-stage → 0.1.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/OpenStack.md +74 -0
- package/README.md +63 -2
- package/bin/builtindie +3 -0
- package/package.json +35 -4
- package/public/app.js +35 -0
- package/public/favicon.svg +1 -0
- package/public/icon-192.png +0 -0
- package/public/icon-512.png +0 -0
- package/public/icons/openicon.json +574 -0
- package/public/icons/svg/apple.svg +1 -0
- package/public/icons/svg/arrow-up.svg +1 -0
- package/public/icons/svg/bluesky.svg +1 -0
- package/public/icons/svg/check.svg +1 -0
- package/public/icons/svg/chrome.svg +1 -0
- package/public/icons/svg/code.svg +1 -0
- package/public/icons/svg/discord.svg +1 -0
- package/public/icons/svg/dollar.svg +1 -0
- package/public/icons/svg/external-link.svg +1 -0
- package/public/icons/svg/github.svg +1 -0
- package/public/icons/svg/globe.svg +1 -0
- package/public/icons/svg/instagram.svg +1 -0
- package/public/icons/svg/layers.svg +1 -0
- package/public/icons/svg/link.svg +1 -0
- package/public/icons/svg/linkedin.svg +1 -0
- package/public/icons/svg/mail.svg +1 -0
- package/public/icons/svg/product-hunt.svg +1 -0
- package/public/icons/svg/reddit.svg +1 -0
- package/public/icons/svg/rocket.svg +1 -0
- package/public/icons/svg/search.svg +1 -0
- package/public/icons/svg/share.svg +1 -0
- package/public/icons/svg/star.svg +1 -0
- package/public/icons/svg/tag.svg +1 -0
- package/public/icons/svg/telegram.svg +1 -0
- package/public/icons/svg/terminal.svg +1 -0
- package/public/icons/svg/tiktok.svg +1 -0
- package/public/icons/svg/trophy.svg +1 -0
- package/public/icons/svg/user.svg +1 -0
- package/public/icons/svg/x.svg +1 -0
- package/public/icons/svg/youtube.svg +1 -0
- package/public/install.sh +44 -0
- package/public/manifest.webmanifest +20 -0
- package/public/moshpit-root-ca.crt +11 -0
- package/public/style.css +162 -0
- package/public/sw.js +30 -0
- package/src/auth.ts +96 -0
- package/src/cli.ts +203 -0
- package/src/client.ts +115 -0
- package/src/db.ts +412 -0
- package/src/fetch.ts +708 -0
- package/src/html.ts +525 -0
- package/src/icons.ts +56 -0
- package/src/mcp.ts +79 -0
- package/src/media.ts +160 -0
- package/src/ring.ts +86 -0
- package/src/server.ts +655 -0
- package/src/service.ts +369 -0
- package/src/tui.ts +127 -0
- package/src/version.ts +1 -0
package/src/server.ts
ADDED
|
@@ -0,0 +1,655 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The built.indie web server: pages, the JSON API, badges, pictures, feeds,
|
|
3
|
+
* the OpenSite/OpenProfile documents and CoinPay's webhook.
|
|
4
|
+
*
|
|
5
|
+
* built.indie is a Moshpit name. Visitors on Moshpit DNS reach https://built.indie
|
|
6
|
+
* directly; everyone else comes through the pit.world relay at
|
|
7
|
+
* https://built.indie.pit.world, which is the address we hand out (badges,
|
|
8
|
+
* share links, feeds) because it works for everybody.
|
|
9
|
+
*/
|
|
10
|
+
import { Hono, type Context } from 'hono'
|
|
11
|
+
import { deleteCookie, getCookie, setCookie } from 'hono/cookie'
|
|
12
|
+
import { Auth, consoleMailer, resendMailer, safeNext, SESSION_COOKIE, validEmail } from './auth.js'
|
|
13
|
+
import { Store, type Product, type User, type Window } from './db.js'
|
|
14
|
+
import { privateAddress } from './fetch.js'
|
|
15
|
+
import { badgeSvg, boardPage, cardImage, claimMarkdown, claimPage, editPage, esc, helpPage, homePage, loginPage, makerPage, mePage, messagePage, productPage, submitPage, type Ctx } from './html.js'
|
|
16
|
+
import { IMAGE_TYPES, Media } from './media.js'
|
|
17
|
+
import { RING_PLACEHOLDER, ringBar, startRingRefresh, visitorOnMoshpit } from './ring.js'
|
|
18
|
+
import { BuiltIndie, CLAIM_LABEL, CLAIM_PREFIX, DEFAULT_CHAINS, InputError, type Config, type Edit } from './service.js'
|
|
19
|
+
import { autofetch, FetchError } from './fetch.js'
|
|
20
|
+
import { VERSION } from './version.js'
|
|
21
|
+
|
|
22
|
+
export { VERSION }
|
|
23
|
+
|
|
24
|
+
const SELF = 'built.indie'
|
|
25
|
+
export const PUBLIC_ORIGIN = 'https://built.indie.pit.world'
|
|
26
|
+
|
|
27
|
+
const CSP = [
|
|
28
|
+
"default-src 'none'",
|
|
29
|
+
"style-src 'self' 'unsafe-inline'",
|
|
30
|
+
"script-src 'self' https://crawlproof.com",
|
|
31
|
+
"img-src 'self' data:",
|
|
32
|
+
"connect-src 'self' https://crawlproof.com",
|
|
33
|
+
'frame-src https://crawlproof.com https://www.youtube-nocookie.com https://player.vimeo.com https://www.loom.com',
|
|
34
|
+
"manifest-src 'self'",
|
|
35
|
+
"worker-src 'self'",
|
|
36
|
+
"form-action 'self' https://coinpayportal.com",
|
|
37
|
+
"base-uri 'none'",
|
|
38
|
+
"frame-ancestors 'none'",
|
|
39
|
+
].join('; ')
|
|
40
|
+
|
|
41
|
+
const SECURITY_HEADERS: Record<string, string> = {
|
|
42
|
+
'content-security-policy': CSP,
|
|
43
|
+
'referrer-policy': 'strict-origin-when-cross-origin',
|
|
44
|
+
'x-content-type-options': 'nosniff',
|
|
45
|
+
'permissions-policy': 'interest-cohort=(), browsing-topics=(), camera=(), microphone=(), geolocation=()',
|
|
46
|
+
'cross-origin-opener-policy': 'same-origin',
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export type Deps = { service: BuiltIndie; auth: Auth; adminKey?: string; secureCookies?: boolean }
|
|
50
|
+
type Env = { Bindings: { ip?: string } }
|
|
51
|
+
|
|
52
|
+
/** A small fixed-window limit per key, in memory: enough to stop a loop, not a fortress. */
|
|
53
|
+
class Limiter {
|
|
54
|
+
private hits = new Map<string, { n: number; reset: number }>()
|
|
55
|
+
constructor(private readonly max: number, private readonly windowMs: number) {}
|
|
56
|
+
allow(key: string, now = Date.now()): boolean {
|
|
57
|
+
const h = this.hits.get(key)
|
|
58
|
+
if (!h || h.reset < now) {
|
|
59
|
+
if (this.hits.size > 50_000) this.hits.clear()
|
|
60
|
+
this.hits.set(key, { n: 1, reset: now + this.windowMs })
|
|
61
|
+
return true
|
|
62
|
+
}
|
|
63
|
+
h.n += 1
|
|
64
|
+
return h.n <= this.max
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const WINDOWS: Window[] = ['today', 'week', 'month', 'all', 'new', 'featured']
|
|
69
|
+
|
|
70
|
+
export function createApp(deps: Deps) {
|
|
71
|
+
const { service } = deps
|
|
72
|
+
const store = service.store
|
|
73
|
+
const config = service.config
|
|
74
|
+
const app = new Hono<Env>()
|
|
75
|
+
const readLimit = new Limiter(20, 10 * 60_000)
|
|
76
|
+
const voteLimit = new Limiter(60, 10 * 60_000)
|
|
77
|
+
const origin = config.origin
|
|
78
|
+
|
|
79
|
+
const direct = (c: Context) => visitorOnMoshpit(SELF, c.req.header('host'), c.req.header('x-moshpit-name'))
|
|
80
|
+
const ip = (c: Context<Env>) => c.env?.ip ?? 'local'
|
|
81
|
+
const voter = (c: Context<Env>) => store.voterFor(ip(c))
|
|
82
|
+
const sessionUser = (c: Context) => deps.auth.sessionUser(getCookie(c, SESSION_COOKIE))
|
|
83
|
+
const ctxOf = (c: Context<Env>): Ctx => {
|
|
84
|
+
const v = voter(c)
|
|
85
|
+
return { origin, user: sessionUser(c), voted: (id) => store.voted(id, v) }
|
|
86
|
+
}
|
|
87
|
+
// Links in mail open where the visitor asked from: the relay, or the Moshpit name itself.
|
|
88
|
+
const here = (c: Context) => (direct(c) ? 'https://built.indie' : origin)
|
|
89
|
+
const toLogin = (c: Context) => c.redirect(`/login?next=${encodeURIComponent(c.req.path)}`)
|
|
90
|
+
const page = (c: Context, html: string, status = 200) => c.html(html.replace(RING_PLACEHOLDER, ringBar(SELF, direct(c))), status as 200)
|
|
91
|
+
const wantsJson = (c: Context) => (c.req.header('accept') ?? '').includes('application/json')
|
|
92
|
+
// Form posts from another site are refused: a page elsewhere cannot vote with our visitors' addresses.
|
|
93
|
+
const crossSite = (c: Context) => {
|
|
94
|
+
const s = c.req.header('sec-fetch-site')
|
|
95
|
+
return s !== undefined && s !== 'same-origin' && s !== 'none'
|
|
96
|
+
}
|
|
97
|
+
const bearer = (c: Context) => (c.req.header('authorization') ?? '').replace(/^Bearer\s+/i, '').trim()
|
|
98
|
+
const isAdmin = (c: Context) => Boolean(deps.adminKey && deps.adminKey.length >= 16 && bearer(c) === deps.adminKey)
|
|
99
|
+
const live = (slug: string) => {
|
|
100
|
+
const p = store.bySlug(slug)
|
|
101
|
+
return p?.status === 'live' ? p : null
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
app.use('*', async (c, next) => {
|
|
105
|
+
await next()
|
|
106
|
+
if (!c.res.headers.has('content-security-policy')) for (const [k, v] of Object.entries(SECURITY_HEADERS)) c.header(k, v)
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
app.get('/health', (c) => c.json({ ok: true, version: VERSION }))
|
|
110
|
+
|
|
111
|
+
// Static files.
|
|
112
|
+
const TYPES: Record<string, string> = { css: 'text/css; charset=utf-8', js: 'text/javascript; charset=utf-8', svg: 'image/svg+xml', png: 'image/png', crt: 'application/x-x509-ca-cert', webmanifest: 'application/manifest+json' }
|
|
113
|
+
app.get('/:file{(style\\.css|app\\.js|sw\\.js|favicon\\.svg|icon-192\\.png|icon-512\\.png|manifest\\.webmanifest|moshpit-root-ca\\.crt)}', async (c) => {
|
|
114
|
+
const name = c.req.param('file')
|
|
115
|
+
const file = Bun.file(new URL(`../public/${name}`, import.meta.url))
|
|
116
|
+
const fresh = name === 'sw.js' || name === 'manifest.webmanifest'
|
|
117
|
+
return c.body(await file.arrayBuffer(), 200, { 'content-type': TYPES[name.split('.').pop()!]!, 'cache-control': fresh ? 'no-cache' : 'public, max-age=86400' })
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
// The CLI installer: curl -fsSL https://built.indie.pit.world/install.sh | sh
|
|
121
|
+
app.get('/install.sh', async (c) => c.body(await Bun.file(new URL('../public/install.sh', import.meta.url)).text(), 200, { 'content-type': 'text/x-sh; charset=utf-8', 'cache-control': 'public, max-age=300' }))
|
|
122
|
+
|
|
123
|
+
// Our icons: the OpenIcon reference set, the part we draw (logicsrc.com/docs/openicon).
|
|
124
|
+
app.get('/icons/openicon.json', async (c) => c.body(await Bun.file(new URL('../public/icons/openicon.json', import.meta.url)).text(), 200, { 'content-type': 'application/json', 'access-control-allow-origin': '*', 'cache-control': 'public, max-age=86400' }))
|
|
125
|
+
app.get('/icons/svg/:file{[a-z0-9-]+\\.svg}', async (c) => {
|
|
126
|
+
const file = Bun.file(new URL(`../public/icons/svg/${c.req.param('file')}`, import.meta.url))
|
|
127
|
+
return (await file.exists()) ? c.body(await file.text(), 200, { 'content-type': 'image/svg+xml', 'access-control-allow-origin': '*', 'cache-control': 'public, max-age=86400' }) : c.notFound()
|
|
128
|
+
})
|
|
129
|
+
app.get('/.well-known/openicon.json', (c) => c.json({ openicon: '0.1', sets: [{ name: 'OpenIcon (built.indie subset)', url: `${origin}/icons/openicon.json` }] }, 200, { 'access-control-allow-origin': '*' }))
|
|
130
|
+
app.get('/.well-known/openstack.md', async (c) => c.body(await Bun.file(new URL('../OpenStack.md', import.meta.url)).text(), 200, { 'content-type': 'text/markdown; charset=utf-8', 'access-control-allow-origin': '*' }))
|
|
131
|
+
|
|
132
|
+
// Boards.
|
|
133
|
+
app.get('/', (c) =>
|
|
134
|
+
page(c, homePage(ctxOf(c), { today: store.list('today'), featured: store.list('featured', 3), yesterday: store.onDay(new Date(Date.now() - 86400_000).toISOString().slice(0, 10), 3), stats: store.count() })),
|
|
135
|
+
)
|
|
136
|
+
app.get('/top/:window', (c) => {
|
|
137
|
+
const w = c.req.param('window') as Window
|
|
138
|
+
if (!WINDOWS.includes(w)) return c.notFound()
|
|
139
|
+
return page(c, boardPage(ctxOf(c), w, store.list(w, 50)))
|
|
140
|
+
})
|
|
141
|
+
app.get('/new', (c) => page(c, boardPage(ctxOf(c), 'new', store.list('new', 50))))
|
|
142
|
+
app.get('/search', (c) => {
|
|
143
|
+
const q = (c.req.query('q') ?? '').trim().slice(0, 60)
|
|
144
|
+
return q ? page(c, boardPage(ctxOf(c), 'all', store.search(q), q)) : c.redirect('/top/all')
|
|
145
|
+
})
|
|
146
|
+
app.get('/help', (c) => page(c, helpPage(ctxOf(c))))
|
|
147
|
+
|
|
148
|
+
// Launching.
|
|
149
|
+
app.get('/submit', (c) => page(c, submitPage(ctxOf(c), { values: { url: c.req.query('url') ?? '' } })))
|
|
150
|
+
app.post('/submit', async (c) => {
|
|
151
|
+
const form = (await c.req.parseBody()) as Record<string, string>
|
|
152
|
+
if (crossSite(c)) return page(c, submitPage(ctxOf(c), { values: form, error: { message: 'Launch from this site, please.' } }), 403)
|
|
153
|
+
if (!readLimit.allow(`read:${ip(c)}`)) return page(c, submitPage(ctxOf(c), { values: form, error: { message: 'Too many launches from you. Wait a few minutes.' } }), 429)
|
|
154
|
+
try {
|
|
155
|
+
const user = sessionUser(c)
|
|
156
|
+
const r = await service.submit({ url: form.url ?? '', makerName: form.makerName, makerProfile: form.makerProfile }, store.voterFor(ip(c)), user)
|
|
157
|
+
if (r.existing) return c.redirect(`/p/${r.product.slug}?existing=1`)
|
|
158
|
+
return c.redirect(user ? `/p/${r.product.slug}/edit?fresh=1` : `/p/${r.product.slug}?launched=1`)
|
|
159
|
+
} catch (e) {
|
|
160
|
+
if (!(e instanceof InputError)) console.error('submit', e)
|
|
161
|
+
return page(c, submitPage(ctxOf(c), { values: form, error: { message: e instanceof Error ? e.message : String(e), ...(e instanceof InputError && e.field ? { field: e.field } : {}) } }), e instanceof InputError ? 400 : 502)
|
|
162
|
+
}
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
// A product.
|
|
166
|
+
const productView = (c: Context<Env>, p: Product) =>
|
|
167
|
+
page(
|
|
168
|
+
c,
|
|
169
|
+
productPage(ctxOf(c), {
|
|
170
|
+
product: p,
|
|
171
|
+
award: store.awards(p.id),
|
|
172
|
+
maker: p.makerId ? store.maker(p.makerId) : null,
|
|
173
|
+
featuredOk: c.req.query('featured') === '1',
|
|
174
|
+
launched: c.req.query('launched') === '1',
|
|
175
|
+
canEdit: store.canEdit(p, sessionUser(c)),
|
|
176
|
+
chains: config.chains,
|
|
177
|
+
featureUsd: config.featureUsd,
|
|
178
|
+
featureDays: config.featureDays,
|
|
179
|
+
payments: Boolean(config.coinpay),
|
|
180
|
+
}),
|
|
181
|
+
)
|
|
182
|
+
app.get('/p/:slug', (c) => {
|
|
183
|
+
const p = store.bySlug(c.req.param('slug'))
|
|
184
|
+
// A draft is visible to whoever may edit it (the preview); otherwise it does not exist yet.
|
|
185
|
+
if (!p || p.status === 'hidden' || (p.status === 'draft' && !store.canEdit(p, sessionUser(c)))) return page(c, messagePage(ctxOf(c), { title: 'Not here', message: 'Nothing has launched at that address.' }), 404)
|
|
186
|
+
return productView(c, p)
|
|
187
|
+
})
|
|
188
|
+
|
|
189
|
+
const editView = (c: Context<Env>, p: Product, extra: { flash?: string; error?: { message: string; field?: string } } = {}, status = 200) =>
|
|
190
|
+
page(c, editPage(ctxOf(c), { product: p, fresh: c.req.query('fresh') === '1', ...extra }), status)
|
|
191
|
+
/** The product, when the signed-in person may edit it; otherwise the response to send. */
|
|
192
|
+
const editable = (c: Context<Env>): Product | Response => {
|
|
193
|
+
const user = sessionUser(c)
|
|
194
|
+
if (!user) return toLogin(c)
|
|
195
|
+
const p = store.bySlug(c.req.param('slug') ?? '')
|
|
196
|
+
if (!p || p.status === 'hidden') return page(c, messagePage(ctxOf(c), { title: 'Not here', message: 'Nothing has launched at that address.' }), 404)
|
|
197
|
+
if (!store.canEdit(p, user)) return c.redirect(`/p/${p.slug}/claim`)
|
|
198
|
+
return p
|
|
199
|
+
}
|
|
200
|
+
app.get('/p/:slug/edit', (c) => {
|
|
201
|
+
const p = editable(c)
|
|
202
|
+
return p instanceof Response ? p : editView(c, p)
|
|
203
|
+
})
|
|
204
|
+
app.post('/p/:slug/edit', async (c) => {
|
|
205
|
+
const p = editable(c)
|
|
206
|
+
if (p instanceof Response) return p
|
|
207
|
+
if (crossSite(c)) return editView(c, p, { error: { message: 'Edit from built.indie itself.' } }, 403)
|
|
208
|
+
const body = await c.req.parseBody({ all: true })
|
|
209
|
+
const one = (k: string) => (Array.isArray(body[k]) ? String((body[k] as unknown[])[0]) : body[k] === undefined ? undefined : String(body[k]))
|
|
210
|
+
const action = one('action') ?? 'save'
|
|
211
|
+
try {
|
|
212
|
+
if (action === 'delete') {
|
|
213
|
+
store.delete(p.id)
|
|
214
|
+
await service.media.remove(p.id)
|
|
215
|
+
return page(c, messagePage(ctxOf(c), { title: 'Deleted', message: `${p.name} is gone, pictures and all.` }))
|
|
216
|
+
}
|
|
217
|
+
if (action === 'refetch') {
|
|
218
|
+
if (!readLimit.allow(`read:${ip(c)}`)) return editView(c, p, { error: { message: 'Too many reads from you. Wait a few minutes.' } }, 429)
|
|
219
|
+
return editView(c, await service.refetch(p), { flash: 'Read your homepage again.' })
|
|
220
|
+
}
|
|
221
|
+
const remove = body.remove === undefined ? [] : (Array.isArray(body.remove) ? body.remove : [body.remove]).map(String)
|
|
222
|
+
const edit: Edit = { name: one('name'), tagline: one('tagline'), description: one('description'), tags: one('tags'), videos: one('videos'), makerName: one('makerName'), makerProfile: one('makerProfile'), remove }
|
|
223
|
+
let updated = await service.edit(p, edit)
|
|
224
|
+
if (action === 'publish') {
|
|
225
|
+
updated = service.publish(updated)
|
|
226
|
+
return c.redirect(`/p/${updated.slug}?launched=1`)
|
|
227
|
+
}
|
|
228
|
+
return editView(c, updated, { flash: 'Saved.' })
|
|
229
|
+
} catch (e) {
|
|
230
|
+
if (!(e instanceof InputError)) console.error('edit', e)
|
|
231
|
+
return editView(c, store.product(p.id) ?? p, { error: { message: e instanceof Error ? e.message : String(e), ...(e instanceof InputError && e.field ? { field: e.field } : {}) } }, e instanceof InputError ? 400 : 502)
|
|
232
|
+
}
|
|
233
|
+
})
|
|
234
|
+
|
|
235
|
+
// Claiming: the domain's owner proves it in DNS and takes the listing.
|
|
236
|
+
const claimRecord = (p: Product, user: User) => ({ type: 'TXT', name: `${CLAIM_LABEL}.${p.domain.replace(/^www\./, '')}`, value: `${CLAIM_PREFIX}${service.claimToken(p, user)}` })
|
|
237
|
+
const claimView = (c: Context<Env>, p: Product, user: User, extra: { result?: { ok: boolean; looked: string[] } } = {}, status = 200) =>
|
|
238
|
+
page(c, claimPage(ctxOf(c), { product: p, record: claimRecord(p, user), owner: p.ownerId === user.id, ...extra }), status)
|
|
239
|
+
// The same instructions as Markdown, to paste to an AI agent with DNS access.
|
|
240
|
+
app.get('/p/:slug/claim.md', (c) => {
|
|
241
|
+
const user = sessionUser(c)
|
|
242
|
+
if (!user) return c.redirect(`/login?next=${encodeURIComponent(c.req.path)}`)
|
|
243
|
+
const p = store.bySlug(c.req.param('slug'))
|
|
244
|
+
if (!p || p.status === 'hidden') return c.text('not found', 404)
|
|
245
|
+
return c.body(claimMarkdown(origin, p, claimRecord(p, user)), 200, { 'content-type': 'text/markdown; charset=utf-8', 'cache-control': 'private, no-store' })
|
|
246
|
+
})
|
|
247
|
+
app.get('/p/:slug/claim', (c) => {
|
|
248
|
+
const user = sessionUser(c)
|
|
249
|
+
if (!user) return toLogin(c)
|
|
250
|
+
const p = store.bySlug(c.req.param('slug'))
|
|
251
|
+
if (!p || p.status === 'hidden') return c.notFound()
|
|
252
|
+
return claimView(c, p, user)
|
|
253
|
+
})
|
|
254
|
+
app.post('/p/:slug/claim', async (c) => {
|
|
255
|
+
const user = sessionUser(c)
|
|
256
|
+
if (!user) return toLogin(c)
|
|
257
|
+
const p = store.bySlug(c.req.param('slug'))
|
|
258
|
+
if (!p || p.status === 'hidden') return c.notFound()
|
|
259
|
+
if (crossSite(c) || !readLimit.allow(`read:${ip(c)}`)) return claimView(c, p, user, {}, 429)
|
|
260
|
+
const result = await service.claim(p, user)
|
|
261
|
+
return claimView(c, store.product(p.id)!, user, { result })
|
|
262
|
+
})
|
|
263
|
+
|
|
264
|
+
// Signing in: an emailed link, which is also sign-up.
|
|
265
|
+
app.get('/login', (c) => page(c, loginPage(ctxOf(c), { next: safeNext(c.req.query('next')) })))
|
|
266
|
+
app.post('/login', async (c) => {
|
|
267
|
+
const form = (await c.req.parseBody()) as Record<string, string>
|
|
268
|
+
const next = safeNext(form.next)
|
|
269
|
+
if (!validEmail(form.email)) return page(c, loginPage(ctxOf(c), { next, error: 'That does not look like an email address.', email: form.email }), 400)
|
|
270
|
+
if (!crossSite(c) && readLimit.allow(`mail:${ip(c)}`)) await deps.auth.sendLink(form.email ?? '', next, here(c))
|
|
271
|
+
return page(c, loginPage(ctxOf(c), { next, sent: true }))
|
|
272
|
+
})
|
|
273
|
+
app.get('/auth/verify', (c) => {
|
|
274
|
+
const r = deps.auth.consumeLink(c.req.query('token') ?? '')
|
|
275
|
+
if (!r) return page(c, messagePage(ctxOf(c), { title: 'That link is used up', message: 'Sign-in links work once, for 20 minutes. Ask for a new one.' }), 400)
|
|
276
|
+
setCookie(c, SESSION_COOKIE, deps.auth.createSession(r.user.id), { httpOnly: true, secure: deps.secureCookies ?? (c.req.header('x-forwarded-proto') ?? 'https') === 'https', sameSite: 'Lax', path: '/', maxAge: 90 * 86400 })
|
|
277
|
+
return c.redirect(r.next ?? '/me')
|
|
278
|
+
})
|
|
279
|
+
app.post('/logout', (c) => {
|
|
280
|
+
if (!crossSite(c)) deps.auth.endSession(getCookie(c, SESSION_COOKIE))
|
|
281
|
+
deleteCookie(c, SESSION_COOKIE, { path: '/' })
|
|
282
|
+
return c.redirect('/')
|
|
283
|
+
})
|
|
284
|
+
app.get('/me', (c) => {
|
|
285
|
+
const user = sessionUser(c)
|
|
286
|
+
return user ? page(c, mePage(ctxOf(c), { user, products: store.mine(user.id) })) : toLogin(c)
|
|
287
|
+
})
|
|
288
|
+
app.post('/me/key', (c) => {
|
|
289
|
+
const user = sessionUser(c)
|
|
290
|
+
if (!user) return toLogin(c)
|
|
291
|
+
if (crossSite(c)) return c.redirect('/me')
|
|
292
|
+
return page(c, mePage(ctxOf(c), { user, products: store.mine(user.id), apiKey: store.newApiKey(user.id) }))
|
|
293
|
+
})
|
|
294
|
+
|
|
295
|
+
app.post('/p/:slug/vote', (c) => {
|
|
296
|
+
const p = live(c.req.param('slug'))
|
|
297
|
+
if (!p) return wantsJson(c) ? c.json({ error: 'no such product' }, 404) : c.notFound()
|
|
298
|
+
if (crossSite(c)) return c.json({ error: 'vote from built.indie itself' }, 403)
|
|
299
|
+
const counted = voteLimit.allow(`vote:${ip(c)}`) && store.vote(p.id, voter(c))
|
|
300
|
+
const now = store.product(p.id)!
|
|
301
|
+
if (wantsJson(c)) return c.json({ slug: now.slug, votes: now.votes, counted, voted: true })
|
|
302
|
+
const back = c.req.header('referer')
|
|
303
|
+
let to = `/p/${p.slug}`
|
|
304
|
+
try {
|
|
305
|
+
if (back && new URL(back).host === c.req.header('host')) to = new URL(back).pathname
|
|
306
|
+
} catch {}
|
|
307
|
+
return c.redirect(to)
|
|
308
|
+
})
|
|
309
|
+
|
|
310
|
+
app.post('/p/:slug/verify', async (c) => {
|
|
311
|
+
const p = live(c.req.param('slug'))
|
|
312
|
+
if (!p) return c.notFound()
|
|
313
|
+
if (!readLimit.allow(`read:${ip(c)}`)) return c.redirect(`/p/${p.slug}#badge`)
|
|
314
|
+
const ok = await service.verify(p)
|
|
315
|
+
return page(c, messagePage(ctxOf(c), ok ? { title: 'Verified ✓', message: `We found the badge on ${p.domain}. Your link here is now a followed link.` } : { title: 'Not yet', message: `We read ${p.domain} and did not find a link to built.indie. Add the badge HTML to your homepage, deploy, and try again.` }))
|
|
316
|
+
})
|
|
317
|
+
|
|
318
|
+
app.post('/p/:slug/feature', async (c) => {
|
|
319
|
+
const p = live(c.req.param('slug'))
|
|
320
|
+
if (!p) return c.notFound()
|
|
321
|
+
const form = (await c.req.parseBody()) as Record<string, string>
|
|
322
|
+
try {
|
|
323
|
+
const r = await service.feature(p, form.chain ?? '')
|
|
324
|
+
return c.redirect(r.checkoutUrl)
|
|
325
|
+
} catch (e) {
|
|
326
|
+
return page(c, messagePage(ctxOf(c), { title: 'Could not start the payment', message: e instanceof Error ? e.message : String(e) }), e instanceof InputError ? 400 : 502)
|
|
327
|
+
}
|
|
328
|
+
})
|
|
329
|
+
|
|
330
|
+
// The open-spec documents for a product.
|
|
331
|
+
app.get('/p/:slug/opensite.json', (c) => {
|
|
332
|
+
const p = live(c.req.param('slug'))
|
|
333
|
+
return p?.record ? c.json(p.record, 200, { 'access-control-allow-origin': '*' }) : c.json({ error: 'not found' }, 404)
|
|
334
|
+
})
|
|
335
|
+
app.get('/p/:slug/openprofile.md', (c) => {
|
|
336
|
+
const p = live(c.req.param('slug'))
|
|
337
|
+
return p ? c.body(service.openProfile(p), 200, { 'content-type': 'text/markdown; charset=utf-8', 'access-control-allow-origin': '*' }) : c.text('not found', 404)
|
|
338
|
+
})
|
|
339
|
+
|
|
340
|
+
// Pictures we saved. SVG is served sandboxed: it is a picture, never a document.
|
|
341
|
+
app.get('/m/:id/:file', async (c) => {
|
|
342
|
+
const { id, file } = c.req.param()
|
|
343
|
+
const body = await service.media.read(id, file)
|
|
344
|
+
if (!body) return c.notFound()
|
|
345
|
+
const type = IMAGE_TYPES[file.split('.').pop()!]!
|
|
346
|
+
return c.body(body as Uint8Array<ArrayBuffer>, 200, { 'content-type': type, 'cache-control': 'public, max-age=604800', 'content-security-policy': "default-src 'none'; style-src 'unsafe-inline'; sandbox", 'x-content-type-options': 'nosniff' })
|
|
347
|
+
})
|
|
348
|
+
|
|
349
|
+
// The badge: the viral part. Cached briefly so the count stays live.
|
|
350
|
+
app.get('/badge/:file{[a-z0-9-]+\\.svg}', (c) => {
|
|
351
|
+
const p = live(c.req.param('file').replace(/\.svg$/, ''))
|
|
352
|
+
if (!p) return c.body('<svg xmlns="http://www.w3.org/2000/svg" width="1" height="1"/>', 404, { 'content-type': 'image/svg+xml' })
|
|
353
|
+
return c.body(badgeSvg(p, store.awards(p.id), c.req.query('theme') === 'light' ? 'light' : 'dark'), 200, {
|
|
354
|
+
'content-type': 'image/svg+xml; charset=utf-8',
|
|
355
|
+
'cache-control': 'public, max-age=300',
|
|
356
|
+
'access-control-allow-origin': '*',
|
|
357
|
+
'cross-origin-resource-policy': 'cross-origin',
|
|
358
|
+
'content-security-policy': "default-src 'none'; style-src 'unsafe-inline'",
|
|
359
|
+
})
|
|
360
|
+
})
|
|
361
|
+
|
|
362
|
+
app.get('/makers/:id', (c) => {
|
|
363
|
+
const m = store.maker(c.req.param('id'))
|
|
364
|
+
return m ? page(c, makerPage(ctxOf(c), { maker: m, products: store.byMaker(m.id) })) : page(c, messagePage(ctxOf(c), { title: 'Not here', message: 'No such maker.' }), 404)
|
|
365
|
+
})
|
|
366
|
+
app.get('/makers/:id/openprofile.md', (c) => {
|
|
367
|
+
const md = service.makerProfile(c.req.param('id'))
|
|
368
|
+
return md ? c.body(md, 200, { 'content-type': 'text/markdown; charset=utf-8', 'access-control-allow-origin': '*' }) : c.text('not found', 404)
|
|
369
|
+
})
|
|
370
|
+
|
|
371
|
+
// Feeds and files for machines.
|
|
372
|
+
app.get('/feed.xml', (c) => {
|
|
373
|
+
const items = store
|
|
374
|
+
.list('new', 50)
|
|
375
|
+
.map((p) => `<item><title>${esc(`${p.name}: ${p.tagline}`)}</title><link>${origin}/p/${p.slug}</link><guid isPermaLink="true">${origin}/p/${p.slug}</guid><pubDate>${new Date(p.launchedAt ?? p.createdAt).toUTCString()}</pubDate><description>${esc(p.description)}</description>${cardImage(origin, p) ? `<enclosure url="${esc(cardImage(origin, p))}" type="image/png" length="0"/>` : ''}</item>`)
|
|
376
|
+
.join('')
|
|
377
|
+
return c.body(`<?xml version="1.0" encoding="UTF-8"?><rss version="2.0"><channel><title>built.indie launches</title><link>${origin}/</link><description>Indie products launched on built.indie</description>${items}</channel></rss>`, 200, { 'content-type': 'application/rss+xml; charset=utf-8' })
|
|
378
|
+
})
|
|
379
|
+
app.get('/sitemap.xml', (c) => {
|
|
380
|
+
const urls = ['/', '/top/today', '/top/week', '/top/month', '/top/all', '/new', '/submit', '/help', ...store.list('all', 5000).map((p) => `/p/${p.slug}`)]
|
|
381
|
+
return c.body(`<?xml version="1.0" encoding="UTF-8"?><urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">${urls.map((u) => `<url><loc>${origin}${u}</loc></url>`).join('')}</urlset>`, 200, { 'content-type': 'application/xml' })
|
|
382
|
+
})
|
|
383
|
+
app.get('/robots.txt', (c) => c.text(`User-agent: *\nAllow: /\nDisallow: /p/*/edit\nSitemap: ${origin}/sitemap.xml\n`))
|
|
384
|
+
app.get('/llms.txt', (c) =>
|
|
385
|
+
c.text(`# built.indie\n\n> Launch your indie product with one URL. built.indie reads your homepage (meta tags, Open Graph, JSON-LD, llms.txt, OpenSite, OpenProfile), saves the logo and screenshots, and puts it on a daily upvote board. Free, open source, no account.\n\n## API\n\n- [Help](${origin}/help): JSON API, CLI, TUI and MCP\n- [Today](${origin}/api/v1/products?window=today): today's launches as JSON\n- [Launch](${origin}/help#api): POST /api/v1/products {"url": "..."}\n- [MCP](${origin}/help#mcp): claude mcp add builtindie -- bunx @profullstack/builtindie mcp\n- [Install](${origin}/install.sh): curl -fsSL ${origin}/install.sh | sh\n- [Feed](${origin}/feed.xml): RSS of every launch\n`),
|
|
386
|
+
)
|
|
387
|
+
app.get('/.well-known/openprofile.md', (c) =>
|
|
388
|
+
c.body(`# built.indie\n\n- **Kind**: organization\n- **Web**: ${origin}\n- **Avatar**: ${origin}/icon-512.png\n\nLaunch your indie product with one URL.\n\n## Accounts\n\n- ${'https://github.com/moshcoder/built.indie'}\n- https://pit.world/ring\n\n## Topics\n\n- indie\n- launches\n- startups\n\n## Operator\n\n- **Name**: Profullstack, Inc.\n- **Profile**: https://profullstack.com/.well-known/openprofile.md\n`, 200, { 'content-type': 'text/markdown; charset=utf-8', 'access-control-allow-origin': '*' }),
|
|
389
|
+
)
|
|
390
|
+
app.get('/.well-known/opensite.json', (c) =>
|
|
391
|
+
c.json(
|
|
392
|
+
{
|
|
393
|
+
opensite: '0.1',
|
|
394
|
+
site: { name: 'built.indie', web: origin, description: 'Launch your indie product with one URL.', icon: `${origin}/icon-512.png`, language: 'en', operator: `${origin}/.well-known/openprofile.md`, kinds: ['product', 'page'], feeds: [`${origin}/feed.xml`], sitemaps: [`${origin}/sitemap.xml`] },
|
|
395
|
+
index: { allow: true, refresh: 3600 },
|
|
396
|
+
},
|
|
397
|
+
200,
|
|
398
|
+
{ 'access-control-allow-origin': '*' },
|
|
399
|
+
),
|
|
400
|
+
)
|
|
401
|
+
|
|
402
|
+
// The JSON API.
|
|
403
|
+
const apiProduct = (p: Product) => ({
|
|
404
|
+
slug: p.slug,
|
|
405
|
+
name: p.name,
|
|
406
|
+
tagline: p.tagline,
|
|
407
|
+
description: p.description,
|
|
408
|
+
url: p.url,
|
|
409
|
+
domain: p.domain,
|
|
410
|
+
status: p.status,
|
|
411
|
+
votes: p.votes,
|
|
412
|
+
tags: p.tags,
|
|
413
|
+
logo: p.logo ? `${origin}/m/${p.id}/${p.logo}` : null,
|
|
414
|
+
card_image: p.ogImage ? `${origin}/m/${p.id}/${p.ogImage}` : null,
|
|
415
|
+
screenshot: p.screenshot ? `${origin}/m/${p.id}/${p.screenshot}` : null,
|
|
416
|
+
images: p.images.map((f) => `${origin}/m/${p.id}/${f}`),
|
|
417
|
+
videos: p.videos,
|
|
418
|
+
socials: p.socials,
|
|
419
|
+
llms: p.llms,
|
|
420
|
+
profile: p.profile,
|
|
421
|
+
maker: p.makerName || p.makerProfile ? { name: p.makerName, profile: p.makerProfile, verified: p.makerVerified, page: p.makerId ? `${origin}/makers/${p.makerId}` : null } : null,
|
|
422
|
+
verified: Boolean(p.verifiedAt),
|
|
423
|
+
featured_until: p.featuredUntil ? new Date(p.featuredUntil).toISOString() : null,
|
|
424
|
+
launched_at: p.launchedAt ? new Date(p.launchedAt).toISOString() : null,
|
|
425
|
+
page: `${origin}/p/${p.slug}`,
|
|
426
|
+
badge: `${origin}/badge/${p.slug}.svg`,
|
|
427
|
+
stack: p.extras.stack,
|
|
428
|
+
pricing: p.extras.pricing,
|
|
429
|
+
openicon: p.extras.openicon,
|
|
430
|
+
opensite: `${origin}/p/${p.slug}/opensite.json`,
|
|
431
|
+
openprofile: `${origin}/p/${p.slug}/openprofile.md`,
|
|
432
|
+
})
|
|
433
|
+
const apiError = (c: Context, e: unknown) => {
|
|
434
|
+
if (!(e instanceof InputError) && !(e instanceof FetchError)) console.error('api', e)
|
|
435
|
+
return c.json({ error: e instanceof Error ? e.message : String(e), ...(e instanceof InputError && e.field ? { field: e.field } : {}) }, e instanceof InputError || e instanceof FetchError ? 400 : 502)
|
|
436
|
+
}
|
|
437
|
+
const body = async (c: Context) => ((await c.req.json().catch(() => ({}))) ?? {}) as Record<string, any>
|
|
438
|
+
const apiUser = (c: Context) => store.byApiKey(bearer(c))
|
|
439
|
+
const owned = (c: Context) => {
|
|
440
|
+
const p = store.bySlug(c.req.param('slug') ?? '')
|
|
441
|
+
return p && store.canEdit(p, apiUser(c)) ? p : null
|
|
442
|
+
}
|
|
443
|
+
const NEEDS_KEY = { error: 'send your API key (from /me): authorization: Bearer bi_…, for a product you submitted or claimed (/p/<slug>/claim)' }
|
|
444
|
+
|
|
445
|
+
app.post('/api/v1/preview', async (c) => {
|
|
446
|
+
if (!readLimit.allow(`read:${ip(c)}`)) return c.json({ error: 'too many reads from you; wait a few minutes' }, 429)
|
|
447
|
+
try {
|
|
448
|
+
return c.json(await autofetch(String((await body(c)).url ?? ''), config.fetch))
|
|
449
|
+
} catch (e) {
|
|
450
|
+
return apiError(c, e)
|
|
451
|
+
}
|
|
452
|
+
})
|
|
453
|
+
|
|
454
|
+
app.post('/api/v1/products', async (c) => {
|
|
455
|
+
if (!readLimit.allow(`read:${ip(c)}`)) return c.json({ error: 'too many launches from you; wait a few minutes' }, 429)
|
|
456
|
+
const b = await body(c)
|
|
457
|
+
try {
|
|
458
|
+
const user = apiUser(c)
|
|
459
|
+
const r = await service.submit({ url: String(b.url ?? ''), makerName: b.maker_name, makerProfile: b.maker_profile }, store.voterFor(ip(c)), user)
|
|
460
|
+
if (r.existing) return c.json({ existing: true, product: apiProduct(r.product) }, 200)
|
|
461
|
+
let p = r.product
|
|
462
|
+
if (user && (b.name || b.tagline || b.description || b.tags || b.videos)) p = await service.edit(p, { name: b.name, tagline: b.tagline, description: b.description, tags: b.tags, videos: b.videos })
|
|
463
|
+
if (user && b.publish !== false && p.status === 'draft') p = service.publish(p)
|
|
464
|
+
return c.json({ product: apiProduct(p), claim_url: `${origin}/p/${p.slug}/claim` }, 201)
|
|
465
|
+
} catch (e) {
|
|
466
|
+
return apiError(c, e)
|
|
467
|
+
}
|
|
468
|
+
})
|
|
469
|
+
|
|
470
|
+
app.get('/api/v1/products', (c) => {
|
|
471
|
+
const w = (c.req.query('window') ?? 'today') as Window
|
|
472
|
+
const q = c.req.query('q')
|
|
473
|
+
if (!q && !WINDOWS.includes(w)) return c.json({ error: `window is one of ${WINDOWS.join(', ')}` }, 400)
|
|
474
|
+
const limit = Math.min(100, Math.max(1, Number(c.req.query('limit') ?? 30) || 30))
|
|
475
|
+
const offset = Math.max(0, Number(c.req.query('offset') ?? 0) || 0)
|
|
476
|
+
return c.json({ window: q ? 'search' : w, products: (q ? store.search(q.slice(0, 60), limit) : store.list(w, limit, offset)).map(apiProduct) }, 200, { 'access-control-allow-origin': '*' })
|
|
477
|
+
})
|
|
478
|
+
|
|
479
|
+
app.get('/api/v1/products/:slug', (c) => {
|
|
480
|
+
const p = store.bySlug(c.req.param('slug'))
|
|
481
|
+
const mine = owned(c)
|
|
482
|
+
if (!p || (p.status !== 'live' && !mine)) return c.json({ error: 'no such product' }, 404)
|
|
483
|
+
return c.json({ ...apiProduct(p), award: store.awards(p.id) }, 200, { 'access-control-allow-origin': '*' })
|
|
484
|
+
})
|
|
485
|
+
|
|
486
|
+
app.patch('/api/v1/products/:slug', async (c) => {
|
|
487
|
+
const p = owned(c)
|
|
488
|
+
if (!p) return c.json(NEEDS_KEY, 401)
|
|
489
|
+
const b = await body(c)
|
|
490
|
+
try {
|
|
491
|
+
return c.json(apiProduct(await service.edit(p, { name: b.name, tagline: b.tagline, description: b.description, tags: b.tags, videos: b.videos, makerName: b.maker_name, makerProfile: b.maker_profile, socials: b.socials, remove: b.remove })))
|
|
492
|
+
} catch (e) {
|
|
493
|
+
return apiError(c, e)
|
|
494
|
+
}
|
|
495
|
+
})
|
|
496
|
+
|
|
497
|
+
app.post('/api/v1/products/:slug/:action{(publish|refetch|verify|feature)}', async (c) => {
|
|
498
|
+
const action = c.req.param('action')
|
|
499
|
+
const p = action === 'verify' ? live(c.req.param('slug')) : owned(c)
|
|
500
|
+
if (!p) return c.json(action === 'verify' ? { error: 'no such product' } : NEEDS_KEY, action === 'verify' ? 404 : 401)
|
|
501
|
+
try {
|
|
502
|
+
if (action === 'publish') return c.json(apiProduct(service.publish(p)))
|
|
503
|
+
if (!readLimit.allow(`read:${ip(c)}`)) return c.json({ error: 'too many reads from you; wait a few minutes' }, 429)
|
|
504
|
+
if (action === 'refetch') return c.json(apiProduct(await service.refetch(p)))
|
|
505
|
+
if (action === 'verify') return c.json({ verified: await service.verify(p) })
|
|
506
|
+
const r = await service.feature(p, String((await body(c)).chain ?? ''))
|
|
507
|
+
return c.json({ checkout_url: r.checkoutUrl, payment: r.ref, usd: config.featureUsd, days: config.featureDays })
|
|
508
|
+
} catch (e) {
|
|
509
|
+
return apiError(c, e)
|
|
510
|
+
}
|
|
511
|
+
})
|
|
512
|
+
|
|
513
|
+
app.post('/api/v1/products/:slug/vote', (c) => {
|
|
514
|
+
const p = live(c.req.param('slug'))
|
|
515
|
+
if (!p) return c.json({ error: 'no such product' }, 404)
|
|
516
|
+
if (crossSite(c)) return c.json({ error: 'vote from built.indie itself' }, 403)
|
|
517
|
+
const counted = voteLimit.allow(`vote:${ip(c)}`) && store.vote(p.id, voter(c))
|
|
518
|
+
return c.json({ slug: p.slug, votes: store.product(p.id)!.votes, counted })
|
|
519
|
+
})
|
|
520
|
+
|
|
521
|
+
app.delete('/api/v1/products/:slug', async (c) => {
|
|
522
|
+
// The maker deletes; an admin takes down (hidden, kept for the record).
|
|
523
|
+
if (isAdmin(c)) {
|
|
524
|
+
const p = store.bySlug(c.req.param('slug'))
|
|
525
|
+
if (!p) return c.json({ error: 'no such product' }, 404)
|
|
526
|
+
store.update(p.id, { status: 'hidden' })
|
|
527
|
+
return c.json({ hidden: true })
|
|
528
|
+
}
|
|
529
|
+
const p = owned(c)
|
|
530
|
+
if (!p) return c.json(NEEDS_KEY, 401)
|
|
531
|
+
store.delete(p.id)
|
|
532
|
+
await service.media.remove(p.id)
|
|
533
|
+
return c.json({ deleted: true })
|
|
534
|
+
})
|
|
535
|
+
|
|
536
|
+
const NEEDS_ACCOUNT = { error: 'send your API key (from /me): authorization: Bearer bi_…' }
|
|
537
|
+
app.get('/api/v1/products/:slug/claim', (c) => {
|
|
538
|
+
const user = apiUser(c)
|
|
539
|
+
if (!user) return c.json(NEEDS_ACCOUNT, 401)
|
|
540
|
+
const p = store.bySlug(c.req.param('slug'))
|
|
541
|
+
if (!p || p.status === 'hidden') return c.json({ error: 'no such product' }, 404)
|
|
542
|
+
return c.json({ owner: p.ownerId === user.id, record: claimRecord(p, user), markdown: claimMarkdown(origin, p, claimRecord(p, user)) })
|
|
543
|
+
})
|
|
544
|
+
app.post('/api/v1/products/:slug/claim', async (c) => {
|
|
545
|
+
const user = apiUser(c)
|
|
546
|
+
if (!user) return c.json(NEEDS_ACCOUNT, 401)
|
|
547
|
+
const p = store.bySlug(c.req.param('slug'))
|
|
548
|
+
if (!p || p.status === 'hidden') return c.json({ error: 'no such product' }, 404)
|
|
549
|
+
if (!readLimit.allow(`read:${ip(c)}`)) return c.json({ error: 'too many checks; wait a few minutes' }, 429)
|
|
550
|
+
const r = await service.claim(p, user)
|
|
551
|
+
return c.json({ claimed: r.ok, looked: r.looked, record: claimRecord(p, user), ...(r.ok ? {} : { markdown: claimMarkdown(origin, p, claimRecord(p, user)) }) })
|
|
552
|
+
})
|
|
553
|
+
app.get('/api/v1/me', (c) => {
|
|
554
|
+
const user = apiUser(c)
|
|
555
|
+
if (!user) return c.json(NEEDS_ACCOUNT, 401)
|
|
556
|
+
return c.json({ email: user.email, products: store.mine(user.id).map((p) => ({ ...apiProduct(p), owner: p.ownerId === user.id })) })
|
|
557
|
+
})
|
|
558
|
+
|
|
559
|
+
app.get('/api/v1/makers/:id', (c) => {
|
|
560
|
+
const m = store.maker(c.req.param('id'))
|
|
561
|
+
return m ? c.json({ ...m, page: `${origin}/makers/${m.id}`, products: store.byMaker(m.id).map(apiProduct) }) : c.json({ error: 'no such maker' }, 404)
|
|
562
|
+
})
|
|
563
|
+
|
|
564
|
+
// OpenSite index calls (logicsrc.com/docs/opensite, "Publishing a record").
|
|
565
|
+
app.get('/api/v1/sites', (c) => {
|
|
566
|
+
const url = c.req.query('url') ?? ''
|
|
567
|
+
let p: Product | null = null
|
|
568
|
+
try {
|
|
569
|
+
p = url ? store.byUrl(url.startsWith('http') ? url : `https://${url}`) : null
|
|
570
|
+
} catch {}
|
|
571
|
+
return p?.record && p.status === 'live' ? c.json(p.record, 200, { 'access-control-allow-origin': '*' }) : c.json({ error: 'no record for that URL; POST /api/v1/sites to read it' }, 404)
|
|
572
|
+
})
|
|
573
|
+
app.post('/api/v1/sites', async (c) => {
|
|
574
|
+
if (!readLimit.allow(`read:${ip(c)}`)) return c.json({ error: 'too many reads from you; wait a few minutes' }, 429)
|
|
575
|
+
try {
|
|
576
|
+
return c.json(service.recordFrom(await autofetch(String((await body(c)).url ?? ''), config.fetch)))
|
|
577
|
+
} catch (e) {
|
|
578
|
+
return apiError(c, e)
|
|
579
|
+
}
|
|
580
|
+
})
|
|
581
|
+
|
|
582
|
+
app.get('/api/v1/stats', (c) => c.json({ ...store.count(), version: VERSION }))
|
|
583
|
+
|
|
584
|
+
// CoinPay's events for the Featured slot.
|
|
585
|
+
app.post('/hooks/coinpay', async (c) => {
|
|
586
|
+
const raw = await c.req.text()
|
|
587
|
+
const r = service.webhook(raw, c.req.header('x-coinpay-signature'))
|
|
588
|
+
return r.ok ? c.json({ ok: true }) : c.json({ error: 'bad signature' }, (r.status ?? 401) as 401)
|
|
589
|
+
})
|
|
590
|
+
|
|
591
|
+
app.notFound((c) => (c.req.path.startsWith('/api/') ? c.json({ error: 'not found' }, 404) : page(c, messagePage(ctxOf(c as Context<Env>), { title: 'Not found', message: 'That page does not exist.' }), 404)))
|
|
592
|
+
app.onError((error, c) => {
|
|
593
|
+
console.error(error instanceof Error ? error.stack : error)
|
|
594
|
+
return c.req.path.startsWith('/api/') ? c.json({ error: 'Something went wrong.' }, 500) : page(c, messagePage(ctxOf(c as Context<Env>), { title: 'Something went wrong', message: 'Try again in a moment.' }), 500)
|
|
595
|
+
})
|
|
596
|
+
return app
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
/** The client's address: nginx's X-Real-IP, or, when the request came through the pit.world relay on this box, the relay's X-Forwarded-For. */
|
|
600
|
+
export function clientIp(peer: string, headers: Headers, relays: string[]): string {
|
|
601
|
+
const loopback = peer === '127.0.0.1' || peer === '::1' || peer === '::ffff:127.0.0.1'
|
|
602
|
+
if (!loopback) return peer
|
|
603
|
+
const real = headers.get('x-real-ip') ?? ''
|
|
604
|
+
if (real && relays.includes(real) && headers.get('x-moshpit-name')) {
|
|
605
|
+
const first = (headers.get('x-forwarded-for') ?? '').split(',')[0]?.trim() ?? ''
|
|
606
|
+
if (first && !privateAddress(first)) return first
|
|
607
|
+
}
|
|
608
|
+
return real || peer
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
export function configFromEnv(env: Record<string, string | undefined> = process.env): Config {
|
|
612
|
+
const coinpay =
|
|
613
|
+
env.COINPAY_API_KEY && env.COINPAY_BUSINESS_ID
|
|
614
|
+
? { baseUrl: (env.COINPAY_API_URL ?? 'https://coinpayportal.com').replace(/\/+$/, '').replace(/\/api$/, ''), apiKey: env.COINPAY_API_KEY, businessId: env.COINPAY_BUSINESS_ID, webhookSecret: env.COINPAY_WEBHOOK_SECRET ?? '' }
|
|
615
|
+
: null
|
|
616
|
+
const chains = (env.COINPAY_CHAINS ?? '').split(/[\s,]+/).filter(Boolean)
|
|
617
|
+
return {
|
|
618
|
+
origin: (env.BUILTINDIE_ORIGIN ?? PUBLIC_ORIGIN).replace(/\/+$/, ''),
|
|
619
|
+
coinpay,
|
|
620
|
+
featureUsd: Number(env.FEATURE_USD ?? 19),
|
|
621
|
+
featureDays: Number(env.FEATURE_DAYS ?? 7),
|
|
622
|
+
chains: chains.length ? chains : DEFAULT_CHAINS,
|
|
623
|
+
screenshots: env.BUILTINDIE_SCREENSHOTS !== 'off',
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
export function serve(options: { port?: number; hostname?: string } = {}) {
|
|
628
|
+
const env = process.env
|
|
629
|
+
const dataDir = env.BUILTINDIE_DATA ?? `${env.HOME}/.local/share/built-indie`
|
|
630
|
+
const store = new Store(env.BUILTINDIE_DB ?? `${dataDir}/built-indie.db`, env.BUILTINDIE_KEY ?? '')
|
|
631
|
+
const media = new Media(env.BUILTINDIE_MEDIA ?? `${dataDir}/media`)
|
|
632
|
+
const service = new BuiltIndie(store, media, { ...configFromEnv(env), claimSecret: env.BUILTINDIE_KEY })
|
|
633
|
+
const mailer = env.RESEND_API_KEY ? resendMailer(env.RESEND_API_KEY, env.MAIL_FROM ?? 'built.indie <built@pit.world>') : consoleMailer
|
|
634
|
+
const app = createApp({ service, auth: new Auth(store, mailer), adminKey: env.BUILTINDIE_ADMIN_KEY })
|
|
635
|
+
const relays = (env.BUILTINDIE_RELAY_IPS ?? '23.95.228.174,127.0.0.1,::1').split(/[\s,]+/).filter(Boolean)
|
|
636
|
+
startRingRefresh()
|
|
637
|
+
// Drafts nobody launched within a day go, pictures too.
|
|
638
|
+
const sweep = () => void Promise.all(store.sweepDrafts().map((id) => media.remove(id))).catch(() => {})
|
|
639
|
+
sweep()
|
|
640
|
+
setInterval(sweep, 3600_000).unref?.()
|
|
641
|
+
return Bun.serve({
|
|
642
|
+
port: options.port ?? Number(env.PORT ?? 8798),
|
|
643
|
+
hostname: options.hostname ?? env.HOST ?? '127.0.0.1',
|
|
644
|
+
idleTimeout: 120,
|
|
645
|
+
maxRequestBodySize: 1024 * 1024,
|
|
646
|
+
fetch(request, srv) {
|
|
647
|
+
return app.fetch(request, { ip: clientIp(srv.requestIP(request)?.address ?? '', request.headers, relays) })
|
|
648
|
+
},
|
|
649
|
+
})
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
if (import.meta.main) {
|
|
653
|
+
const server = serve()
|
|
654
|
+
console.log(`built.indie listening on http://${server.hostname}:${server.port}`)
|
|
655
|
+
}
|