@profullstack/builtindie 0.1.4 → 0.6.0
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/OpenStack.md +1 -1
- package/README.md +24 -4
- package/package.json +5 -2
- package/public/app.js +58 -0
- package/public/style.css +108 -11
- package/src/auth.ts +10 -6
- package/src/browse.ts +243 -0
- package/src/cli.ts +110 -10
- package/src/client.ts +81 -5
- package/src/coinpay-signin.ts +118 -0
- package/src/db.ts +398 -8
- package/src/fetch.ts +15 -7
- package/src/html.ts +490 -76
- package/src/karma.ts +179 -0
- package/src/leaderboard.ts +59 -0
- package/src/mcp.ts +52 -4
- package/src/pit-footer.ts +39 -0
- package/src/ring-footer.ts +53 -0
- package/src/ring.ts +5 -1
- package/src/server.ts +439 -42
- package/src/service.ts +238 -74
- package/src/tags.ts +92 -0
- package/src/tui.ts +28 -6
- package/src/version.ts +1 -1
package/src/server.ts
CHANGED
|
@@ -7,16 +7,24 @@
|
|
|
7
7
|
* https://built.indie.pit.world, which is the address we hand out (badges,
|
|
8
8
|
* share links, feeds) because it works for everybody.
|
|
9
9
|
*/
|
|
10
|
+
import { startRingFooterRefresh } from './ring-footer.js'
|
|
10
11
|
import { Hono, type Context } from 'hono'
|
|
11
12
|
import { deleteCookie, getCookie, setCookie } from 'hono/cookie'
|
|
12
|
-
import {
|
|
13
|
-
import {
|
|
13
|
+
import { leaderboard } from '@profullstack/leaderboard/hono'
|
|
14
|
+
import { Auth, consoleMailer, resendMailer, safeNext, SESSION_COOKIE, validEmail, type Mailer } from './auth.js'
|
|
15
|
+
import { Store, type Comment, type Product, type Review, type User, type Window } from './db.js'
|
|
14
16
|
import { privateAddress } from './fetch.js'
|
|
15
|
-
import {
|
|
17
|
+
import { CATEGORY_LABELS, parseQuery, queryString, search } from './browse.js'
|
|
18
|
+
import { badgeSvg, launchBadgeSvg, boardPage, browsePage, cardImage, claimBadgeSnippet, claimMarkdown, claimPage, editPage, esc, helpPage, homePage, inviteLanding, invitePage, leaderboardPage, loginPage, makerPage, mePage, messagePage, productPage, submitPage, tagsPage, type CommentView, type Ctx, type InviterRow, type ReviewView } from './html.js'
|
|
19
|
+
import { normalizeTag } from './tags.js'
|
|
20
|
+
import { siteLeaderboard } from './leaderboard.js'
|
|
21
|
+
import { HOW_TO_EARN, SHARE_NETWORKS } from './karma.js'
|
|
16
22
|
import { IMAGE_TYPES, Media } from './media.js'
|
|
23
|
+
import { startPitFooterRefresh } from './pit-footer.js'
|
|
17
24
|
import { RING_PLACEHOLDER, ringBar, startRingRefresh, visitorOnMoshpit } from './ring.js'
|
|
18
|
-
import { BuiltIndie, CLAIM_LABEL, CLAIM_PREFIX,
|
|
25
|
+
import { BuiltIndie, CLAIM_LABEL, CLAIM_PREFIX, InputError, type Config, type Edit } from './service.js'
|
|
19
26
|
import { autofetch, FetchError } from './fetch.js'
|
|
27
|
+
import { COINPAY_ERRORS, coinpayOAuthFromEnv, isPlaceholder, mountCoinPaySignin, PROVIDER as COINPAY, type CoinPayOAuth } from './coinpay-signin.js'
|
|
20
28
|
import { VERSION } from './version.js'
|
|
21
29
|
|
|
22
30
|
export { VERSION }
|
|
@@ -46,7 +54,10 @@ const SECURITY_HEADERS: Record<string, string> = {
|
|
|
46
54
|
'cross-origin-opener-policy': 'same-origin',
|
|
47
55
|
}
|
|
48
56
|
|
|
49
|
-
export type Deps = { service: BuiltIndie; auth: Auth; adminKey?: string; secureCookies?: boolean }
|
|
57
|
+
export type Deps = { service: BuiltIndie; auth: Auth; adminKey?: string; secureCookies?: boolean; mailer?: Mailer; /** Sign in with CoinPay; off when null. */ coinpayOAuth?: CoinPayOAuth | null }
|
|
58
|
+
|
|
59
|
+
/** The invite code from /i/<code>, kept until the visitor signs up (it also rides in the magic link). */
|
|
60
|
+
const REF_COOKIE = 'bi_ref'
|
|
50
61
|
type Env = { Bindings: { ip?: string } }
|
|
51
62
|
|
|
52
63
|
/** A small fixed-window limit per key, in memory: enough to stop a loop, not a fortress. */
|
|
@@ -74,6 +85,8 @@ export function createApp(deps: Deps) {
|
|
|
74
85
|
const app = new Hono<Env>()
|
|
75
86
|
const readLimit = new Limiter(20, 10 * 60_000)
|
|
76
87
|
const voteLimit = new Limiter(60, 10 * 60_000)
|
|
88
|
+
const commentLimit = new Limiter(10, 10 * 60_000)
|
|
89
|
+
const shareLimit = new Limiter(60, 10 * 60_000)
|
|
77
90
|
const origin = config.origin
|
|
78
91
|
|
|
79
92
|
const direct = (c: Context) => visitorOnMoshpit(SELF, c.req.header('host'), c.req.header('x-moshpit-name'))
|
|
@@ -82,7 +95,7 @@ export function createApp(deps: Deps) {
|
|
|
82
95
|
const sessionUser = (c: Context) => deps.auth.sessionUser(getCookie(c, SESSION_COOKIE))
|
|
83
96
|
const ctxOf = (c: Context<Env>): Ctx => {
|
|
84
97
|
const v = voter(c)
|
|
85
|
-
return { origin, user: sessionUser(c), voted: (id) => store.voted(id, v) }
|
|
98
|
+
return { origin, user: sessionUser(c), voted: (id) => store.voted(id, v), coinpay: Boolean(deps.coinpayOAuth) }
|
|
86
99
|
}
|
|
87
100
|
// Links in mail open where the visitor asked from: the relay, or the Moshpit name itself.
|
|
88
101
|
const here = (c: Context) => (direct(c) ? 'https://built.indie' : origin)
|
|
@@ -100,6 +113,40 @@ export function createApp(deps: Deps) {
|
|
|
100
113
|
const p = store.bySlug(slug)
|
|
101
114
|
return p?.status === 'live' ? p : null
|
|
102
115
|
}
|
|
116
|
+
const secure = (c: Context) => deps.secureCookies ?? (c.req.header('x-forwarded-proto') ?? 'https') === 'https'
|
|
117
|
+
|
|
118
|
+
// The leaderboards (@profullstack/leaderboard over the karma ledger and the invites table).
|
|
119
|
+
const board = siteLeaderboard(store, origin)
|
|
120
|
+
const rowsOf = async (id: 'karma' | 'invites', period: 'week' | 'month' | 'all', limit: number): Promise<InviterRow[]> => (await board.top({ board: id, period, limit })).rows.map((r) => ({ ...r, url: `/leaderboard/u/${encodeURIComponent(r.id)}` }))
|
|
121
|
+
const inviters = (limit: number) => rowsOf('invites', 'week', limit)
|
|
122
|
+
/** Top karma this week; an empty week falls back to all time so the widget never looks dead. */
|
|
123
|
+
const topKarma = async (limit: number): Promise<{ period: 'week' | 'all'; rows: InviterRow[] }> => {
|
|
124
|
+
const week = await rowsOf('karma', 'week', limit)
|
|
125
|
+
return week.length ? { period: 'week', rows: week } : { period: 'all', rows: await rowsOf('karma', 'all', limit) }
|
|
126
|
+
}
|
|
127
|
+
// Every karma write: the board re-reads the ledger and checks badges (a projection is never record()ed).
|
|
128
|
+
service.karma.onChange = (userId) => {
|
|
129
|
+
board.invalidate()
|
|
130
|
+
board.award(userId).catch((e) => console.error(`[karma] badges: ${(e as Error).message}`))
|
|
131
|
+
}
|
|
132
|
+
service.karma.onCredit = (userId, granted) => {
|
|
133
|
+
const u = store.user(userId)
|
|
134
|
+
if (!u || !deps.mailer) return
|
|
135
|
+
const text = `Your karma on built.indie crossed another ${service.karma.perCredit}: that is ${granted === 1 ? 'a free Featured week' : `${granted} free Featured weeks`} (${config.featureDays} days pinned on the homepage).\n\nUse it: ${origin}/invite\nYour karma: ${origin}/me#karma`
|
|
136
|
+
deps.mailer({ to: u.email, subject: 'Your karma earned a free Featured week on built.indie', text, html: `<p>${esc(text).replace(/\n/g, '<br>')}</p>` }).catch((e) => console.error(`[karma] mail failed: ${(e as Error).message}`))
|
|
137
|
+
}
|
|
138
|
+
const inviteLink = (user: User) => `${origin}/i/${store.inviteCode(user.id)}`
|
|
139
|
+
service.onInviteEarned = (inviterId, p) => {
|
|
140
|
+
board.invalidate()
|
|
141
|
+
// A projection is never record()ed, so badges are checked here, when the fact they rest on changes.
|
|
142
|
+
board.award(inviterId).catch((e) => console.error(`[invite] badges: ${(e as Error).message}`))
|
|
143
|
+
const inviter = store.user(inviterId)
|
|
144
|
+
if (!inviter || !deps.mailer) return
|
|
145
|
+
const credits = store.inviteStats(inviterId).credits
|
|
146
|
+
const text = `${p.name} just launched on built.indie through your invite: ${origin}/p/${p.slug}\n\nThat earned you a free Featured week. You have ${credits} to use: ${origin}/invite\n\nKeep going: every maker you invite who launches earns another.`
|
|
147
|
+
const html = `<p><b>${esc(p.name)}</b> just launched on built.indie through your invite: <a href="${origin}/p/${esc(p.slug)}">${origin}/p/${esc(p.slug)}</a></p><p>That earned you a free Featured week. You have <b>${credits}</b> to use.</p><p><a href="${origin}/invite" style="display:inline-block;padding:10px 18px;background:#ff5c39;color:#fff;border-radius:8px;text-decoration:none;font-weight:700">Use it</a></p>`
|
|
148
|
+
deps.mailer({ to: inviter.email, subject: `You earned a free Featured week on built.indie`, text, html }).catch((e) => console.error(`[invite] mail failed: ${(e as Error).message}`))
|
|
149
|
+
}
|
|
103
150
|
|
|
104
151
|
app.use('*', async (c, next) => {
|
|
105
152
|
await next()
|
|
@@ -130,8 +177,8 @@ export function createApp(deps: Deps) {
|
|
|
130
177
|
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
178
|
|
|
132
179
|
// 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() })),
|
|
180
|
+
app.get('/', async (c) =>
|
|
181
|
+
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(), inviters: await inviters(5), karma: await topKarma(5), featureDays: config.featureDays, featureUsd: config.featureUsd })),
|
|
135
182
|
)
|
|
136
183
|
app.get('/top/:window', (c) => {
|
|
137
184
|
const w = c.req.param('window') as Window
|
|
@@ -139,10 +186,30 @@ export function createApp(deps: Deps) {
|
|
|
139
186
|
return page(c, boardPage(ctxOf(c), w, store.list(w, 50)))
|
|
140
187
|
})
|
|
141
188
|
app.get('/new', (c) => page(c, boardPage(ctxOf(c), 'new', store.list('new', 50))))
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
189
|
+
// Browse and search, with facets (browse.ts). /c/ and /t/ are one filter each, at an indexable address.
|
|
190
|
+
const browse = (c: Context, extra: Record<string, string> = {}) => {
|
|
191
|
+
const params = new URL(c.req.url).searchParams
|
|
192
|
+
for (const [k, v] of Object.entries(extra)) if (!params.getAll(k).includes(v)) params.append(k, v)
|
|
193
|
+
return search(store.allLive(), parseQuery(params))
|
|
194
|
+
}
|
|
195
|
+
app.get('/search', (c) => page(c, browsePage(ctxOf(c), browse(c))))
|
|
196
|
+
app.get('/c/:category', (c) => {
|
|
197
|
+
const key = c.req.param('category')
|
|
198
|
+
if (!(key in CATEGORY_LABELS)) return c.notFound()
|
|
199
|
+
const label = CATEGORY_LABELS[key]!
|
|
200
|
+
// Further filters go to /search, so /c/<category> stays one page.
|
|
201
|
+
if (new URL(c.req.url).search) return c.redirect(`/search?category=${encodeURIComponent(key)}&${new URL(c.req.url).searchParams}`)
|
|
202
|
+
return page(c, browsePage(ctxOf(c), browse(c, { category: key }), { path: `/c/${key}`, heading: label, intro: `${label}: indie products launched on built.indie` }))
|
|
145
203
|
})
|
|
204
|
+
app.get('/t/:tag', (c) => {
|
|
205
|
+
const tag = normalizeTag(c.req.param('tag'))
|
|
206
|
+
if (!tag) return c.notFound()
|
|
207
|
+
if (tag !== c.req.param('tag')) return c.redirect(`/t/${encodeURIComponent(tag)}`, 301)
|
|
208
|
+
if (new URL(c.req.url).search) return c.redirect(`/search?tag=${encodeURIComponent(tag)}&${new URL(c.req.url).searchParams}`)
|
|
209
|
+
const r = browse(c, { tag })
|
|
210
|
+
return page(c, browsePage(ctxOf(c), r, { path: `/t/${encodeURIComponent(tag)}`, heading: `#${tag}`, intro: `Indie products tagged ${tag}` }), r.total ? 200 : 404)
|
|
211
|
+
})
|
|
212
|
+
app.get('/tags', (c) => page(c, tagsPage(ctxOf(c), store.tagCounts())))
|
|
146
213
|
app.get('/help', (c) => page(c, helpPage(ctxOf(c))))
|
|
147
214
|
|
|
148
215
|
// Launching.
|
|
@@ -163,21 +230,45 @@ export function createApp(deps: Deps) {
|
|
|
163
230
|
})
|
|
164
231
|
|
|
165
232
|
// A product.
|
|
166
|
-
|
|
233
|
+
/** Comments and reviews as the page shows them: public names and karma, never emails. */
|
|
234
|
+
const discussion = (p: Product, me: User | null) => {
|
|
235
|
+
const comments = store.comments(p.id)
|
|
236
|
+
const reviews = store.reviews(p.id)
|
|
237
|
+
const karmaOf = store.karmaOf([...comments.map((x) => x.userId), ...reviews.map((x) => x.userId)])
|
|
238
|
+
const names = new Map<string, string>()
|
|
239
|
+
const who = (id: string) => names.get(id) ?? (names.set(id, store.publicName(id)), names.get(id)!)
|
|
240
|
+
const owner = p.ownerId ?? p.submitterUser
|
|
241
|
+
const cv = (x: Comment): CommentView => ({ id: x.id, parentId: x.parentId, body: x.body, deleted: Boolean(x.deletedAt), at: x.createdAt, author: x.deletedAt ? '' : who(x.userId), karma: karmaOf.get(x.userId) ?? 0, maker: x.userId === owner, mine: Boolean(me && x.userId === me.id) })
|
|
242
|
+
const rv = (x: Review): ReviewView => ({ rating: x.rating, body: x.body, at: x.updatedAt, author: who(x.userId), karma: karmaOf.get(x.userId) ?? 0, mine: Boolean(me && x.userId === me.id) })
|
|
243
|
+
return { comments: comments.map(cv), reviews: reviews.map(rv), myReview: me ? store.review(p.id, me.id) : null }
|
|
244
|
+
}
|
|
245
|
+
const productView = (c: Context<Env>, p: Product, extra: { error?: string } = {}, status = 200) =>
|
|
167
246
|
page(
|
|
168
247
|
c,
|
|
169
248
|
productPage(ctxOf(c), {
|
|
249
|
+
...discussion(p, sessionUser(c)),
|
|
250
|
+
error: extra.error,
|
|
251
|
+
selfListing: (() => {
|
|
252
|
+
const u = sessionUser(c)
|
|
253
|
+
return Boolean(u && (p.ownerId === u.id || p.submitterUser === u.id))
|
|
254
|
+
})(),
|
|
170
255
|
product: p,
|
|
171
256
|
award: store.awards(p.id),
|
|
172
257
|
maker: p.makerId ? store.maker(p.makerId) : null,
|
|
173
258
|
featuredOk: c.req.query('featured') === '1',
|
|
259
|
+
featuredCredit: c.req.query('featured') === 'credit',
|
|
174
260
|
launched: c.req.query('launched') === '1',
|
|
175
261
|
canEdit: store.canEdit(p, sessionUser(c)),
|
|
176
|
-
|
|
262
|
+
credits: (() => {
|
|
263
|
+
const u = sessionUser(c)
|
|
264
|
+
return u && store.canEdit(p, u) ? store.inviteStats(u.id).credits : 0
|
|
265
|
+
})(),
|
|
266
|
+
buyerEmail: sessionUser(c)?.email ?? '',
|
|
177
267
|
featureUsd: config.featureUsd,
|
|
178
268
|
featureDays: config.featureDays,
|
|
179
269
|
payments: Boolean(config.coinpay),
|
|
180
270
|
}),
|
|
271
|
+
status,
|
|
181
272
|
)
|
|
182
273
|
app.get('/p/:slug', (c) => {
|
|
183
274
|
const p = store.bySlug(c.req.param('slug'))
|
|
@@ -232,17 +323,19 @@ export function createApp(deps: Deps) {
|
|
|
232
323
|
}
|
|
233
324
|
})
|
|
234
325
|
|
|
235
|
-
// Claiming: the domain's owner proves it in DNS and takes the listing.
|
|
326
|
+
// Claiming: the domain's owner proves it with the badge carrying their badge id, or in DNS, and takes the listing.
|
|
236
327
|
const claimRecord = (p: Product, user: User) => ({ type: 'TXT', name: `${CLAIM_LABEL}.${p.domain.replace(/^www\./, '')}`, value: `${CLAIM_PREFIX}${service.claimToken(p, user)}` })
|
|
237
|
-
const
|
|
238
|
-
|
|
328
|
+
const claimBadge = (p: Product, user: User) => claimBadgeSnippet(origin, p, store.badgeId(p.id, user.id))
|
|
329
|
+
const claimMd = (p: Product, user: User) => claimMarkdown(origin, p, claimRecord(p, user), claimBadge(p, user))
|
|
330
|
+
const claimView = (c: Context<Env>, p: Product, user: User, extra: { result?: { ok: boolean; looked: string[]; method?: string } } = {}, status = 200) =>
|
|
331
|
+
page(c, claimPage(ctxOf(c), { product: p, record: claimRecord(p, user), badge: claimBadge(p, user), owner: p.ownerId === user.id, ...extra }), status)
|
|
239
332
|
// The same instructions as Markdown, to paste to an AI agent with DNS access.
|
|
240
333
|
app.get('/p/:slug/claim.md', (c) => {
|
|
241
334
|
const user = sessionUser(c)
|
|
242
335
|
if (!user) return c.redirect(`/login?next=${encodeURIComponent(c.req.path)}`)
|
|
243
336
|
const p = store.bySlug(c.req.param('slug'))
|
|
244
337
|
if (!p || p.status === 'hidden') return c.text('not found', 404)
|
|
245
|
-
return c.body(
|
|
338
|
+
return c.body(claimMd(p, user), 200, { 'content-type': 'text/markdown; charset=utf-8', 'cache-control': 'private, no-store' })
|
|
246
339
|
})
|
|
247
340
|
app.get('/p/:slug/claim', (c) => {
|
|
248
341
|
const user = sessionUser(c)
|
|
@@ -262,18 +355,40 @@ export function createApp(deps: Deps) {
|
|
|
262
355
|
})
|
|
263
356
|
|
|
264
357
|
// Signing in: an emailed link, which is also sign-up.
|
|
265
|
-
|
|
358
|
+
const invitedBy = (c: Context) => {
|
|
359
|
+
const inviter = store.inviterOf(getCookie(c, REF_COOKIE) ?? '')
|
|
360
|
+
return inviter && !sessionUser(c) ? store.publicName(inviter) : null
|
|
361
|
+
}
|
|
362
|
+
app.get('/login', (c) => page(c, loginPage(ctxOf(c), { next: safeNext(c.req.query('next')), invitedBy: invitedBy(c), error: COINPAY_ERRORS[c.req.query('error') ?? ''] })))
|
|
363
|
+
// Sign in with CoinPay (coinpay-signin.ts): /api/v1/coinpay/login and /callback.
|
|
364
|
+
mountCoinPaySignin(app, {
|
|
365
|
+
store,
|
|
366
|
+
auth: deps.auth,
|
|
367
|
+
origin,
|
|
368
|
+
oauth: deps.coinpayOAuth ?? null,
|
|
369
|
+
secure,
|
|
370
|
+
sessionUser,
|
|
371
|
+
// A new member who came through an invite link counts, as with a magic link.
|
|
372
|
+
onNewMember: (c, user) => {
|
|
373
|
+
const ref = getCookie(c, REF_COOKIE)
|
|
374
|
+
if (ref) store.recordInvite(user.id, ref), deleteCookie(c, REF_COOKIE, { path: '/' })
|
|
375
|
+
},
|
|
376
|
+
})
|
|
266
377
|
app.post('/login', async (c) => {
|
|
267
378
|
const form = (await c.req.parseBody()) as Record<string, string>
|
|
268
379
|
const next = safeNext(form.next)
|
|
269
380
|
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))
|
|
381
|
+
if (!crossSite(c) && readLimit.allow(`mail:${ip(c)}`)) await deps.auth.sendLink(form.email ?? '', next, here(c), getCookie(c, REF_COOKIE) ?? null)
|
|
271
382
|
return page(c, loginPage(ctxOf(c), { next, sent: true }))
|
|
272
383
|
})
|
|
273
384
|
app.get('/auth/verify', (c) => {
|
|
274
385
|
const r = deps.auth.consumeLink(c.req.query('token') ?? '')
|
|
275
386
|
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:
|
|
387
|
+
setCookie(c, SESSION_COOKIE, deps.auth.createSession(r.user.id), { httpOnly: true, secure: secure(c), sameSite: 'Lax', path: '/', maxAge: 90 * 86400 })
|
|
388
|
+
// A new member who came through an invite link: their first live listing will earn the inviter a credit.
|
|
389
|
+
const ref = r.ref ?? getCookie(c, REF_COOKIE)
|
|
390
|
+
if (r.created && ref) store.recordInvite(r.user.id, ref)
|
|
391
|
+
if (ref) deleteCookie(c, REF_COOKIE, { path: '/' })
|
|
277
392
|
return c.redirect(r.next ?? '/me')
|
|
278
393
|
})
|
|
279
394
|
app.post('/logout', (c) => {
|
|
@@ -281,22 +396,77 @@ export function createApp(deps: Deps) {
|
|
|
281
396
|
deleteCookie(c, SESSION_COOKIE, { path: '/' })
|
|
282
397
|
return c.redirect('/')
|
|
283
398
|
})
|
|
399
|
+
const signinInfo = (c: Context, user: User) => ({
|
|
400
|
+
coinpay: store.identities(user.id).find((i) => i.provider === COINPAY) ?? null,
|
|
401
|
+
emailless: isPlaceholder(user.email),
|
|
402
|
+
coinpayNote: ({ connected: 'CoinPay is connected: sign in with it next time.', taken: 'That CoinPay account is already linked to another built.indie member.', welcome: 'Welcome! You signed in with CoinPay.' } as Record<string, string>)[c.req.query('coinpay') ?? ''] ?? null,
|
|
403
|
+
})
|
|
284
404
|
app.get('/me', (c) => {
|
|
285
405
|
const user = sessionUser(c)
|
|
286
|
-
return user ? page(c, mePage(ctxOf(c), { user, products: store.mine(user.id) })) : toLogin(c)
|
|
406
|
+
return user ? page(c, mePage(ctxOf(c), { user, products: store.mine(user.id), invite: store.inviteStats(user.id), karma: service.karma.summary(user.id), howToEarn: HOW_TO_EARN, featureDays: config.featureDays, ...signinInfo(c, user) })) : toLogin(c)
|
|
287
407
|
})
|
|
288
408
|
app.post('/me/key', (c) => {
|
|
289
409
|
const user = sessionUser(c)
|
|
290
410
|
if (!user) return toLogin(c)
|
|
291
411
|
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) }))
|
|
412
|
+
return page(c, mePage(ctxOf(c), { user, products: store.mine(user.id), apiKey: store.newApiKey(user.id), invite: store.inviteStats(user.id), karma: service.karma.summary(user.id), howToEarn: HOW_TO_EARN, featureDays: config.featureDays, ...signinInfo(c, user) }))
|
|
413
|
+
})
|
|
414
|
+
|
|
415
|
+
// Invites: a link per member; a new member it brings in who launches earns the inviter a free Featured week.
|
|
416
|
+
app.get('/i/:code', (c) => {
|
|
417
|
+
const code = c.req.param('code')
|
|
418
|
+
const inviter = store.inviterOf(code)
|
|
419
|
+
if (!inviter) return c.redirect('/invite')
|
|
420
|
+
if (!sessionUser(c)) setCookie(c, REF_COOKIE, code, { httpOnly: true, secure: secure(c), sameSite: 'Lax', path: '/', maxAge: 30 * 86400 })
|
|
421
|
+
return page(c, inviteLanding(ctxOf(c), { inviter: store.publicName(inviter) }))
|
|
422
|
+
})
|
|
423
|
+
const inviteView = async (c: Context<Env>, extra: { flash?: string; error?: string } = {}, status = 200) => {
|
|
424
|
+
const user = sessionUser(c)
|
|
425
|
+
const me = user
|
|
426
|
+
? { link: inviteLink(user), stats: store.inviteStats(user.id), name: store.publicName(user.id), displayName: user.displayName, listings: store.mine(user.id).filter((p) => p.status === 'live'), ...extra }
|
|
427
|
+
: undefined
|
|
428
|
+
return page(c, invitePage(ctxOf(c), { featureDays: config.featureDays, featureUsd: config.featureUsd, inviters: await inviters(10), me }), status)
|
|
429
|
+
}
|
|
430
|
+
app.get('/invite', (c) => inviteView(c))
|
|
431
|
+
app.post('/invite/name', async (c) => {
|
|
432
|
+
const user = sessionUser(c)
|
|
433
|
+
if (!user) return toLogin(c)
|
|
434
|
+
if (crossSite(c)) return c.redirect('/invite')
|
|
435
|
+
const name = String(((await c.req.parseBody()) as Record<string, string>).displayName ?? '').replace(/\s+/g, ' ').trim().slice(0, 40)
|
|
436
|
+
store.setDisplayName(user.id, name || null)
|
|
437
|
+
board.invalidate()
|
|
438
|
+
return inviteView(c, { flash: name ? `Saved. The board shows you as ${name}.` : 'Cleared. The board shows your best listing instead.' })
|
|
293
439
|
})
|
|
440
|
+
app.post('/p/:slug/credit', (c) => {
|
|
441
|
+
const user = sessionUser(c)
|
|
442
|
+
if (!user) return toLogin(c)
|
|
443
|
+
const p = store.bySlug(c.req.param('slug'))
|
|
444
|
+
if (!p || p.status === 'hidden') return c.notFound()
|
|
445
|
+
if (crossSite(c) || !store.canEdit(p, user)) return c.redirect(`/p/${p.slug}`)
|
|
446
|
+
try {
|
|
447
|
+
service.redeemCredit(p, user)
|
|
448
|
+
return c.redirect(`/p/${p.slug}?featured=credit`)
|
|
449
|
+
} catch (e) {
|
|
450
|
+
return inviteView(c, { error: e instanceof Error ? e.message : String(e) }, 400)
|
|
451
|
+
}
|
|
452
|
+
})
|
|
453
|
+
|
|
454
|
+
// The leaderboard: our page, then the package answers everything under /leaderboard (JSON, RSS, share cards, widget).
|
|
455
|
+
app.get('/leaderboard', async (c) => {
|
|
456
|
+
const user = sessionUser(c)
|
|
457
|
+
const which = c.req.query('board') === 'invites' ? 'invites' : 'karma'
|
|
458
|
+
// An empty week shows all time instead, so the board never opens blank.
|
|
459
|
+
const period = (await rowsOf(which, 'week', 1)).length ? 'week' : 'all'
|
|
460
|
+
return page(c, leaderboardPage(ctxOf(c), { board: which, period, featureDays: config.featureDays, featureUsd: config.featureUsd, perCredit: service.karma.perCredit, howToEarn: HOW_TO_EARN, me: user?.id ?? null }))
|
|
461
|
+
})
|
|
462
|
+
app.use('/leaderboard.json', leaderboard(board))
|
|
463
|
+
app.use('/leaderboard/*', leaderboard(board))
|
|
294
464
|
|
|
295
465
|
app.post('/p/:slug/vote', (c) => {
|
|
296
466
|
const p = live(c.req.param('slug'))
|
|
297
467
|
if (!p) return wantsJson(c) ? c.json({ error: 'no such product' }, 404) : c.notFound()
|
|
298
468
|
if (crossSite(c)) return c.json({ error: 'vote from built.indie itself' }, 403)
|
|
299
|
-
const counted = voteLimit.allow(`vote:${ip(c)}`) &&
|
|
469
|
+
const counted = voteLimit.allow(`vote:${ip(c)}`) && service.vote(p, voter(c), sessionUser(c))
|
|
300
470
|
const now = store.product(p.id)!
|
|
301
471
|
if (wantsJson(c)) return c.json({ slug: now.slug, votes: now.votes, counted, voted: true })
|
|
302
472
|
const back = c.req.header('referer')
|
|
@@ -307,6 +477,66 @@ export function createApp(deps: Deps) {
|
|
|
307
477
|
return c.redirect(to)
|
|
308
478
|
})
|
|
309
479
|
|
|
480
|
+
// Comments, reviews and tracked shares: signed-in members, from this site only.
|
|
481
|
+
const back = (p: Product, hash: string) => `/p/${p.slug}#${hash}`
|
|
482
|
+
app.post('/p/:slug/comments', async (c) => {
|
|
483
|
+
const user = sessionUser(c)
|
|
484
|
+
if (!user) return toLogin(c)
|
|
485
|
+
const p = live(c.req.param('slug'))
|
|
486
|
+
if (!p) return c.notFound()
|
|
487
|
+
if (crossSite(c)) return c.redirect(back(p, 'comments'))
|
|
488
|
+
if (!commentLimit.allow(`comment:${user.id}`)) return productView(c, p, { error: 'That is a lot of comments. Wait a few minutes.' }, 429)
|
|
489
|
+
const form = (await c.req.parseBody()) as Record<string, string>
|
|
490
|
+
try {
|
|
491
|
+
const made = service.comment(p, user, form.body ?? '', form.parent || null)
|
|
492
|
+
return c.redirect(back(p, `c-${made.id}`))
|
|
493
|
+
} catch (e) {
|
|
494
|
+
if (e instanceof InputError) return productView(c, p, { error: e.message }, 400)
|
|
495
|
+
throw e
|
|
496
|
+
}
|
|
497
|
+
})
|
|
498
|
+
app.post('/p/:slug/comments/:id/delete', (c) => {
|
|
499
|
+
const user = sessionUser(c)
|
|
500
|
+
if (!user) return toLogin(c)
|
|
501
|
+
const p = store.bySlug(c.req.param('slug'))
|
|
502
|
+
if (!p) return c.notFound()
|
|
503
|
+
if (crossSite(c)) return c.redirect(back(p, 'comments'))
|
|
504
|
+
const cm = store.comment(c.req.param('id'))
|
|
505
|
+
if (!cm || cm.productId !== p.id) return c.redirect(back(p, 'comments'))
|
|
506
|
+
try {
|
|
507
|
+
service.deleteComment(cm.id, user)
|
|
508
|
+
} catch (e) {
|
|
509
|
+
if (!(e instanceof InputError)) throw e
|
|
510
|
+
}
|
|
511
|
+
return c.redirect(back(p, 'comments'))
|
|
512
|
+
})
|
|
513
|
+
app.post('/p/:slug/review', async (c) => {
|
|
514
|
+
const user = sessionUser(c)
|
|
515
|
+
if (!user) return toLogin(c)
|
|
516
|
+
const p = live(c.req.param('slug'))
|
|
517
|
+
if (!p) return c.notFound()
|
|
518
|
+
if (crossSite(c)) return c.redirect(back(p, 'reviews'))
|
|
519
|
+
const form = (await c.req.parseBody()) as Record<string, string>
|
|
520
|
+
try {
|
|
521
|
+
if (form.action === 'delete') service.deleteReview(p, user.id)
|
|
522
|
+
else service.review(p, user, form.rating, form.body)
|
|
523
|
+
return c.redirect(back(p, 'reviews'))
|
|
524
|
+
} catch (e) {
|
|
525
|
+
if (e instanceof InputError) return productView(c, p, { error: e.message }, 400)
|
|
526
|
+
throw e
|
|
527
|
+
}
|
|
528
|
+
})
|
|
529
|
+
// A share button: app.js sends a beacon here before the share window opens.
|
|
530
|
+
app.post('/p/:slug/share', (c) => {
|
|
531
|
+
const p = live(c.req.param('slug'))
|
|
532
|
+
if (!p) return c.json({ error: 'no such product' }, 404)
|
|
533
|
+
if (crossSite(c)) return c.json({ error: 'share from built.indie itself' }, 403)
|
|
534
|
+
const net = c.req.query('net') ?? ''
|
|
535
|
+
if (!(SHARE_NETWORKS as readonly string[]).includes(net)) return c.json({ error: `net is one of ${SHARE_NETWORKS.join(', ')}` }, 400)
|
|
536
|
+
if (!shareLimit.allow(`share:${ip(c)}`)) return c.json({ counted: false })
|
|
537
|
+
return c.json(service.share(p, sessionUser(c), net))
|
|
538
|
+
})
|
|
539
|
+
|
|
310
540
|
app.post('/p/:slug/verify', async (c) => {
|
|
311
541
|
const p = live(c.req.param('slug'))
|
|
312
542
|
if (!p) return c.notFound()
|
|
@@ -320,8 +550,9 @@ export function createApp(deps: Deps) {
|
|
|
320
550
|
if (!p) return c.notFound()
|
|
321
551
|
const form = (await c.req.parseBody()) as Record<string, string>
|
|
322
552
|
try {
|
|
323
|
-
const
|
|
324
|
-
|
|
553
|
+
const user = sessionUser(c)
|
|
554
|
+
const r = await service.feature(p, { email: form.email || user?.email || '', accountId: user?.id ?? null })
|
|
555
|
+
return c.redirect(r.checkoutUrl, 303)
|
|
325
556
|
} catch (e) {
|
|
326
557
|
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
558
|
}
|
|
@@ -347,8 +578,8 @@ export function createApp(deps: Deps) {
|
|
|
347
578
|
})
|
|
348
579
|
|
|
349
580
|
// The badge: the viral part. Cached briefly so the count stays live.
|
|
350
|
-
|
|
351
|
-
|
|
581
|
+
const badgeImage = (c: Context<Env>, p: Product | null, unlisted = false) => {
|
|
582
|
+
if (!p && unlisted) return c.body(launchBadgeSvg(c.req.query('theme') === 'light' ? 'light' : 'dark'), 200, { 'content-type': 'image/svg+xml; charset=utf-8', 'cache-control': 'public, max-age=3600', 'access-control-allow-origin': '*', 'cross-origin-resource-policy': 'cross-origin', 'content-security-policy': "default-src 'none'; style-src 'unsafe-inline'" })
|
|
352
583
|
if (!p) return c.body('<svg xmlns="http://www.w3.org/2000/svg" width="1" height="1"/>', 404, { 'content-type': 'image/svg+xml' })
|
|
353
584
|
return c.body(badgeSvg(p, store.awards(p.id), c.req.query('theme') === 'light' ? 'light' : 'dark'), 200, {
|
|
354
585
|
'content-type': 'image/svg+xml; charset=utf-8',
|
|
@@ -357,6 +588,20 @@ export function createApp(deps: Deps) {
|
|
|
357
588
|
'cross-origin-resource-policy': 'cross-origin',
|
|
358
589
|
'content-security-policy': "default-src 'none'; style-src 'unsafe-inline'",
|
|
359
590
|
})
|
|
591
|
+
}
|
|
592
|
+
app.get('/badge/:file{[a-z0-9-]+\\.svg}', (c) => badgeImage(c, live(c.req.param('file').replace(/\.svg$/, ''))))
|
|
593
|
+
// The same badge by badge id: the one the claim page hands out. On a homepage it claims the listing for that account.
|
|
594
|
+
// By site, for snippets shared across many sites (the Profullstack footer): ?site=<homepage>.
|
|
595
|
+
// A site with no listing (built.indie itself, a brand-new site) gets an invitation to launch instead.
|
|
596
|
+
app.get('/for.svg', (c) => badgeImage(c, store.bySite(c.req.query('site') ?? ''), true))
|
|
597
|
+
app.get('/for', (c) => {
|
|
598
|
+
const p = store.bySite(c.req.query('site') ?? '')
|
|
599
|
+
return c.redirect(p ? `/p/${p.slug}?ref=badge` : '/submit?ref=badge')
|
|
600
|
+
})
|
|
601
|
+
app.get('/images.svg', (c) => {
|
|
602
|
+
const b = store.badge(c.req.query('id') ?? '')
|
|
603
|
+
const p = b ? store.product(b.productId) : null
|
|
604
|
+
return badgeImage(c, p?.status === 'live' ? p : null)
|
|
360
605
|
})
|
|
361
606
|
|
|
362
607
|
app.get('/makers/:id', (c) => {
|
|
@@ -377,12 +622,13 @@ export function createApp(deps: Deps) {
|
|
|
377
622
|
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
623
|
})
|
|
379
624
|
app.get('/sitemap.xml', (c) => {
|
|
380
|
-
const
|
|
625
|
+
const all = search(store.allLive(), parseQuery({}), { limit: 0 })
|
|
626
|
+
const urls = ['/', '/search', '/tags', '/top/today', '/top/week', '/top/month', '/top/all', '/new', '/submit', '/help', '/invite', '/leaderboard', ...all.facets.category.map((f) => `/c/${f.value}`), ...store.tagCounts().map((t) => `/t/${encodeURIComponent(t.tag)}`), ...store.list('all', 5000).map((p) => `/p/${p.slug}`)]
|
|
381
627
|
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
628
|
})
|
|
383
629
|
app.get('/robots.txt', (c) => c.text(`User-agent: *\nAllow: /\nDisallow: /p/*/edit\nSitemap: ${origin}/sitemap.xml\n`))
|
|
384
630
|
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`),
|
|
631
|
+
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- [Karma](${origin}/help#karma): members earn karma for launching, voting, reviewing, commenting and sharing; every ${service.karma.perCredit} karma is a free Featured week. GET /api/v1/karma (your key), GET /api/v1/leaderboard?board=karma&period=week\n- [Leaderboard](${origin}/leaderboard): top karma (default) and top inviters; JSON at /leaderboard/karma.json?period=week\n- [Comments and reviews](${origin}/help#api): GET/POST /api/v1/products/<slug>/comments, GET/POST /api/v1/products/<slug>/reviews, POST /api/v1/products/<slug>/share\n`),
|
|
386
632
|
)
|
|
387
633
|
app.get('/.well-known/openprofile.md', (c) =>
|
|
388
634
|
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': '*' }),
|
|
@@ -409,6 +655,8 @@ export function createApp(deps: Deps) {
|
|
|
409
655
|
domain: p.domain,
|
|
410
656
|
status: p.status,
|
|
411
657
|
votes: p.votes,
|
|
658
|
+
reviews: p.reviews,
|
|
659
|
+
rating: p.rating,
|
|
412
660
|
tags: p.tags,
|
|
413
661
|
logo: p.logo ? `${origin}/m/${p.id}/${p.logo}` : null,
|
|
414
662
|
card_image: p.ogImage ? `${origin}/m/${p.id}/${p.ogImage}` : null,
|
|
@@ -452,11 +700,12 @@ export function createApp(deps: Deps) {
|
|
|
452
700
|
})
|
|
453
701
|
|
|
454
702
|
app.post('/api/v1/products', async (c) => {
|
|
455
|
-
|
|
703
|
+
const admin = isAdmin(c)
|
|
704
|
+
if (!admin && !readLimit.allow(`read:${ip(c)}`)) return c.json({ error: 'too many launches from you; wait a few minutes' }, 429)
|
|
456
705
|
const b = await body(c)
|
|
457
706
|
try {
|
|
458
707
|
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)
|
|
708
|
+
const r = await service.submit({ url: String(b.url ?? ''), makerName: b.maker_name, makerProfile: b.maker_profile }, store.voterFor(ip(c)), user, admin)
|
|
460
709
|
if (r.existing) return c.json({ existing: true, product: apiProduct(r.product) }, 200)
|
|
461
710
|
let p = r.product
|
|
462
711
|
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 })
|
|
@@ -470,11 +719,41 @@ export function createApp(deps: Deps) {
|
|
|
470
719
|
app.get('/api/v1/products', (c) => {
|
|
471
720
|
const w = (c.req.query('window') ?? 'today') as Window
|
|
472
721
|
const q = c.req.query('q')
|
|
473
|
-
|
|
722
|
+
const tag = c.req.query('tag') !== undefined ? normalizeTag(c.req.query('tag')!) : undefined
|
|
723
|
+
if (tag === null) return c.json({ error: 'tag is 2 to 24 letters, digits, spaces or + # . -' }, 400)
|
|
724
|
+
if (!q && !tag && !WINDOWS.includes(w)) return c.json({ error: `window is one of ${WINDOWS.join(', ')}` }, 400)
|
|
474
725
|
const limit = Math.min(100, Math.max(1, Number(c.req.query('limit') ?? 30) || 30))
|
|
475
726
|
const offset = Math.max(0, Number(c.req.query('offset') ?? 0) || 0)
|
|
476
|
-
|
|
727
|
+
const products = tag ? store.byTag(tag, limit) : q ? search(store.allLive(), parseQuery({ q }), { limit }).products : store.list(w, limit, offset)
|
|
728
|
+
return c.json({ window: tag ? 'tag' : q ? 'search' : w, ...(tag ? { tag } : {}), products: products.map(apiProduct) }, 200, { 'access-control-allow-origin': '*' })
|
|
729
|
+
})
|
|
730
|
+
|
|
731
|
+
// Search with facets: the browse page as data. Same parameters as /search.
|
|
732
|
+
app.get('/api/v1/search', (c) => {
|
|
733
|
+
const params = new URL(c.req.url).searchParams
|
|
734
|
+
const limit = Math.min(100, Math.max(1, Number(params.get('limit') ?? 30) || 30))
|
|
735
|
+
const r = search(store.allLive(), parseQuery(params), { limit })
|
|
736
|
+
const qs = queryString(r.query)
|
|
737
|
+
return c.json(
|
|
738
|
+
{
|
|
739
|
+
query: r.query,
|
|
740
|
+
total: r.total,
|
|
741
|
+
page: r.query.page,
|
|
742
|
+
pages: Math.ceil(r.total / limit),
|
|
743
|
+
products: r.products.map((p) => ({ ...apiProduct(p), categories: r.categories.get(p.id) ?? [] })),
|
|
744
|
+
...(params.get('facets') === '0' ? {} : { facets: r.facets }),
|
|
745
|
+
web: `${origin}/search${qs ? `?${qs}` : ''}`,
|
|
746
|
+
},
|
|
747
|
+
200,
|
|
748
|
+
{ 'access-control-allow-origin': '*', 'cache-control': 'public, max-age=30' },
|
|
749
|
+
)
|
|
477
750
|
})
|
|
751
|
+
app.get('/api/v1/categories', (c) => {
|
|
752
|
+
const r = search(store.allLive(), parseQuery({}), { limit: 0 })
|
|
753
|
+
return c.json({ categories: r.facets.category.map((f) => ({ key: f.value, label: f.label, count: f.count, page: `${origin}/c/${f.value}` })), tags: r.facets.tag.map((f) => ({ tag: f.value, count: f.count, page: `${origin}/t/${encodeURIComponent(f.value)}` })) }, 200, { 'access-control-allow-origin': '*' })
|
|
754
|
+
})
|
|
755
|
+
|
|
756
|
+
app.get('/api/v1/tags', (c) => c.json({ tags: store.tagCounts() }, 200, { 'access-control-allow-origin': '*' }))
|
|
478
757
|
|
|
479
758
|
app.get('/api/v1/products/:slug', (c) => {
|
|
480
759
|
const p = store.bySlug(c.req.param('slug'))
|
|
@@ -503,8 +782,10 @@ export function createApp(deps: Deps) {
|
|
|
503
782
|
if (!readLimit.allow(`read:${ip(c)}`)) return c.json({ error: 'too many reads from you; wait a few minutes' }, 429)
|
|
504
783
|
if (action === 'refetch') return c.json(apiProduct(await service.refetch(p)))
|
|
505
784
|
if (action === 'verify') return c.json({ verified: await service.verify(p) })
|
|
506
|
-
const
|
|
507
|
-
|
|
785
|
+
const b = await body(c)
|
|
786
|
+
const user = apiUser(c)
|
|
787
|
+
const r = await service.feature(p, { email: String(b.email ?? user?.email ?? ''), accountId: user?.id ?? null })
|
|
788
|
+
return c.json({ checkout_url: r.checkoutUrl, checkout_session_id: r.sessionId, methods: r.methods, payment: r.ref, usd: config.featureUsd, days: config.featureDays })
|
|
508
789
|
} catch (e) {
|
|
509
790
|
return apiError(c, e)
|
|
510
791
|
}
|
|
@@ -514,7 +795,7 @@ export function createApp(deps: Deps) {
|
|
|
514
795
|
const p = live(c.req.param('slug'))
|
|
515
796
|
if (!p) return c.json({ error: 'no such product' }, 404)
|
|
516
797
|
if (crossSite(c)) return c.json({ error: 'vote from built.indie itself' }, 403)
|
|
517
|
-
const counted = voteLimit.allow(`vote:${ip(c)}`) &&
|
|
798
|
+
const counted = voteLimit.allow(`vote:${ip(c)}`) && service.vote(p, voter(c), apiUser(c))
|
|
518
799
|
return c.json({ slug: p.slug, votes: store.product(p.id)!.votes, counted })
|
|
519
800
|
})
|
|
520
801
|
|
|
@@ -524,6 +805,9 @@ export function createApp(deps: Deps) {
|
|
|
524
805
|
const p = store.bySlug(c.req.param('slug'))
|
|
525
806
|
if (!p) return c.json({ error: 'no such product' }, 404)
|
|
526
807
|
store.update(p.id, { status: 'hidden' })
|
|
808
|
+
store.revokeInvite(p.id)
|
|
809
|
+
service.karma.takenDown(p.id)
|
|
810
|
+
board.invalidate()
|
|
527
811
|
return c.json({ hidden: true })
|
|
528
812
|
}
|
|
529
813
|
const p = owned(c)
|
|
@@ -539,7 +823,7 @@ export function createApp(deps: Deps) {
|
|
|
539
823
|
if (!user) return c.json(NEEDS_ACCOUNT, 401)
|
|
540
824
|
const p = store.bySlug(c.req.param('slug'))
|
|
541
825
|
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),
|
|
826
|
+
return c.json({ owner: p.ownerId === user.id, record: claimRecord(p, user), badge: claimBadge(p, user), markdown: claimMd(p, user) })
|
|
543
827
|
})
|
|
544
828
|
app.post('/api/v1/products/:slug/claim', async (c) => {
|
|
545
829
|
const user = apiUser(c)
|
|
@@ -548,12 +832,118 @@ export function createApp(deps: Deps) {
|
|
|
548
832
|
if (!p || p.status === 'hidden') return c.json({ error: 'no such product' }, 404)
|
|
549
833
|
if (!readLimit.allow(`read:${ip(c)}`)) return c.json({ error: 'too many checks; wait a few minutes' }, 429)
|
|
550
834
|
const r = await service.claim(p, user)
|
|
551
|
-
return c.json({ claimed: r.ok, looked: r.looked, record: claimRecord(p, user), ...(r.ok ? {} : { markdown:
|
|
835
|
+
return c.json({ claimed: r.ok, ...(r.method ? { method: r.method } : {}), looked: r.looked, record: claimRecord(p, user), badge: claimBadge(p, user), ...(r.ok ? {} : { markdown: claimMd(p, user) }) })
|
|
552
836
|
})
|
|
553
837
|
app.get('/api/v1/me', (c) => {
|
|
554
838
|
const user = apiUser(c)
|
|
555
839
|
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 })) })
|
|
840
|
+
return c.json({ email: isPlaceholder(user.email) ? null : user.email, coinpay: Boolean(store.identities(user.id).some((i) => i.provider === COINPAY)), products: store.mine(user.id).map((p) => ({ ...apiProduct(p), owner: p.ownerId === user.id })), invite: apiInvite(user), karma: apiKarma(user) })
|
|
841
|
+
})
|
|
842
|
+
const apiInvite = (user: User) => {
|
|
843
|
+
const s = store.inviteStats(user.id)
|
|
844
|
+
return { link: inviteLink(user), code: s.code, invited: s.invited, launched: s.launched, credits: s.credits, karma_credits: s.karmaCredits, used: s.used, feature_days: config.featureDays }
|
|
845
|
+
}
|
|
846
|
+
app.get('/api/v1/invites', (c) => {
|
|
847
|
+
const user = apiUser(c)
|
|
848
|
+
return user ? c.json(apiInvite(user)) : c.json(NEEDS_ACCOUNT, 401)
|
|
849
|
+
})
|
|
850
|
+
app.post('/api/v1/products/:slug/credit', (c) => {
|
|
851
|
+
const user = apiUser(c)
|
|
852
|
+
const p = owned(c)
|
|
853
|
+
if (!user || !p) return c.json(NEEDS_KEY, 401)
|
|
854
|
+
try {
|
|
855
|
+
const after = service.redeemCredit(p, user)
|
|
856
|
+
return c.json({ featured_until: new Date(after.featuredUntil!).toISOString(), credits: store.inviteStats(user.id).credits })
|
|
857
|
+
} catch (e) {
|
|
858
|
+
return apiError(c, e)
|
|
859
|
+
}
|
|
860
|
+
})
|
|
861
|
+
|
|
862
|
+
// Karma, comments, reviews, shares.
|
|
863
|
+
const apiKarma = (user: User) => {
|
|
864
|
+
const k = service.karma.summary(user.id)
|
|
865
|
+
return { total: k.total, week: k.week, per_credit: k.perCredit, next_credit_in: k.nextCreditIn, credits: { earned: k.credits.granted, used: k.credits.used }, events: k.events.map((e) => ({ action: e.action, label: e.label, ref: e.ref, points: e.points, at: new Date(e.at).toISOString() })), how_to_earn: HOW_TO_EARN, leaderboard: `${origin}/leaderboard` }
|
|
866
|
+
}
|
|
867
|
+
app.get('/api/v1/karma', (c) => {
|
|
868
|
+
const user = apiUser(c)
|
|
869
|
+
return user ? c.json(apiKarma(user)) : c.json({ ...NEEDS_ACCOUNT, how_to_earn: HOW_TO_EARN, leaderboard: `${origin}/api/v1/leaderboard?board=karma&period=week` }, 401)
|
|
870
|
+
})
|
|
871
|
+
app.get('/api/v1/leaderboard', async (c) => {
|
|
872
|
+
const which = c.req.query('board') === 'invites' ? 'invites' : 'karma'
|
|
873
|
+
const period = (['week', 'month', 'all'] as const).find((x) => x === c.req.query('period')) ?? 'week'
|
|
874
|
+
const limit = Math.min(100, Math.max(1, Number(c.req.query('limit') ?? 25) || 25))
|
|
875
|
+
const top = await board.top({ board: which, period, limit })
|
|
876
|
+
return c.json({ board: which, period, total: top.total, rows: top.rows.map((r) => ({ rank: r.rank, id: r.id, name: r.name, value: r.value, badges: r.badges, url: `${origin}/leaderboard/u/${encodeURIComponent(r.id)}` })), web: `${origin}/leaderboard${which === 'karma' ? '' : '?board=invites'}` }, 200, { 'access-control-allow-origin': '*' })
|
|
877
|
+
})
|
|
878
|
+
const apiComment = (p: Product, x: CommentView) => ({ id: x.id, parent: x.parentId, body: x.deleted ? null : x.body, deleted: x.deleted, author: x.author || null, karma: x.karma, maker: x.maker, at: new Date(x.at).toISOString(), url: `${origin}/p/${p.slug}#c-${x.id}` })
|
|
879
|
+
app.get('/api/v1/products/:slug/comments', (c) => {
|
|
880
|
+
const p = live(c.req.param('slug'))
|
|
881
|
+
if (!p) return c.json({ error: 'no such product' }, 404)
|
|
882
|
+
return c.json({ slug: p.slug, comments: discussion(p, apiUser(c)).comments.map((x) => ({ ...apiComment(p, x), mine: x.mine })) }, 200, { 'access-control-allow-origin': '*' })
|
|
883
|
+
})
|
|
884
|
+
app.post('/api/v1/products/:slug/comments', async (c) => {
|
|
885
|
+
const user = apiUser(c)
|
|
886
|
+
if (!user) return c.json(NEEDS_ACCOUNT, 401)
|
|
887
|
+
const p = live(c.req.param('slug'))
|
|
888
|
+
if (!p) return c.json({ error: 'no such product' }, 404)
|
|
889
|
+
if (!commentLimit.allow(`comment:${user.id}`)) return c.json({ error: 'too many comments; wait a few minutes' }, 429)
|
|
890
|
+
const b = await body(c)
|
|
891
|
+
try {
|
|
892
|
+
const made = service.comment(p, user, String(b.body ?? b.text ?? ''), b.parent ? String(b.parent) : null)
|
|
893
|
+
const view = discussion(p, user).comments.find((x) => x.id === made.id)!
|
|
894
|
+
return c.json({ comment: apiComment(p, view), karma: store.karmaTotal(user.id) }, 201)
|
|
895
|
+
} catch (e) {
|
|
896
|
+
return apiError(c, e)
|
|
897
|
+
}
|
|
898
|
+
})
|
|
899
|
+
app.delete('/api/v1/comments/:id', (c) => {
|
|
900
|
+
const admin = isAdmin(c)
|
|
901
|
+
const user = apiUser(c)
|
|
902
|
+
if (!admin && !user) return c.json(NEEDS_ACCOUNT, 401)
|
|
903
|
+
try {
|
|
904
|
+
service.deleteComment(c.req.param('id'), user, admin)
|
|
905
|
+
return c.json({ deleted: true })
|
|
906
|
+
} catch (e) {
|
|
907
|
+
return apiError(c, e)
|
|
908
|
+
}
|
|
909
|
+
})
|
|
910
|
+
const apiReview = (x: ReviewView) => ({ rating: x.rating, body: x.body, author: x.author, karma: x.karma, at: new Date(x.at).toISOString() })
|
|
911
|
+
app.get('/api/v1/products/:slug/reviews', (c) => {
|
|
912
|
+
const p = live(c.req.param('slug'))
|
|
913
|
+
if (!p) return c.json({ error: 'no such product' }, 404)
|
|
914
|
+
return c.json({ slug: p.slug, rating: p.rating, count: p.reviews, reviews: discussion(p, null).reviews.map(apiReview) }, 200, { 'access-control-allow-origin': '*' })
|
|
915
|
+
})
|
|
916
|
+
app.post('/api/v1/products/:slug/reviews', async (c) => {
|
|
917
|
+
const user = apiUser(c)
|
|
918
|
+
if (!user) return c.json(NEEDS_ACCOUNT, 401)
|
|
919
|
+
const p = live(c.req.param('slug'))
|
|
920
|
+
if (!p) return c.json({ error: 'no such product' }, 404)
|
|
921
|
+
const b = await body(c)
|
|
922
|
+
try {
|
|
923
|
+
const r = service.review(p, user, b.rating, b.body ?? b.text ?? '')
|
|
924
|
+
const now = store.product(p.id)!
|
|
925
|
+
return c.json({ review: { rating: r.rating, body: r.body }, product: { rating: now.rating, count: now.reviews }, karma: store.karmaTotal(user.id) }, 201)
|
|
926
|
+
} catch (e) {
|
|
927
|
+
return apiError(c, e)
|
|
928
|
+
}
|
|
929
|
+
})
|
|
930
|
+
app.delete('/api/v1/products/:slug/reviews', (c) => {
|
|
931
|
+
const user = apiUser(c)
|
|
932
|
+
if (!user) return c.json(NEEDS_ACCOUNT, 401)
|
|
933
|
+
const p = store.bySlug(c.req.param('slug'))
|
|
934
|
+
if (!p) return c.json({ error: 'no such product' }, 404)
|
|
935
|
+
return c.json({ deleted: service.deleteReview(p, user.id) })
|
|
936
|
+
})
|
|
937
|
+
app.post('/api/v1/products/:slug/share', async (c) => {
|
|
938
|
+
const p = live(c.req.param('slug'))
|
|
939
|
+
if (!p) return c.json({ error: 'no such product' }, 404)
|
|
940
|
+
const b = await body(c)
|
|
941
|
+
try {
|
|
942
|
+
if (!shareLimit.allow(`share:${ip(c)}`)) return c.json({ counted: false })
|
|
943
|
+
return c.json(service.share(p, apiUser(c), String(b.network ?? c.req.query('net') ?? '')))
|
|
944
|
+
} catch (e) {
|
|
945
|
+
return apiError(c, e)
|
|
946
|
+
}
|
|
557
947
|
})
|
|
558
948
|
|
|
559
949
|
app.get('/api/v1/makers/:id', (c) => {
|
|
@@ -613,13 +1003,12 @@ export function configFromEnv(env: Record<string, string | undefined> = process.
|
|
|
613
1003
|
env.COINPAY_API_KEY && env.COINPAY_BUSINESS_ID
|
|
614
1004
|
? { 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
1005
|
: null
|
|
616
|
-
const chains = (env.COINPAY_CHAINS ?? '').split(/[\s,]+/).filter(Boolean)
|
|
617
1006
|
return {
|
|
618
1007
|
origin: (env.BUILTINDIE_ORIGIN ?? PUBLIC_ORIGIN).replace(/\/+$/, ''),
|
|
619
1008
|
coinpay,
|
|
620
1009
|
featureUsd: Number(env.FEATURE_USD ?? 19),
|
|
621
1010
|
featureDays: Number(env.FEATURE_DAYS ?? 7),
|
|
622
|
-
|
|
1011
|
+
karmaPerCredit: Number(env.KARMA_PER_CREDIT ?? 500) || 500,
|
|
623
1012
|
screenshots: env.BUILTINDIE_SCREENSHOTS !== 'off',
|
|
624
1013
|
}
|
|
625
1014
|
}
|
|
@@ -631,13 +1020,20 @@ export function serve(options: { port?: number; hostname?: string } = {}) {
|
|
|
631
1020
|
const media = new Media(env.BUILTINDIE_MEDIA ?? `${dataDir}/media`)
|
|
632
1021
|
const service = new BuiltIndie(store, media, { ...configFromEnv(env), claimSecret: env.BUILTINDIE_KEY })
|
|
633
1022
|
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 })
|
|
1023
|
+
const app = createApp({ service, auth: new Auth(store, mailer), adminKey: env.BUILTINDIE_ADMIN_KEY, mailer, coinpayOAuth: coinpayOAuthFromEnv(env) })
|
|
635
1024
|
const relays = (env.BUILTINDIE_RELAY_IPS ?? '23.95.228.174,127.0.0.1,::1').split(/[\s,]+/).filter(Boolean)
|
|
636
1025
|
startRingRefresh()
|
|
1026
|
+
startPitFooterRefresh('built.indie')
|
|
637
1027
|
// Drafts nobody launched within a day go, pictures too.
|
|
638
1028
|
const sweep = () => void Promise.all(store.sweepDrafts().map((id) => media.remove(id))).catch(() => {})
|
|
639
1029
|
sweep()
|
|
640
1030
|
setInterval(sweep, 3600_000).unref?.()
|
|
1031
|
+
// Badges placed since launch only count once someone presses Verify; look again every 6 hours, a few at a time.
|
|
1032
|
+
const reverify = async () => {
|
|
1033
|
+
for (const p of store.unverified()) await service.verify(p).catch(() => false)
|
|
1034
|
+
}
|
|
1035
|
+
setTimeout(() => void reverify(), 60_000).unref?.()
|
|
1036
|
+
setInterval(() => void reverify(), 6 * 3600_000).unref?.()
|
|
641
1037
|
return Bun.serve({
|
|
642
1038
|
port: options.port ?? Number(env.PORT ?? 8798),
|
|
643
1039
|
hostname: options.hostname ?? env.HOST ?? '127.0.0.1',
|
|
@@ -651,5 +1047,6 @@ export function serve(options: { port?: number; hostname?: string } = {}) {
|
|
|
651
1047
|
|
|
652
1048
|
if (import.meta.main) {
|
|
653
1049
|
const server = serve()
|
|
1050
|
+
startRingFooterRefresh()
|
|
654
1051
|
console.log(`built.indie listening on http://${server.hostname}:${server.port}`)
|
|
655
1052
|
}
|