@profullstack/builtindie 0.1.4 → 0.7.1
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 +33 -4
- package/package.json +16 -7
- package/public/app.js +58 -0
- package/public/style.css +113 -11
- package/src/auth.ts +10 -6
- package/src/browse.ts +243 -0
- package/src/cli.ts +131 -10
- package/src/client.ts +96 -5
- package/src/coinpay-signin.ts +118 -0
- package/src/db.ts +406 -9
- package/src/fetch.ts +15 -7
- package/src/html.ts +561 -82
- package/src/karma.ts +179 -0
- package/src/leaderboard.ts +59 -0
- package/src/mcp.ts +55 -4
- package/src/pit-footer.ts +39 -0
- package/src/profile.ts +34 -0
- package/src/ring-footer.ts +53 -0
- package/src/ring.ts +5 -1
- package/src/server.ts +607 -48
- package/src/service.ts +307 -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,17 +7,27 @@
|
|
|
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, INVITE_BONUS_DAYS, INVITES_PER_PERSON, 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'
|
|
29
|
+
import { mountProfileRoutes, PROFILE_ENHANCE_SCRIPT, saveSignupProfile, validateProfile } from '@profullstack/profile'
|
|
30
|
+
import { openProfiles } from './profile.js'
|
|
21
31
|
|
|
22
32
|
export { VERSION }
|
|
23
33
|
|
|
@@ -28,7 +38,8 @@ const CSP = [
|
|
|
28
38
|
"default-src 'none'",
|
|
29
39
|
"style-src 'self' 'unsafe-inline'",
|
|
30
40
|
"script-src 'self' https://crawlproof.com",
|
|
31
|
-
|
|
41
|
+
// The shared footer's badges: ours is same-origin, pwamart's is not.
|
|
42
|
+
"img-src 'self' data: https://pwamart.com",
|
|
32
43
|
"connect-src 'self' https://crawlproof.com",
|
|
33
44
|
'frame-src https://crawlproof.com https://www.youtube-nocookie.com https://player.vimeo.com https://www.loom.com',
|
|
34
45
|
"manifest-src 'self'",
|
|
@@ -46,7 +57,14 @@ const SECURITY_HEADERS: Record<string, string> = {
|
|
|
46
57
|
'cross-origin-opener-policy': 'same-origin',
|
|
47
58
|
}
|
|
48
59
|
|
|
49
|
-
export type Deps = { service: BuiltIndie; auth: Auth; adminKey?: string; secureCookies?: boolean }
|
|
60
|
+
export type Deps = { service: BuiltIndie; auth: Auth; adminKey?: string; secureCookies?: boolean; mailer?: Mailer; /** Sign in with CoinPay; off when null. */ coinpayOAuth?: CoinPayOAuth | null }
|
|
61
|
+
|
|
62
|
+
/** The invite code from /i/<code>, kept until the visitor signs up (it also rides in the magic link). */
|
|
63
|
+
const REF_COOKIE = 'bi_ref'
|
|
64
|
+
/** A scarce single-use invite code (INV-XXXX-XXXX-XXXX) from /i/<code>, kept until the visitor signs up. */
|
|
65
|
+
const INVITE_COOKIE = 'bi_invite'
|
|
66
|
+
/** An @profullstack/affiliates invite code, as opposed to a member's open invite-link code. */
|
|
67
|
+
const isInviteCode = (code: string | null | undefined): code is string => Boolean(code && /^INV-?[0-9A-Z]{4}-?[0-9A-Z]{4}-?[0-9A-Z]{4}$/i.test(code.trim()))
|
|
50
68
|
type Env = { Bindings: { ip?: string } }
|
|
51
69
|
|
|
52
70
|
/** A small fixed-window limit per key, in memory: enough to stop a loop, not a fortress. */
|
|
@@ -74,7 +92,12 @@ export function createApp(deps: Deps) {
|
|
|
74
92
|
const app = new Hono<Env>()
|
|
75
93
|
const readLimit = new Limiter(20, 10 * 60_000)
|
|
76
94
|
const voteLimit = new Limiter(60, 10 * 60_000)
|
|
95
|
+
const commentLimit = new Limiter(10, 10 * 60_000)
|
|
96
|
+
const shareLimit = new Limiter(60, 10 * 60_000)
|
|
97
|
+
const redeemLimit = new Limiter(10, 10 * 60_000)
|
|
77
98
|
const origin = config.origin
|
|
99
|
+
// The shared profile fields (profile.ts); user_profiles is created as the app starts.
|
|
100
|
+
const profiles = openProfiles(store.db)
|
|
78
101
|
|
|
79
102
|
const direct = (c: Context) => visitorOnMoshpit(SELF, c.req.header('host'), c.req.header('x-moshpit-name'))
|
|
80
103
|
const ip = (c: Context<Env>) => c.env?.ip ?? 'local'
|
|
@@ -82,7 +105,7 @@ export function createApp(deps: Deps) {
|
|
|
82
105
|
const sessionUser = (c: Context) => deps.auth.sessionUser(getCookie(c, SESSION_COOKIE))
|
|
83
106
|
const ctxOf = (c: Context<Env>): Ctx => {
|
|
84
107
|
const v = voter(c)
|
|
85
|
-
return { origin, user: sessionUser(c), voted: (id) => store.voted(id, v) }
|
|
108
|
+
return { origin, user: sessionUser(c), voted: (id) => store.voted(id, v), coinpay: Boolean(deps.coinpayOAuth) }
|
|
86
109
|
}
|
|
87
110
|
// Links in mail open where the visitor asked from: the relay, or the Moshpit name itself.
|
|
88
111
|
const here = (c: Context) => (direct(c) ? 'https://built.indie' : origin)
|
|
@@ -100,12 +123,54 @@ export function createApp(deps: Deps) {
|
|
|
100
123
|
const p = store.bySlug(slug)
|
|
101
124
|
return p?.status === 'live' ? p : null
|
|
102
125
|
}
|
|
126
|
+
const secure = (c: Context) => deps.secureCookies ?? (c.req.header('x-forwarded-proto') ?? 'https') === 'https'
|
|
127
|
+
|
|
128
|
+
// The leaderboards (@profullstack/leaderboard over the karma ledger and the invites table).
|
|
129
|
+
const board = siteLeaderboard(store, origin)
|
|
130
|
+
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)}` }))
|
|
131
|
+
const inviters = (limit: number) => rowsOf('invites', 'week', limit)
|
|
132
|
+
/** Top karma this week; an empty week falls back to all time so the widget never looks dead. */
|
|
133
|
+
const topKarma = async (limit: number): Promise<{ period: 'week' | 'all'; rows: InviterRow[] }> => {
|
|
134
|
+
const week = await rowsOf('karma', 'week', limit)
|
|
135
|
+
return week.length ? { period: 'week', rows: week } : { period: 'all', rows: await rowsOf('karma', 'all', limit) }
|
|
136
|
+
}
|
|
137
|
+
// Every karma write: the board re-reads the ledger and checks badges (a projection is never record()ed).
|
|
138
|
+
service.karma.onChange = (userId) => {
|
|
139
|
+
board.invalidate()
|
|
140
|
+
board.award(userId).catch((e) => console.error(`[karma] badges: ${(e as Error).message}`))
|
|
141
|
+
}
|
|
142
|
+
service.karma.onCredit = (userId, granted) => {
|
|
143
|
+
const u = store.user(userId)
|
|
144
|
+
if (!u || !deps.mailer) return
|
|
145
|
+
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`
|
|
146
|
+
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}`))
|
|
147
|
+
}
|
|
148
|
+
const inviteLink = (user: User) => `${origin}/i/${store.inviteCode(user.id)}`
|
|
149
|
+
service.onInviteEarned = (inviterId, p) => {
|
|
150
|
+
board.invalidate()
|
|
151
|
+
// A projection is never record()ed, so badges are checked here, when the fact they rest on changes.
|
|
152
|
+
board.award(inviterId).catch((e) => console.error(`[invite] badges: ${(e as Error).message}`))
|
|
153
|
+
const inviter = store.user(inviterId)
|
|
154
|
+
if (!inviter || !deps.mailer) return
|
|
155
|
+
const credits = store.inviteStats(inviterId).credits
|
|
156
|
+
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.`
|
|
157
|
+
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>`
|
|
158
|
+
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}`))
|
|
159
|
+
}
|
|
103
160
|
|
|
104
161
|
app.use('*', async (c, next) => {
|
|
105
162
|
await next()
|
|
106
163
|
if (!c.res.headers.has('content-security-policy')) for (const [k, v] of Object.entries(SECURITY_HEADERS)) c.header(k, v)
|
|
107
164
|
})
|
|
108
165
|
|
|
166
|
+
// ?ref=<a member's invite code> on any page is their invite link too: the same bi_ref cookie
|
|
167
|
+
// /i/<code> sets, first touch wins, so one sign-up is counted once, in one place (the invites table).
|
|
168
|
+
app.use('*', async (c, next) => {
|
|
169
|
+
const ref = c.req.query('ref')
|
|
170
|
+
if (ref && ref.length <= 40 && !getCookie(c, REF_COOKIE) && store.inviterOf(ref) && !sessionUser(c)) setCookie(c, REF_COOKIE, ref, { httpOnly: true, secure: secure(c), sameSite: 'Lax', path: '/', maxAge: 30 * 86400 })
|
|
171
|
+
await next()
|
|
172
|
+
})
|
|
173
|
+
|
|
109
174
|
app.get('/health', (c) => c.json({ ok: true, version: VERSION }))
|
|
110
175
|
|
|
111
176
|
// Static files.
|
|
@@ -130,8 +195,8 @@ export function createApp(deps: Deps) {
|
|
|
130
195
|
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
196
|
|
|
132
197
|
// 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() })),
|
|
198
|
+
app.get('/', async (c) =>
|
|
199
|
+
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
200
|
)
|
|
136
201
|
app.get('/top/:window', (c) => {
|
|
137
202
|
const w = c.req.param('window') as Window
|
|
@@ -139,10 +204,30 @@ export function createApp(deps: Deps) {
|
|
|
139
204
|
return page(c, boardPage(ctxOf(c), w, store.list(w, 50)))
|
|
140
205
|
})
|
|
141
206
|
app.get('/new', (c) => page(c, boardPage(ctxOf(c), 'new', store.list('new', 50))))
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
207
|
+
// Browse and search, with facets (browse.ts). /c/ and /t/ are one filter each, at an indexable address.
|
|
208
|
+
const browse = (c: Context, extra: Record<string, string> = {}) => {
|
|
209
|
+
const params = new URL(c.req.url).searchParams
|
|
210
|
+
for (const [k, v] of Object.entries(extra)) if (!params.getAll(k).includes(v)) params.append(k, v)
|
|
211
|
+
return search(store.allLive(), parseQuery(params))
|
|
212
|
+
}
|
|
213
|
+
app.get('/search', (c) => page(c, browsePage(ctxOf(c), browse(c))))
|
|
214
|
+
app.get('/c/:category', (c) => {
|
|
215
|
+
const key = c.req.param('category')
|
|
216
|
+
if (!(key in CATEGORY_LABELS)) return c.notFound()
|
|
217
|
+
const label = CATEGORY_LABELS[key]!
|
|
218
|
+
// Further filters go to /search, so /c/<category> stays one page.
|
|
219
|
+
if (new URL(c.req.url).search) return c.redirect(`/search?category=${encodeURIComponent(key)}&${new URL(c.req.url).searchParams}`)
|
|
220
|
+
return page(c, browsePage(ctxOf(c), browse(c, { category: key }), { path: `/c/${key}`, heading: label, intro: `${label}: indie products launched on built.indie` }))
|
|
221
|
+
})
|
|
222
|
+
app.get('/t/:tag', (c) => {
|
|
223
|
+
const tag = normalizeTag(c.req.param('tag'))
|
|
224
|
+
if (!tag) return c.notFound()
|
|
225
|
+
if (tag !== c.req.param('tag')) return c.redirect(`/t/${encodeURIComponent(tag)}`, 301)
|
|
226
|
+
if (new URL(c.req.url).search) return c.redirect(`/search?tag=${encodeURIComponent(tag)}&${new URL(c.req.url).searchParams}`)
|
|
227
|
+
const r = browse(c, { tag })
|
|
228
|
+
return page(c, browsePage(ctxOf(c), r, { path: `/t/${encodeURIComponent(tag)}`, heading: `#${tag}`, intro: `Indie products tagged ${tag}` }), r.total ? 200 : 404)
|
|
145
229
|
})
|
|
230
|
+
app.get('/tags', (c) => page(c, tagsPage(ctxOf(c), store.tagCounts())))
|
|
146
231
|
app.get('/help', (c) => page(c, helpPage(ctxOf(c))))
|
|
147
232
|
|
|
148
233
|
// Launching.
|
|
@@ -163,21 +248,50 @@ export function createApp(deps: Deps) {
|
|
|
163
248
|
})
|
|
164
249
|
|
|
165
250
|
// A product.
|
|
166
|
-
|
|
251
|
+
/** Comments and reviews as the page shows them: public names and karma, never emails. */
|
|
252
|
+
const discussion = (p: Product, me: User | null) => {
|
|
253
|
+
const comments = store.comments(p.id)
|
|
254
|
+
const reviews = store.reviews(p.id)
|
|
255
|
+
const karmaOf = store.karmaOf([...comments.map((x) => x.userId), ...reviews.map((x) => x.userId)])
|
|
256
|
+
const names = new Map<string, string>()
|
|
257
|
+
const who = (id: string) => names.get(id) ?? (names.set(id, store.publicName(id)), names.get(id)!)
|
|
258
|
+
const owner = p.ownerId ?? p.submitterUser
|
|
259
|
+
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) })
|
|
260
|
+
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) })
|
|
261
|
+
return { comments: comments.map(cv), reviews: reviews.map(rv), myReview: me ? store.review(p.id, me.id) : null }
|
|
262
|
+
}
|
|
263
|
+
const productView = async (c: Context<Env>, p: Product, extra: { error?: string } = {}, status = 200) =>
|
|
167
264
|
page(
|
|
168
265
|
c,
|
|
169
266
|
productPage(ctxOf(c), {
|
|
267
|
+
...discussion(p, sessionUser(c)),
|
|
268
|
+
error: extra.error,
|
|
269
|
+
selfListing: (() => {
|
|
270
|
+
const u = sessionUser(c)
|
|
271
|
+
return Boolean(u && (p.ownerId === u.id || p.submitterUser === u.id))
|
|
272
|
+
})(),
|
|
170
273
|
product: p,
|
|
171
274
|
award: store.awards(p.id),
|
|
172
275
|
maker: p.makerId ? store.maker(p.makerId) : null,
|
|
173
276
|
featuredOk: c.req.query('featured') === '1',
|
|
277
|
+
featuredCredit: c.req.query('featured') === 'credit',
|
|
278
|
+
featuredDaysUsed: c.req.query('featured') === 'days',
|
|
279
|
+
daysBalance: await (async () => {
|
|
280
|
+
const u = sessionUser(c)
|
|
281
|
+
return u && store.canEdit(p, u) ? service.featuredDays(u) : 0
|
|
282
|
+
})(),
|
|
174
283
|
launched: c.req.query('launched') === '1',
|
|
175
284
|
canEdit: store.canEdit(p, sessionUser(c)),
|
|
176
|
-
|
|
285
|
+
credits: (() => {
|
|
286
|
+
const u = sessionUser(c)
|
|
287
|
+
return u && store.canEdit(p, u) ? store.inviteStats(u.id).credits : 0
|
|
288
|
+
})(),
|
|
289
|
+
buyerEmail: sessionUser(c)?.email ?? '',
|
|
177
290
|
featureUsd: config.featureUsd,
|
|
178
291
|
featureDays: config.featureDays,
|
|
179
292
|
payments: Boolean(config.coinpay),
|
|
180
293
|
}),
|
|
294
|
+
status,
|
|
181
295
|
)
|
|
182
296
|
app.get('/p/:slug', (c) => {
|
|
183
297
|
const p = store.bySlug(c.req.param('slug'))
|
|
@@ -232,17 +346,19 @@ export function createApp(deps: Deps) {
|
|
|
232
346
|
}
|
|
233
347
|
})
|
|
234
348
|
|
|
235
|
-
// Claiming: the domain's owner proves it in DNS and takes the listing.
|
|
349
|
+
// Claiming: the domain's owner proves it with the badge carrying their badge id, or in DNS, and takes the listing.
|
|
236
350
|
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
|
-
|
|
351
|
+
const claimBadge = (p: Product, user: User) => claimBadgeSnippet(origin, p, store.badgeId(p.id, user.id))
|
|
352
|
+
const claimMd = (p: Product, user: User) => claimMarkdown(origin, p, claimRecord(p, user), claimBadge(p, user))
|
|
353
|
+
const claimView = (c: Context<Env>, p: Product, user: User, extra: { result?: { ok: boolean; looked: string[]; method?: string } } = {}, status = 200) =>
|
|
354
|
+
page(c, claimPage(ctxOf(c), { product: p, record: claimRecord(p, user), badge: claimBadge(p, user), owner: p.ownerId === user.id, ...extra }), status)
|
|
239
355
|
// The same instructions as Markdown, to paste to an AI agent with DNS access.
|
|
240
356
|
app.get('/p/:slug/claim.md', (c) => {
|
|
241
357
|
const user = sessionUser(c)
|
|
242
358
|
if (!user) return c.redirect(`/login?next=${encodeURIComponent(c.req.path)}`)
|
|
243
359
|
const p = store.bySlug(c.req.param('slug'))
|
|
244
360
|
if (!p || p.status === 'hidden') return c.text('not found', 404)
|
|
245
|
-
return c.body(
|
|
361
|
+
return c.body(claimMd(p, user), 200, { 'content-type': 'text/markdown; charset=utf-8', 'cache-control': 'private, no-store' })
|
|
246
362
|
})
|
|
247
363
|
app.get('/p/:slug/claim', (c) => {
|
|
248
364
|
const user = sessionUser(c)
|
|
@@ -261,19 +377,64 @@ export function createApp(deps: Deps) {
|
|
|
261
377
|
return claimView(c, store.product(p.id)!, user, { result })
|
|
262
378
|
})
|
|
263
379
|
|
|
380
|
+
/**
|
|
381
|
+
* A new member: a scarce invite code (bi_invite) gives both people Featured days, and an
|
|
382
|
+
* invite link (bi_ref, /i/<code> or ?ref=) makes their first live listing earn the inviter a credit.
|
|
383
|
+
* Both write the same invites row, first one wins, so a sign-up is counted once.
|
|
384
|
+
*/
|
|
385
|
+
const joined = async (c: Context, user: User, carried: string | null) => {
|
|
386
|
+
const parts = (carried ?? '').split(' ').filter(Boolean)
|
|
387
|
+
const invite = parts.find(isInviteCode) ?? getCookie(c, INVITE_COOKIE)
|
|
388
|
+
const ref = parts.find((x) => !isInviteCode(x)) ?? getCookie(c, REF_COOKIE)
|
|
389
|
+
if (invite) await service.acceptInvite(invite, user).catch((e) => console.error(`[invite] accept failed: ${(e as Error).message}`))
|
|
390
|
+
if (ref) store.recordInvite(user.id, ref)
|
|
391
|
+
for (const name of [REF_COOKIE, INVITE_COOKIE]) deleteCookie(c, name, { path: '/' })
|
|
392
|
+
}
|
|
393
|
+
|
|
264
394
|
// Signing in: an emailed link, which is also sign-up.
|
|
265
|
-
|
|
395
|
+
const invitedBy = (c: Context) => {
|
|
396
|
+
const inviter = store.inviterOf(getCookie(c, REF_COOKIE) ?? '')
|
|
397
|
+
return inviter && !sessionUser(c) ? store.publicName(inviter) : null
|
|
398
|
+
}
|
|
399
|
+
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') ?? ''] })))
|
|
400
|
+
// Creating an account asks for the shared profile fields; plain sign-in never does.
|
|
401
|
+
app.get('/signup', (c) => page(c, loginPage(ctxOf(c), { next: safeNext(c.req.query('next')), invitedBy: invitedBy(c), signup: true })))
|
|
402
|
+
app.get('/vendor-profile.js', (c) => c.body(PROFILE_ENHANCE_SCRIPT, 200, { 'content-type': 'text/javascript; charset=utf-8', 'cache-control': 'public, max-age=86400' }))
|
|
403
|
+
// Sign in with CoinPay (coinpay-signin.ts): /api/v1/coinpay/login and /callback.
|
|
404
|
+
mountCoinPaySignin(app, {
|
|
405
|
+
store,
|
|
406
|
+
auth: deps.auth,
|
|
407
|
+
origin,
|
|
408
|
+
oauth: deps.coinpayOAuth ?? null,
|
|
409
|
+
secure,
|
|
410
|
+
sessionUser,
|
|
411
|
+
// A new member who came through an invite link counts, as with a magic link.
|
|
412
|
+
onNewMember: (c, user) => void joined(c, user, null),
|
|
413
|
+
})
|
|
266
414
|
app.post('/login', async (c) => {
|
|
267
415
|
const form = (await c.req.parseBody()) as Record<string, string>
|
|
268
416
|
const next = safeNext(form.next)
|
|
269
|
-
|
|
270
|
-
if (!
|
|
417
|
+
const signup = form.mode === 'signup'
|
|
418
|
+
if (!validEmail(form.email)) return page(c, loginPage(ctxOf(c), { next, error: 'That does not look like an email address.', email: form.email, signup, values: form }), 400)
|
|
419
|
+
// The signup fields are checked before any link exists; they ride on the link and are saved on verify.
|
|
420
|
+
let profile: object | null = null
|
|
421
|
+
if (signup) {
|
|
422
|
+
const check = validateProfile(form)
|
|
423
|
+
if (!check.ok) return page(c, loginPage(ctxOf(c), { next, error: 'Some fields need fixing.', email: form.email, signup, values: form, errors: check.errors }), 400)
|
|
424
|
+
profile = check.profile
|
|
425
|
+
}
|
|
426
|
+
// The invites ride in the link (space-separated), so opening it in another browser still counts.
|
|
427
|
+
if (!crossSite(c) && readLimit.allow(`mail:${ip(c)}`)) await deps.auth.sendLink(form.email ?? '', next, here(c), [getCookie(c, INVITE_COOKIE), getCookie(c, REF_COOKIE)].filter(Boolean).join(' ') || null, profile)
|
|
271
428
|
return page(c, loginPage(ctxOf(c), { next, sent: true }))
|
|
272
429
|
})
|
|
273
|
-
app.get('/auth/verify', (c) => {
|
|
430
|
+
app.get('/auth/verify', async (c) => {
|
|
274
431
|
const r = deps.auth.consumeLink(c.req.query('token') ?? '')
|
|
275
432
|
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:
|
|
433
|
+
setCookie(c, SESSION_COOKIE, deps.auth.createSession(r.user.id), { httpOnly: true, secure: secure(c), sameSite: 'Lax', path: '/', maxAge: 90 * 86400 })
|
|
434
|
+
if (r.created) await joined(c, r.user, r.ref ?? null)
|
|
435
|
+
else for (const name of [REF_COOKIE, INVITE_COOKIE]) deleteCookie(c, name, { path: '/' })
|
|
436
|
+
// The signup form's fields, now that the account exists. A failure never costs the sign-in: /me asks again.
|
|
437
|
+
if (r.profile) await saveSignupProfile(await profiles.ready, r.user.id, r.profile, { email: r.user.email }).catch((error) => console.error(`[auth] signup profile save failed: ${(error as Error).message}`))
|
|
277
438
|
return c.redirect(r.next ?? '/me')
|
|
278
439
|
})
|
|
279
440
|
app.post('/logout', (c) => {
|
|
@@ -281,22 +442,142 @@ export function createApp(deps: Deps) {
|
|
|
281
442
|
deleteCookie(c, SESSION_COOKIE, { path: '/' })
|
|
282
443
|
return c.redirect('/')
|
|
283
444
|
})
|
|
284
|
-
|
|
445
|
+
const signinInfo = (c: Context, user: User) => ({
|
|
446
|
+
coinpay: store.identities(user.id).find((i) => i.provider === COINPAY) ?? null,
|
|
447
|
+
emailless: isPlaceholder(user.email),
|
|
448
|
+
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,
|
|
449
|
+
})
|
|
450
|
+
app.get('/me', async (c) => {
|
|
285
451
|
const user = sessionUser(c)
|
|
286
|
-
return user ? page(c, mePage(ctxOf(c), { user, products: store.mine(user.id) })) : toLogin(c)
|
|
452
|
+
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, daysBalance: await service.featuredDays(user), ...signinInfo(c, user), profile: await (await profiles.ready).get(user.id), detailsNote: c.req.query('details') === 'saved' ? 'Your details are saved.' : null })) : toLogin(c)
|
|
287
453
|
})
|
|
288
|
-
|
|
454
|
+
// "Have a code?": a credit code (@profullstack/redeem-credits) adds Featured days to the balance.
|
|
455
|
+
app.post('/me/redeem', async (c) => {
|
|
289
456
|
const user = sessionUser(c)
|
|
290
457
|
if (!user) return toLogin(c)
|
|
291
458
|
if (crossSite(c)) return c.redirect('/me')
|
|
292
|
-
|
|
459
|
+
const code = String(((await c.req.parseBody()) as Record<string, string>).code ?? '')
|
|
460
|
+
let note: string
|
|
461
|
+
let status = 200
|
|
462
|
+
if (!redeemLimit.allow(`redeem:${user.id}`) || !redeemLimit.allow(`redeem-ip:${ip(c)}`)) (note = 'Too many tries. Wait a few minutes.'), (status = 429)
|
|
463
|
+
else
|
|
464
|
+
try {
|
|
465
|
+
note = (await service.redeem(user, code)).message
|
|
466
|
+
} catch (e) {
|
|
467
|
+
note = e instanceof Error ? e.message : String(e)
|
|
468
|
+
status = 400
|
|
469
|
+
}
|
|
470
|
+
return 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, daysBalance: await service.featuredDays(user), redeemNote: note, redeemError: status !== 200, ...signinInfo(c, user), profile: await (await profiles.ready).get(user.id) }), status)
|
|
471
|
+
})
|
|
472
|
+
// Spend Featured days from the balance on a listing you own.
|
|
473
|
+
app.post('/p/:slug/feature-days', async (c) => {
|
|
474
|
+
const user = sessionUser(c)
|
|
475
|
+
if (!user) return toLogin(c)
|
|
476
|
+
const p = store.bySlug(c.req.param('slug'))
|
|
477
|
+
if (!p || p.status === 'hidden') return c.notFound()
|
|
478
|
+
if (crossSite(c) || !store.canEdit(p, user)) return c.redirect(`/p/${p.slug}`)
|
|
479
|
+
const days = Number(((await c.req.parseBody()) as Record<string, string>).days ?? 0)
|
|
480
|
+
try {
|
|
481
|
+
await service.spendFeaturedDays(p, user, days)
|
|
482
|
+
return c.redirect(`/p/${p.slug}?featured=days`)
|
|
483
|
+
} catch (e) {
|
|
484
|
+
return productView(c, p, { error: e instanceof Error ? e.message : String(e) }, 400)
|
|
485
|
+
}
|
|
486
|
+
})
|
|
487
|
+
// The shared profile (@profullstack/profile): the /me form, and GET/PUT/PATCH /api/v1/me/profile for a session or an API key.
|
|
488
|
+
app.post('/me/profile', async (c) => {
|
|
489
|
+
const user = sessionUser(c)
|
|
490
|
+
if (!user) return toLogin(c)
|
|
491
|
+
if (crossSite(c)) return c.redirect('/me')
|
|
492
|
+
const form = (await c.req.parseBody()) as Record<string, string>
|
|
493
|
+
const email = isPlaceholder(user.email) ? null : user.email
|
|
494
|
+
const check = validateProfile({ ...form, email }, { required: ['first_name', 'last_name'] })
|
|
495
|
+
if (!check.ok) return 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, daysBalance: await service.featuredDays(user), ...signinInfo(c, user), profile: form, detailsNote: 'Some fields need fixing.', detailsError: true, detailsErrors: check.errors }), 400)
|
|
496
|
+
await (await profiles.ready).save(user.id, check.profile)
|
|
497
|
+
return c.redirect('/me?details=saved#details')
|
|
498
|
+
})
|
|
499
|
+
app.post('/me/key', async (c) => {
|
|
500
|
+
const user = sessionUser(c)
|
|
501
|
+
if (!user) return toLogin(c)
|
|
502
|
+
if (crossSite(c)) return c.redirect('/me')
|
|
503
|
+
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, daysBalance: await service.featuredDays(user), ...signinInfo(c, user) }))
|
|
504
|
+
})
|
|
505
|
+
|
|
506
|
+
// Invites: a link per member; a new member it brings in who launches earns the inviter a free Featured week.
|
|
507
|
+
app.get('/i/:code', (c) => {
|
|
508
|
+
const code = c.req.param('code')
|
|
509
|
+
// A scarce invite code: whoever made it stays private until it is used.
|
|
510
|
+
if (isInviteCode(code)) {
|
|
511
|
+
if (!sessionUser(c)) setCookie(c, INVITE_COOKIE, code.trim().toUpperCase(), { httpOnly: true, secure: secure(c), sameSite: 'Lax', path: '/', maxAge: 30 * 86400 })
|
|
512
|
+
return page(c, inviteLanding(ctxOf(c), { inviter: null, bonusDays: INVITE_BONUS_DAYS }))
|
|
513
|
+
}
|
|
514
|
+
const inviter = store.inviterOf(code)
|
|
515
|
+
if (!inviter) return c.redirect('/invite')
|
|
516
|
+
if (!sessionUser(c)) setCookie(c, REF_COOKIE, code, { httpOnly: true, secure: secure(c), sameSite: 'Lax', path: '/', maxAge: 30 * 86400 })
|
|
517
|
+
return page(c, inviteLanding(ctxOf(c), { inviter: store.publicName(inviter) }))
|
|
518
|
+
})
|
|
519
|
+
const inviteCodes = async (user: User) => ({ quota: await service.affiliates.quota(user.id), invites: await service.affiliates.invites(user.id), bonusDays: INVITE_BONUS_DAYS })
|
|
520
|
+
const inviteView = async (c: Context<Env>, extra: { flash?: string; error?: string; newCode?: string } = {}, status = 200) => {
|
|
521
|
+
const user = sessionUser(c)
|
|
522
|
+
const me = user
|
|
523
|
+
? { 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'), codes: { ...(await inviteCodes(user)), newLink: extra.newCode ? `${origin}/i/${extra.newCode}` : undefined }, ...extra }
|
|
524
|
+
: undefined
|
|
525
|
+
return page(c, invitePage(ctxOf(c), { featureDays: config.featureDays, featureUsd: config.featureUsd, inviters: await inviters(10), me }), status)
|
|
526
|
+
}
|
|
527
|
+
app.get('/invite', (c) => inviteView(c))
|
|
528
|
+
app.get('/invites', (c) => c.redirect('/invite#codes'))
|
|
529
|
+
// A scarce invite code: three each, single use.
|
|
530
|
+
app.post('/invite/code', async (c) => {
|
|
531
|
+
const user = sessionUser(c)
|
|
532
|
+
if (!user) return toLogin(c)
|
|
533
|
+
if (crossSite(c)) return c.redirect('/invite')
|
|
534
|
+
const note = String(((await c.req.parseBody()) as Record<string, string>).note ?? '').slice(0, 80)
|
|
535
|
+
try {
|
|
536
|
+
const invite = await service.affiliates.createInvite(user.id, note)
|
|
537
|
+
return inviteView(c, { newCode: invite.code })
|
|
538
|
+
} catch (e) {
|
|
539
|
+
return inviteView(c, { error: (e as Error).message === 'no invites left' ? `You have used all ${INVITES_PER_PERSON} invite codes.` : (e as Error).message }, 400)
|
|
540
|
+
}
|
|
541
|
+
})
|
|
542
|
+
app.post('/invite/name', async (c) => {
|
|
543
|
+
const user = sessionUser(c)
|
|
544
|
+
if (!user) return toLogin(c)
|
|
545
|
+
if (crossSite(c)) return c.redirect('/invite')
|
|
546
|
+
const name = String(((await c.req.parseBody()) as Record<string, string>).displayName ?? '').replace(/\s+/g, ' ').trim().slice(0, 40)
|
|
547
|
+
store.setDisplayName(user.id, name || null)
|
|
548
|
+
board.invalidate()
|
|
549
|
+
return inviteView(c, { flash: name ? `Saved. The board shows you as ${name}.` : 'Cleared. The board shows your best listing instead.' })
|
|
550
|
+
})
|
|
551
|
+
app.post('/p/:slug/credit', (c) => {
|
|
552
|
+
const user = sessionUser(c)
|
|
553
|
+
if (!user) return toLogin(c)
|
|
554
|
+
const p = store.bySlug(c.req.param('slug'))
|
|
555
|
+
if (!p || p.status === 'hidden') return c.notFound()
|
|
556
|
+
if (crossSite(c) || !store.canEdit(p, user)) return c.redirect(`/p/${p.slug}`)
|
|
557
|
+
try {
|
|
558
|
+
service.redeemCredit(p, user)
|
|
559
|
+
return c.redirect(`/p/${p.slug}?featured=credit`)
|
|
560
|
+
} catch (e) {
|
|
561
|
+
return inviteView(c, { error: e instanceof Error ? e.message : String(e) }, 400)
|
|
562
|
+
}
|
|
293
563
|
})
|
|
294
564
|
|
|
565
|
+
// The leaderboard: our page, then the package answers everything under /leaderboard (JSON, RSS, share cards, widget).
|
|
566
|
+
app.get('/leaderboard', async (c) => {
|
|
567
|
+
const user = sessionUser(c)
|
|
568
|
+
const which = c.req.query('board') === 'invites' ? 'invites' : 'karma'
|
|
569
|
+
// An empty week shows all time instead, so the board never opens blank.
|
|
570
|
+
const period = (await rowsOf(which, 'week', 1)).length ? 'week' : 'all'
|
|
571
|
+
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 }))
|
|
572
|
+
})
|
|
573
|
+
app.use('/leaderboard.json', leaderboard(board))
|
|
574
|
+
app.use('/leaderboard/*', leaderboard(board))
|
|
575
|
+
|
|
295
576
|
app.post('/p/:slug/vote', (c) => {
|
|
296
577
|
const p = live(c.req.param('slug'))
|
|
297
578
|
if (!p) return wantsJson(c) ? c.json({ error: 'no such product' }, 404) : c.notFound()
|
|
298
579
|
if (crossSite(c)) return c.json({ error: 'vote from built.indie itself' }, 403)
|
|
299
|
-
const counted = voteLimit.allow(`vote:${ip(c)}`) &&
|
|
580
|
+
const counted = voteLimit.allow(`vote:${ip(c)}`) && service.vote(p, voter(c), sessionUser(c))
|
|
300
581
|
const now = store.product(p.id)!
|
|
301
582
|
if (wantsJson(c)) return c.json({ slug: now.slug, votes: now.votes, counted, voted: true })
|
|
302
583
|
const back = c.req.header('referer')
|
|
@@ -307,6 +588,66 @@ export function createApp(deps: Deps) {
|
|
|
307
588
|
return c.redirect(to)
|
|
308
589
|
})
|
|
309
590
|
|
|
591
|
+
// Comments, reviews and tracked shares: signed-in members, from this site only.
|
|
592
|
+
const back = (p: Product, hash: string) => `/p/${p.slug}#${hash}`
|
|
593
|
+
app.post('/p/:slug/comments', async (c) => {
|
|
594
|
+
const user = sessionUser(c)
|
|
595
|
+
if (!user) return toLogin(c)
|
|
596
|
+
const p = live(c.req.param('slug'))
|
|
597
|
+
if (!p) return c.notFound()
|
|
598
|
+
if (crossSite(c)) return c.redirect(back(p, 'comments'))
|
|
599
|
+
if (!commentLimit.allow(`comment:${user.id}`)) return productView(c, p, { error: 'That is a lot of comments. Wait a few minutes.' }, 429)
|
|
600
|
+
const form = (await c.req.parseBody()) as Record<string, string>
|
|
601
|
+
try {
|
|
602
|
+
const made = service.comment(p, user, form.body ?? '', form.parent || null)
|
|
603
|
+
return c.redirect(back(p, `c-${made.id}`))
|
|
604
|
+
} catch (e) {
|
|
605
|
+
if (e instanceof InputError) return productView(c, p, { error: e.message }, 400)
|
|
606
|
+
throw e
|
|
607
|
+
}
|
|
608
|
+
})
|
|
609
|
+
app.post('/p/:slug/comments/:id/delete', (c) => {
|
|
610
|
+
const user = sessionUser(c)
|
|
611
|
+
if (!user) return toLogin(c)
|
|
612
|
+
const p = store.bySlug(c.req.param('slug'))
|
|
613
|
+
if (!p) return c.notFound()
|
|
614
|
+
if (crossSite(c)) return c.redirect(back(p, 'comments'))
|
|
615
|
+
const cm = store.comment(c.req.param('id'))
|
|
616
|
+
if (!cm || cm.productId !== p.id) return c.redirect(back(p, 'comments'))
|
|
617
|
+
try {
|
|
618
|
+
service.deleteComment(cm.id, user)
|
|
619
|
+
} catch (e) {
|
|
620
|
+
if (!(e instanceof InputError)) throw e
|
|
621
|
+
}
|
|
622
|
+
return c.redirect(back(p, 'comments'))
|
|
623
|
+
})
|
|
624
|
+
app.post('/p/:slug/review', async (c) => {
|
|
625
|
+
const user = sessionUser(c)
|
|
626
|
+
if (!user) return toLogin(c)
|
|
627
|
+
const p = live(c.req.param('slug'))
|
|
628
|
+
if (!p) return c.notFound()
|
|
629
|
+
if (crossSite(c)) return c.redirect(back(p, 'reviews'))
|
|
630
|
+
const form = (await c.req.parseBody()) as Record<string, string>
|
|
631
|
+
try {
|
|
632
|
+
if (form.action === 'delete') service.deleteReview(p, user.id)
|
|
633
|
+
else service.review(p, user, form.rating, form.body)
|
|
634
|
+
return c.redirect(back(p, 'reviews'))
|
|
635
|
+
} catch (e) {
|
|
636
|
+
if (e instanceof InputError) return productView(c, p, { error: e.message }, 400)
|
|
637
|
+
throw e
|
|
638
|
+
}
|
|
639
|
+
})
|
|
640
|
+
// A share button: app.js sends a beacon here before the share window opens.
|
|
641
|
+
app.post('/p/:slug/share', (c) => {
|
|
642
|
+
const p = live(c.req.param('slug'))
|
|
643
|
+
if (!p) return c.json({ error: 'no such product' }, 404)
|
|
644
|
+
if (crossSite(c)) return c.json({ error: 'share from built.indie itself' }, 403)
|
|
645
|
+
const net = c.req.query('net') ?? ''
|
|
646
|
+
if (!(SHARE_NETWORKS as readonly string[]).includes(net)) return c.json({ error: `net is one of ${SHARE_NETWORKS.join(', ')}` }, 400)
|
|
647
|
+
if (!shareLimit.allow(`share:${ip(c)}`)) return c.json({ counted: false })
|
|
648
|
+
return c.json(service.share(p, sessionUser(c), net))
|
|
649
|
+
})
|
|
650
|
+
|
|
310
651
|
app.post('/p/:slug/verify', async (c) => {
|
|
311
652
|
const p = live(c.req.param('slug'))
|
|
312
653
|
if (!p) return c.notFound()
|
|
@@ -320,8 +661,9 @@ export function createApp(deps: Deps) {
|
|
|
320
661
|
if (!p) return c.notFound()
|
|
321
662
|
const form = (await c.req.parseBody()) as Record<string, string>
|
|
322
663
|
try {
|
|
323
|
-
const
|
|
324
|
-
|
|
664
|
+
const user = sessionUser(c)
|
|
665
|
+
const r = await service.feature(p, { email: form.email || user?.email || '', accountId: user?.id ?? null })
|
|
666
|
+
return c.redirect(r.checkoutUrl, 303)
|
|
325
667
|
} catch (e) {
|
|
326
668
|
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
669
|
}
|
|
@@ -347,8 +689,8 @@ export function createApp(deps: Deps) {
|
|
|
347
689
|
})
|
|
348
690
|
|
|
349
691
|
// The badge: the viral part. Cached briefly so the count stays live.
|
|
350
|
-
|
|
351
|
-
|
|
692
|
+
const badgeImage = (c: Context<Env>, p: Product | null, unlisted = false) => {
|
|
693
|
+
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
694
|
if (!p) return c.body('<svg xmlns="http://www.w3.org/2000/svg" width="1" height="1"/>', 404, { 'content-type': 'image/svg+xml' })
|
|
353
695
|
return c.body(badgeSvg(p, store.awards(p.id), c.req.query('theme') === 'light' ? 'light' : 'dark'), 200, {
|
|
354
696
|
'content-type': 'image/svg+xml; charset=utf-8',
|
|
@@ -357,6 +699,20 @@ export function createApp(deps: Deps) {
|
|
|
357
699
|
'cross-origin-resource-policy': 'cross-origin',
|
|
358
700
|
'content-security-policy': "default-src 'none'; style-src 'unsafe-inline'",
|
|
359
701
|
})
|
|
702
|
+
}
|
|
703
|
+
app.get('/badge/:file{[a-z0-9-]+\\.svg}', (c) => badgeImage(c, live(c.req.param('file').replace(/\.svg$/, ''))))
|
|
704
|
+
// The same badge by badge id: the one the claim page hands out. On a homepage it claims the listing for that account.
|
|
705
|
+
// By site, for snippets shared across many sites (the Profullstack footer): ?site=<homepage>.
|
|
706
|
+
// A site with no listing (built.indie itself, a brand-new site) gets an invitation to launch instead.
|
|
707
|
+
app.get('/for.svg', (c) => badgeImage(c, store.bySite(c.req.query('site') ?? ''), true))
|
|
708
|
+
app.get('/for', (c) => {
|
|
709
|
+
const p = store.bySite(c.req.query('site') ?? '')
|
|
710
|
+
return c.redirect(p ? `/p/${p.slug}?ref=badge` : '/submit?ref=badge')
|
|
711
|
+
})
|
|
712
|
+
app.get('/images.svg', (c) => {
|
|
713
|
+
const b = store.badge(c.req.query('id') ?? '')
|
|
714
|
+
const p = b ? store.product(b.productId) : null
|
|
715
|
+
return badgeImage(c, p?.status === 'live' ? p : null)
|
|
360
716
|
})
|
|
361
717
|
|
|
362
718
|
app.get('/makers/:id', (c) => {
|
|
@@ -377,12 +733,13 @@ export function createApp(deps: Deps) {
|
|
|
377
733
|
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
734
|
})
|
|
379
735
|
app.get('/sitemap.xml', (c) => {
|
|
380
|
-
const
|
|
736
|
+
const all = search(store.allLive(), parseQuery({}), { limit: 0 })
|
|
737
|
+
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
738
|
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
739
|
})
|
|
383
740
|
app.get('/robots.txt', (c) => c.text(`User-agent: *\nAllow: /\nDisallow: /p/*/edit\nSitemap: ${origin}/sitemap.xml\n`))
|
|
384
741
|
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`),
|
|
742
|
+
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
743
|
)
|
|
387
744
|
app.get('/.well-known/openprofile.md', (c) =>
|
|
388
745
|
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 +766,8 @@ export function createApp(deps: Deps) {
|
|
|
409
766
|
domain: p.domain,
|
|
410
767
|
status: p.status,
|
|
411
768
|
votes: p.votes,
|
|
769
|
+
reviews: p.reviews,
|
|
770
|
+
rating: p.rating,
|
|
412
771
|
tags: p.tags,
|
|
413
772
|
logo: p.logo ? `${origin}/m/${p.id}/${p.logo}` : null,
|
|
414
773
|
card_image: p.ogImage ? `${origin}/m/${p.id}/${p.ogImage}` : null,
|
|
@@ -452,11 +811,12 @@ export function createApp(deps: Deps) {
|
|
|
452
811
|
})
|
|
453
812
|
|
|
454
813
|
app.post('/api/v1/products', async (c) => {
|
|
455
|
-
|
|
814
|
+
const admin = isAdmin(c)
|
|
815
|
+
if (!admin && !readLimit.allow(`read:${ip(c)}`)) return c.json({ error: 'too many launches from you; wait a few minutes' }, 429)
|
|
456
816
|
const b = await body(c)
|
|
457
817
|
try {
|
|
458
818
|
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)
|
|
819
|
+
const r = await service.submit({ url: String(b.url ?? ''), makerName: b.maker_name, makerProfile: b.maker_profile }, store.voterFor(ip(c)), user, admin)
|
|
460
820
|
if (r.existing) return c.json({ existing: true, product: apiProduct(r.product) }, 200)
|
|
461
821
|
let p = r.product
|
|
462
822
|
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,12 +830,42 @@ export function createApp(deps: Deps) {
|
|
|
470
830
|
app.get('/api/v1/products', (c) => {
|
|
471
831
|
const w = (c.req.query('window') ?? 'today') as Window
|
|
472
832
|
const q = c.req.query('q')
|
|
473
|
-
|
|
833
|
+
const tag = c.req.query('tag') !== undefined ? normalizeTag(c.req.query('tag')!) : undefined
|
|
834
|
+
if (tag === null) return c.json({ error: 'tag is 2 to 24 letters, digits, spaces or + # . -' }, 400)
|
|
835
|
+
if (!q && !tag && !WINDOWS.includes(w)) return c.json({ error: `window is one of ${WINDOWS.join(', ')}` }, 400)
|
|
474
836
|
const limit = Math.min(100, Math.max(1, Number(c.req.query('limit') ?? 30) || 30))
|
|
475
837
|
const offset = Math.max(0, Number(c.req.query('offset') ?? 0) || 0)
|
|
476
|
-
|
|
838
|
+
const products = tag ? store.byTag(tag, limit) : q ? search(store.allLive(), parseQuery({ q }), { limit }).products : store.list(w, limit, offset)
|
|
839
|
+
return c.json({ window: tag ? 'tag' : q ? 'search' : w, ...(tag ? { tag } : {}), products: products.map(apiProduct) }, 200, { 'access-control-allow-origin': '*' })
|
|
840
|
+
})
|
|
841
|
+
|
|
842
|
+
// Search with facets: the browse page as data. Same parameters as /search.
|
|
843
|
+
app.get('/api/v1/search', (c) => {
|
|
844
|
+
const params = new URL(c.req.url).searchParams
|
|
845
|
+
const limit = Math.min(100, Math.max(1, Number(params.get('limit') ?? 30) || 30))
|
|
846
|
+
const r = search(store.allLive(), parseQuery(params), { limit })
|
|
847
|
+
const qs = queryString(r.query)
|
|
848
|
+
return c.json(
|
|
849
|
+
{
|
|
850
|
+
query: r.query,
|
|
851
|
+
total: r.total,
|
|
852
|
+
page: r.query.page,
|
|
853
|
+
pages: Math.ceil(r.total / limit),
|
|
854
|
+
products: r.products.map((p) => ({ ...apiProduct(p), categories: r.categories.get(p.id) ?? [] })),
|
|
855
|
+
...(params.get('facets') === '0' ? {} : { facets: r.facets }),
|
|
856
|
+
web: `${origin}/search${qs ? `?${qs}` : ''}`,
|
|
857
|
+
},
|
|
858
|
+
200,
|
|
859
|
+
{ 'access-control-allow-origin': '*', 'cache-control': 'public, max-age=30' },
|
|
860
|
+
)
|
|
861
|
+
})
|
|
862
|
+
app.get('/api/v1/categories', (c) => {
|
|
863
|
+
const r = search(store.allLive(), parseQuery({}), { limit: 0 })
|
|
864
|
+
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': '*' })
|
|
477
865
|
})
|
|
478
866
|
|
|
867
|
+
app.get('/api/v1/tags', (c) => c.json({ tags: store.tagCounts() }, 200, { 'access-control-allow-origin': '*' }))
|
|
868
|
+
|
|
479
869
|
app.get('/api/v1/products/:slug', (c) => {
|
|
480
870
|
const p = store.bySlug(c.req.param('slug'))
|
|
481
871
|
const mine = owned(c)
|
|
@@ -503,8 +893,10 @@ export function createApp(deps: Deps) {
|
|
|
503
893
|
if (!readLimit.allow(`read:${ip(c)}`)) return c.json({ error: 'too many reads from you; wait a few minutes' }, 429)
|
|
504
894
|
if (action === 'refetch') return c.json(apiProduct(await service.refetch(p)))
|
|
505
895
|
if (action === 'verify') return c.json({ verified: await service.verify(p) })
|
|
506
|
-
const
|
|
507
|
-
|
|
896
|
+
const b = await body(c)
|
|
897
|
+
const user = apiUser(c)
|
|
898
|
+
const r = await service.feature(p, { email: String(b.email ?? user?.email ?? ''), accountId: user?.id ?? null })
|
|
899
|
+
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
900
|
} catch (e) {
|
|
509
901
|
return apiError(c, e)
|
|
510
902
|
}
|
|
@@ -514,7 +906,7 @@ export function createApp(deps: Deps) {
|
|
|
514
906
|
const p = live(c.req.param('slug'))
|
|
515
907
|
if (!p) return c.json({ error: 'no such product' }, 404)
|
|
516
908
|
if (crossSite(c)) return c.json({ error: 'vote from built.indie itself' }, 403)
|
|
517
|
-
const counted = voteLimit.allow(`vote:${ip(c)}`) &&
|
|
909
|
+
const counted = voteLimit.allow(`vote:${ip(c)}`) && service.vote(p, voter(c), apiUser(c))
|
|
518
910
|
return c.json({ slug: p.slug, votes: store.product(p.id)!.votes, counted })
|
|
519
911
|
})
|
|
520
912
|
|
|
@@ -524,6 +916,9 @@ export function createApp(deps: Deps) {
|
|
|
524
916
|
const p = store.bySlug(c.req.param('slug'))
|
|
525
917
|
if (!p) return c.json({ error: 'no such product' }, 404)
|
|
526
918
|
store.update(p.id, { status: 'hidden' })
|
|
919
|
+
store.revokeInvite(p.id)
|
|
920
|
+
service.karma.takenDown(p.id)
|
|
921
|
+
board.invalidate()
|
|
527
922
|
return c.json({ hidden: true })
|
|
528
923
|
}
|
|
529
924
|
const p = owned(c)
|
|
@@ -539,7 +934,7 @@ export function createApp(deps: Deps) {
|
|
|
539
934
|
if (!user) return c.json(NEEDS_ACCOUNT, 401)
|
|
540
935
|
const p = store.bySlug(c.req.param('slug'))
|
|
541
936
|
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),
|
|
937
|
+
return c.json({ owner: p.ownerId === user.id, record: claimRecord(p, user), badge: claimBadge(p, user), markdown: claimMd(p, user) })
|
|
543
938
|
})
|
|
544
939
|
app.post('/api/v1/products/:slug/claim', async (c) => {
|
|
545
940
|
const user = apiUser(c)
|
|
@@ -548,12 +943,169 @@ export function createApp(deps: Deps) {
|
|
|
548
943
|
if (!p || p.status === 'hidden') return c.json({ error: 'no such product' }, 404)
|
|
549
944
|
if (!readLimit.allow(`read:${ip(c)}`)) return c.json({ error: 'too many checks; wait a few minutes' }, 429)
|
|
550
945
|
const r = await service.claim(p, user)
|
|
551
|
-
return c.json({ claimed: r.ok, looked: r.looked, record: claimRecord(p, user), ...(r.ok ? {} : { markdown:
|
|
946
|
+
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) }) })
|
|
947
|
+
})
|
|
948
|
+
mountProfileRoutes(app, {
|
|
949
|
+
store: profiles.store,
|
|
950
|
+
getUser: (_req: Request, c: Context) => {
|
|
951
|
+
const user = sessionUser(c) ?? apiUser(c)
|
|
952
|
+
return user ? { id: user.id, email: isPlaceholder(user.email) ? null : user.email } : null
|
|
953
|
+
},
|
|
954
|
+
})
|
|
955
|
+
app.get('/api/v1/me', async (c) => {
|
|
956
|
+
const user = apiUser(c)
|
|
957
|
+
if (!user) return c.json(NEEDS_ACCOUNT, 401)
|
|
958
|
+
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: await apiInvite(user), featured_days: await service.featuredDays(user), karma: apiKarma(user) })
|
|
959
|
+
})
|
|
960
|
+
const apiInvite = async (user: User) => {
|
|
961
|
+
const s = store.inviteStats(user.id)
|
|
962
|
+
const codes = await inviteCodes(user)
|
|
963
|
+
return {
|
|
964
|
+
link: inviteLink(user),
|
|
965
|
+
code: s.code,
|
|
966
|
+
invited: s.invited,
|
|
967
|
+
launched: s.launched,
|
|
968
|
+
credits: s.credits,
|
|
969
|
+
karma_credits: s.karmaCredits,
|
|
970
|
+
used: s.used,
|
|
971
|
+
feature_days: config.featureDays,
|
|
972
|
+
// Scarce single-use invite codes: the code itself is shown once, when made.
|
|
973
|
+
invite_codes: { quota: codes.quota, bonus_days: codes.bonusDays, invites: codes.invites.map((i) => ({ id: i.id, note: i.note, created_at: new Date(i.createdAt).toISOString(), expires_at: i.expiresAt ? new Date(i.expiresAt).toISOString() : null, used: Boolean(i.usedAt), used_at: i.usedAt ? new Date(i.usedAt).toISOString() : null })) },
|
|
974
|
+
}
|
|
975
|
+
}
|
|
976
|
+
app.get('/api/v1/invites', async (c) => {
|
|
977
|
+
const user = apiUser(c)
|
|
978
|
+
return user ? c.json(await apiInvite(user)) : c.json(NEEDS_ACCOUNT, 401)
|
|
979
|
+
})
|
|
980
|
+
app.post('/api/v1/invites', async (c) => {
|
|
981
|
+
const user = apiUser(c)
|
|
982
|
+
if (!user) return c.json(NEEDS_ACCOUNT, 401)
|
|
983
|
+
try {
|
|
984
|
+
const invite = await service.affiliates.createInvite(user.id, String((await body(c)).note ?? '').slice(0, 80))
|
|
985
|
+
return c.json({ link: `${origin}/i/${invite.code}`, code: invite.code, expires_at: invite.expiresAt ? new Date(invite.expiresAt).toISOString() : null, left: (await service.affiliates.quota(user.id)).left, bonus_days: INVITE_BONUS_DAYS }, 201)
|
|
986
|
+
} catch (e) {
|
|
987
|
+
return c.json({ error: (e as Error).message === 'no invites left' ? `You have used all ${INVITES_PER_PERSON} invite codes.` : (e as Error).message }, 400)
|
|
988
|
+
}
|
|
989
|
+
})
|
|
990
|
+
app.post('/api/v1/redeem', async (c) => {
|
|
991
|
+
const user = apiUser(c)
|
|
992
|
+
if (!user) return c.json(NEEDS_ACCOUNT, 401)
|
|
993
|
+
if (!redeemLimit.allow(`redeem:${user.id}`) || !redeemLimit.allow(`redeem-ip:${ip(c)}`)) return c.json({ error: 'too many tries; wait a few minutes' }, 429)
|
|
994
|
+
try {
|
|
995
|
+
const r = await service.redeem(user, String((await body(c)).code ?? ''))
|
|
996
|
+
return c.json({ ok: true, message: r.message, featured_days: r.days })
|
|
997
|
+
} catch (e) {
|
|
998
|
+
return apiError(c, e)
|
|
999
|
+
}
|
|
1000
|
+
})
|
|
1001
|
+
app.post('/api/v1/products/:slug/feature-days', async (c) => {
|
|
1002
|
+
const user = apiUser(c)
|
|
1003
|
+
const p = owned(c)
|
|
1004
|
+
if (!user || !p) return c.json(NEEDS_KEY, 401)
|
|
1005
|
+
try {
|
|
1006
|
+
const after = await service.spendFeaturedDays(p, user, Number((await body(c)).days ?? 0))
|
|
1007
|
+
return c.json({ featured_until: new Date(after.featuredUntil!).toISOString(), featured_days: await service.featuredDays(user) })
|
|
1008
|
+
} catch (e) {
|
|
1009
|
+
return apiError(c, e)
|
|
1010
|
+
}
|
|
1011
|
+
})
|
|
1012
|
+
app.post('/api/v1/products/:slug/credit', (c) => {
|
|
1013
|
+
const user = apiUser(c)
|
|
1014
|
+
const p = owned(c)
|
|
1015
|
+
if (!user || !p) return c.json(NEEDS_KEY, 401)
|
|
1016
|
+
try {
|
|
1017
|
+
const after = service.redeemCredit(p, user)
|
|
1018
|
+
return c.json({ featured_until: new Date(after.featuredUntil!).toISOString(), credits: store.inviteStats(user.id).credits })
|
|
1019
|
+
} catch (e) {
|
|
1020
|
+
return apiError(c, e)
|
|
1021
|
+
}
|
|
1022
|
+
})
|
|
1023
|
+
|
|
1024
|
+
// Karma, comments, reviews, shares.
|
|
1025
|
+
const apiKarma = (user: User) => {
|
|
1026
|
+
const k = service.karma.summary(user.id)
|
|
1027
|
+
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` }
|
|
1028
|
+
}
|
|
1029
|
+
app.get('/api/v1/karma', (c) => {
|
|
1030
|
+
const user = apiUser(c)
|
|
1031
|
+
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)
|
|
1032
|
+
})
|
|
1033
|
+
app.get('/api/v1/leaderboard', async (c) => {
|
|
1034
|
+
const which = c.req.query('board') === 'invites' ? 'invites' : 'karma'
|
|
1035
|
+
const period = (['week', 'month', 'all'] as const).find((x) => x === c.req.query('period')) ?? 'week'
|
|
1036
|
+
const limit = Math.min(100, Math.max(1, Number(c.req.query('limit') ?? 25) || 25))
|
|
1037
|
+
const top = await board.top({ board: which, period, limit })
|
|
1038
|
+
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': '*' })
|
|
1039
|
+
})
|
|
1040
|
+
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}` })
|
|
1041
|
+
app.get('/api/v1/products/:slug/comments', (c) => {
|
|
1042
|
+
const p = live(c.req.param('slug'))
|
|
1043
|
+
if (!p) return c.json({ error: 'no such product' }, 404)
|
|
1044
|
+
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': '*' })
|
|
552
1045
|
})
|
|
553
|
-
app.
|
|
1046
|
+
app.post('/api/v1/products/:slug/comments', async (c) => {
|
|
554
1047
|
const user = apiUser(c)
|
|
555
1048
|
if (!user) return c.json(NEEDS_ACCOUNT, 401)
|
|
556
|
-
|
|
1049
|
+
const p = live(c.req.param('slug'))
|
|
1050
|
+
if (!p) return c.json({ error: 'no such product' }, 404)
|
|
1051
|
+
if (!commentLimit.allow(`comment:${user.id}`)) return c.json({ error: 'too many comments; wait a few minutes' }, 429)
|
|
1052
|
+
const b = await body(c)
|
|
1053
|
+
try {
|
|
1054
|
+
const made = service.comment(p, user, String(b.body ?? b.text ?? ''), b.parent ? String(b.parent) : null)
|
|
1055
|
+
const view = discussion(p, user).comments.find((x) => x.id === made.id)!
|
|
1056
|
+
return c.json({ comment: apiComment(p, view), karma: store.karmaTotal(user.id) }, 201)
|
|
1057
|
+
} catch (e) {
|
|
1058
|
+
return apiError(c, e)
|
|
1059
|
+
}
|
|
1060
|
+
})
|
|
1061
|
+
app.delete('/api/v1/comments/:id', (c) => {
|
|
1062
|
+
const admin = isAdmin(c)
|
|
1063
|
+
const user = apiUser(c)
|
|
1064
|
+
if (!admin && !user) return c.json(NEEDS_ACCOUNT, 401)
|
|
1065
|
+
try {
|
|
1066
|
+
service.deleteComment(c.req.param('id'), user, admin)
|
|
1067
|
+
return c.json({ deleted: true })
|
|
1068
|
+
} catch (e) {
|
|
1069
|
+
return apiError(c, e)
|
|
1070
|
+
}
|
|
1071
|
+
})
|
|
1072
|
+
const apiReview = (x: ReviewView) => ({ rating: x.rating, body: x.body, author: x.author, karma: x.karma, at: new Date(x.at).toISOString() })
|
|
1073
|
+
app.get('/api/v1/products/:slug/reviews', (c) => {
|
|
1074
|
+
const p = live(c.req.param('slug'))
|
|
1075
|
+
if (!p) return c.json({ error: 'no such product' }, 404)
|
|
1076
|
+
return c.json({ slug: p.slug, rating: p.rating, count: p.reviews, reviews: discussion(p, null).reviews.map(apiReview) }, 200, { 'access-control-allow-origin': '*' })
|
|
1077
|
+
})
|
|
1078
|
+
app.post('/api/v1/products/:slug/reviews', async (c) => {
|
|
1079
|
+
const user = apiUser(c)
|
|
1080
|
+
if (!user) return c.json(NEEDS_ACCOUNT, 401)
|
|
1081
|
+
const p = live(c.req.param('slug'))
|
|
1082
|
+
if (!p) return c.json({ error: 'no such product' }, 404)
|
|
1083
|
+
const b = await body(c)
|
|
1084
|
+
try {
|
|
1085
|
+
const r = service.review(p, user, b.rating, b.body ?? b.text ?? '')
|
|
1086
|
+
const now = store.product(p.id)!
|
|
1087
|
+
return c.json({ review: { rating: r.rating, body: r.body }, product: { rating: now.rating, count: now.reviews }, karma: store.karmaTotal(user.id) }, 201)
|
|
1088
|
+
} catch (e) {
|
|
1089
|
+
return apiError(c, e)
|
|
1090
|
+
}
|
|
1091
|
+
})
|
|
1092
|
+
app.delete('/api/v1/products/:slug/reviews', (c) => {
|
|
1093
|
+
const user = apiUser(c)
|
|
1094
|
+
if (!user) return c.json(NEEDS_ACCOUNT, 401)
|
|
1095
|
+
const p = store.bySlug(c.req.param('slug'))
|
|
1096
|
+
if (!p) return c.json({ error: 'no such product' }, 404)
|
|
1097
|
+
return c.json({ deleted: service.deleteReview(p, user.id) })
|
|
1098
|
+
})
|
|
1099
|
+
app.post('/api/v1/products/:slug/share', async (c) => {
|
|
1100
|
+
const p = live(c.req.param('slug'))
|
|
1101
|
+
if (!p) return c.json({ error: 'no such product' }, 404)
|
|
1102
|
+
const b = await body(c)
|
|
1103
|
+
try {
|
|
1104
|
+
if (!shareLimit.allow(`share:${ip(c)}`)) return c.json({ counted: false })
|
|
1105
|
+
return c.json(service.share(p, apiUser(c), String(b.network ?? c.req.query('net') ?? '')))
|
|
1106
|
+
} catch (e) {
|
|
1107
|
+
return apiError(c, e)
|
|
1108
|
+
}
|
|
557
1109
|
})
|
|
558
1110
|
|
|
559
1111
|
app.get('/api/v1/makers/:id', (c) => {
|
|
@@ -613,13 +1165,12 @@ export function configFromEnv(env: Record<string, string | undefined> = process.
|
|
|
613
1165
|
env.COINPAY_API_KEY && env.COINPAY_BUSINESS_ID
|
|
614
1166
|
? { 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
1167
|
: null
|
|
616
|
-
const chains = (env.COINPAY_CHAINS ?? '').split(/[\s,]+/).filter(Boolean)
|
|
617
1168
|
return {
|
|
618
1169
|
origin: (env.BUILTINDIE_ORIGIN ?? PUBLIC_ORIGIN).replace(/\/+$/, ''),
|
|
619
1170
|
coinpay,
|
|
620
1171
|
featureUsd: Number(env.FEATURE_USD ?? 19),
|
|
621
1172
|
featureDays: Number(env.FEATURE_DAYS ?? 7),
|
|
622
|
-
|
|
1173
|
+
karmaPerCredit: Number(env.KARMA_PER_CREDIT ?? 500) || 500,
|
|
623
1174
|
screenshots: env.BUILTINDIE_SCREENSHOTS !== 'off',
|
|
624
1175
|
}
|
|
625
1176
|
}
|
|
@@ -631,13 +1182,20 @@ export function serve(options: { port?: number; hostname?: string } = {}) {
|
|
|
631
1182
|
const media = new Media(env.BUILTINDIE_MEDIA ?? `${dataDir}/media`)
|
|
632
1183
|
const service = new BuiltIndie(store, media, { ...configFromEnv(env), claimSecret: env.BUILTINDIE_KEY })
|
|
633
1184
|
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 })
|
|
1185
|
+
const app = createApp({ service, auth: new Auth(store, mailer), adminKey: env.BUILTINDIE_ADMIN_KEY, mailer, coinpayOAuth: coinpayOAuthFromEnv(env) })
|
|
635
1186
|
const relays = (env.BUILTINDIE_RELAY_IPS ?? '23.95.228.174,127.0.0.1,::1').split(/[\s,]+/).filter(Boolean)
|
|
636
1187
|
startRingRefresh()
|
|
1188
|
+
startPitFooterRefresh('built.indie')
|
|
637
1189
|
// Drafts nobody launched within a day go, pictures too.
|
|
638
1190
|
const sweep = () => void Promise.all(store.sweepDrafts().map((id) => media.remove(id))).catch(() => {})
|
|
639
1191
|
sweep()
|
|
640
1192
|
setInterval(sweep, 3600_000).unref?.()
|
|
1193
|
+
// Badges placed since launch only count once someone presses Verify; look again every 6 hours, a few at a time.
|
|
1194
|
+
const reverify = async () => {
|
|
1195
|
+
for (const p of store.unverified()) await service.verify(p).catch(() => false)
|
|
1196
|
+
}
|
|
1197
|
+
setTimeout(() => void reverify(), 60_000).unref?.()
|
|
1198
|
+
setInterval(() => void reverify(), 6 * 3600_000).unref?.()
|
|
641
1199
|
return Bun.serve({
|
|
642
1200
|
port: options.port ?? Number(env.PORT ?? 8798),
|
|
643
1201
|
hostname: options.hostname ?? env.HOST ?? '127.0.0.1',
|
|
@@ -651,5 +1209,6 @@ export function serve(options: { port?: number; hostname?: string } = {}) {
|
|
|
651
1209
|
|
|
652
1210
|
if (import.meta.main) {
|
|
653
1211
|
const server = serve()
|
|
1212
|
+
startRingFooterRefresh()
|
|
654
1213
|
console.log(`built.indie listening on http://${server.hostname}:${server.port}`)
|
|
655
1214
|
}
|