@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/service.ts
CHANGED
|
@@ -5,22 +5,47 @@
|
|
|
5
5
|
* submit URL in, a listing out: everything fetched, pictures saved. Free.
|
|
6
6
|
* Anonymous launches go live at once; a signed-in submitter checks a draft first.
|
|
7
7
|
* publish the draft goes live on today's board
|
|
8
|
-
* claim the domain's owner proves it
|
|
8
|
+
* claim the domain's owner proves it (a DNS TXT record, or the badge with their token) and takes the listing
|
|
9
9
|
* refetch read the homepage again (the maker changed their logo)
|
|
10
10
|
* verify the maker put our badge on their site: a followed link back
|
|
11
|
-
* feature pay
|
|
11
|
+
* feature pay on CoinPay's hosted checkout (any coin or PayPal) to sit on top for a week
|
|
12
|
+
* vote, comment, review, share what members do on a listing; each pays karma (karma.ts)
|
|
13
|
+
* redeem a credit code (@profullstack/redeem-credits) adds Featured days to a member's balance
|
|
14
|
+
* invites 3 single-use invite codes each (@profullstack/affiliates); a new member who
|
|
15
|
+
* joins through one gives both of them INVITE_BONUS_DAYS Featured days
|
|
12
16
|
*
|
|
13
17
|
* Products are described in the LogicSRC open specs: each one is an OpenSite
|
|
14
18
|
* record (logicsrc.com/docs/opensite) and an OpenProfile.md with Kind
|
|
15
19
|
* organization (logicsrc.com/docs/openprofile); a maker is an OpenProfile.md
|
|
16
20
|
* of their own, which we read and link to.
|
|
17
21
|
*/
|
|
18
|
-
import { createHmac,
|
|
22
|
+
import { createHmac, randomBytes } from 'node:crypto'
|
|
19
23
|
import { Resolver } from 'node:dns/promises'
|
|
20
24
|
import { keyedSection, listSection, makeOpenProfile, renderOpenProfile } from '@profullstack/openprofile'
|
|
21
25
|
import { autofetch, clip, FetchError, normalizeUrl, readProfile, safeFetch, SOCIAL_KINDS, videoFrom, type FetchOptions, type SiteInfo, type Video } from './fetch.js'
|
|
22
26
|
import { Media, screenshot } from './media.js'
|
|
23
|
-
import { dayOf, makerIdFor, newId, Store, type Product, type User } from './db.js'
|
|
27
|
+
import { dayOf, makerIdFor, newId, Store, type Comment, type Product, type Review, type User } from './db.js'
|
|
28
|
+
import { Karma, SHARE_NETWORKS } from './karma.js'
|
|
29
|
+
import { cleanTags } from './tags.js'
|
|
30
|
+
import { validEmail } from './auth.js'
|
|
31
|
+
import { CoinPay, CoinPayError, readWebhook, verifyWebhook } from '@profullstack/coinpay-onboard'
|
|
32
|
+
import { Credits, sqliteStore as creditStore } from '@profullstack/redeem-credits'
|
|
33
|
+
import { Affiliates, sqliteStore as affiliateStore } from '@profullstack/affiliates'
|
|
34
|
+
|
|
35
|
+
/** The unit a credit code grants: days a listing sits in the Featured slot. */
|
|
36
|
+
export const FEATURED_DAYS = 'featured_days'
|
|
37
|
+
/** Single-use invite codes each member can hand out. Scarce on purpose: an invite should mean something. */
|
|
38
|
+
export const INVITES_PER_PERSON = 3
|
|
39
|
+
/** Featured days both people get when an invite code is used by someone new. */
|
|
40
|
+
export const INVITE_BONUS_DAYS = 2
|
|
41
|
+
/** What a refused credit code means, in words. */
|
|
42
|
+
const REDEEM_REASONS: Record<string, string> = {
|
|
43
|
+
invalid: 'That code is not valid.',
|
|
44
|
+
expired: 'That code has expired.',
|
|
45
|
+
used_up: 'That code has been used.',
|
|
46
|
+
already_redeemed: 'You already redeemed that code.',
|
|
47
|
+
revoked: 'That code was withdrawn.',
|
|
48
|
+
}
|
|
24
49
|
|
|
25
50
|
export class InputError extends Error {
|
|
26
51
|
constructor(message: string, readonly field?: string) {
|
|
@@ -28,19 +53,20 @@ export class InputError extends Error {
|
|
|
28
53
|
}
|
|
29
54
|
}
|
|
30
55
|
|
|
31
|
-
export type CoinPayConfig = { baseUrl: string; apiKey: string; businessId: string; webhookSecret: string }
|
|
56
|
+
export type CoinPayConfig = { baseUrl: string; apiKey: string; businessId: string; webhookSecret: string; /** Tests pass their own. */ fetch?: typeof fetch }
|
|
32
57
|
export type Config = {
|
|
33
58
|
origin: string
|
|
34
59
|
coinpay: CoinPayConfig | null
|
|
35
60
|
featureUsd: number
|
|
36
61
|
featureDays: number
|
|
37
|
-
chains: string[]
|
|
38
62
|
fetch?: FetchOptions
|
|
39
63
|
screenshots?: boolean
|
|
40
64
|
/** Secret behind each person's claim token. */
|
|
41
65
|
claimSecret?: string
|
|
42
66
|
/** TXT lookups; tests pass their own. */
|
|
43
67
|
resolveTxt?: (name: string) => Promise<string[][]>
|
|
68
|
+
/** Karma per free Featured week (KARMA_PER_CREDIT, default 500). */
|
|
69
|
+
karmaPerCredit?: number
|
|
44
70
|
}
|
|
45
71
|
|
|
46
72
|
/** The record an owner adds: `_built-indie.<domain> TXT "built-indie-verify=<token>"` (or the same on the domain itself). */
|
|
@@ -51,38 +77,89 @@ export const CLAIM_PREFIX = 'built-indie-verify='
|
|
|
51
77
|
* TXT records as public resolvers see them: every answer from 1.1.1.1 and 9.9.9.9, merged.
|
|
52
78
|
* One Resolver per server on purpose: Bun 1.4's node:dns Resolver given two servers
|
|
53
79
|
* answers ENOTFOUND for a record each of them serves alone, so no claim could succeed.
|
|
80
|
+
* And fresh Resolvers per lookup: a long-lived one caches NXDOMAIN for the zone's SOA
|
|
81
|
+
* minimum (often 30 min), so a maker who checks before adding the record stays refused.
|
|
54
82
|
*/
|
|
55
|
-
const
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
83
|
+
const publicDns = () =>
|
|
84
|
+
['1.1.1.1', '9.9.9.9'].map((server) => {
|
|
85
|
+
const r = new Resolver({ timeout: 4000, tries: 2 })
|
|
86
|
+
r.setServers([server])
|
|
87
|
+
return r
|
|
88
|
+
})
|
|
89
|
+
export async function publicTxt(name: string, resolvers: Pick<Resolver, 'resolveTxt'>[] = publicDns()): Promise<string[][]> {
|
|
61
90
|
const answers = await Promise.all(resolvers.map((r) => r.resolveTxt(name).catch(() => [] as string[][])))
|
|
62
91
|
return answers.flat()
|
|
63
92
|
}
|
|
64
93
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
BTC: 'Bitcoin', ETH: 'Ether', SOL: 'Solana', POL: 'Polygon', DOGE: 'Dogecoin', BCH: 'Bitcoin Cash', XRP: 'XRP', ADA: 'Cardano', BNB: 'BNB',
|
|
68
|
-
}
|
|
69
|
-
export const DEFAULT_CHAINS = Object.keys(CHAIN_LABELS)
|
|
70
|
-
export const SETTLED = new Set(['paid', 'completed', 'confirmed', 'forwarded', 'succeeded', 'settled'])
|
|
94
|
+
/** Who is paying for a Featured slot: the email is required by CoinPay; the account is the member id, or the email for a guest. */
|
|
95
|
+
export type Buyer = { email: string; accountId?: string | null }
|
|
71
96
|
|
|
72
97
|
export type Edit = Partial<{ name: string; tagline: string; description: string; tags: string[] | string; makerName: string; makerProfile: string; videos: string[] | string; socials: Record<string, string>; remove: string[] | string }>
|
|
73
98
|
|
|
74
99
|
const list = (v: string[] | string | undefined) => (Array.isArray(v) ? v : String(v ?? '').split(/[\n,]+/)).map((s) => s.trim()).filter(Boolean)
|
|
75
100
|
|
|
76
101
|
export class BuiltIndie {
|
|
77
|
-
|
|
102
|
+
readonly karma: Karma
|
|
103
|
+
/** Redeemable credit codes: Featured days, in the app's own database (credit_* tables). */
|
|
104
|
+
readonly credits: Credits
|
|
105
|
+
/** Scarce invite codes (aff_* tables). The open per-member link stays the app's own invite program. */
|
|
106
|
+
readonly affiliates: Affiliates
|
|
107
|
+
constructor(readonly store: Store, readonly media: Media, readonly config: Config) {
|
|
108
|
+
this.karma = new Karma(store, config.karmaPerCredit ?? 500)
|
|
109
|
+
this.credits = new Credits(creditStore(store.db as never))
|
|
110
|
+
this.affiliates = new Affiliates(affiliateStore(store.db as never), { defaultQuota: INVITES_PER_PERSON })
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Featured days this member holds (from credit codes and invite bonuses), not yet spent on a listing. */
|
|
114
|
+
featuredDays(user: Pick<User, 'id'>): Promise<number> {
|
|
115
|
+
return this.credits.balance(user.id, FEATURED_DAYS)
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Redeem a credit code for the signed-in member. Returns what it added, in words; a refused code throws. */
|
|
119
|
+
async redeem(user: Pick<User, 'id'>, code: string): Promise<{ message: string; days: number }> {
|
|
120
|
+
if (!code.trim()) throw new InputError('Enter a code.', 'code')
|
|
121
|
+
const r = await this.credits.redeem(code, user.id)
|
|
122
|
+
if (!r.ok) throw new InputError(REDEEM_REASONS[r.reason] ?? 'That code did not work.', 'code')
|
|
123
|
+
const g = r.grant
|
|
124
|
+
const what = g.unit === FEATURED_DAYS ? `${g.amount} Featured day${g.amount === 1 ? '' : 's'}` : `${g.amount} ${g.unit}`
|
|
125
|
+
const message = g.every === 'month' ? `Added ${what} every month${g.months ? ` for ${g.months} months` : ''}.` : `Added ${what}.`
|
|
126
|
+
return { message: `${message} Use ${g.amount === 1 ? 'it' : 'them'} on any live listing you own.`, days: await this.featuredDays(user) }
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Spend Featured days from the balance on a live listing: the same featured_until a paid week extends. */
|
|
130
|
+
async spendFeaturedDays(p: Product, user: User, days: number): Promise<Product> {
|
|
131
|
+
if (p.status !== 'live') throw new InputError('Only a live listing can be featured.')
|
|
132
|
+
if (!this.store.canEdit(p, user)) throw new InputError('You can only feature a listing you submitted or own.')
|
|
133
|
+
const want = Math.floor(Number(days))
|
|
134
|
+
if (!Number.isFinite(want) || want < 1) throw new InputError('Pick at least one day.', 'days')
|
|
135
|
+
const r = await this.credits.spend(user.id, FEATURED_DAYS, want, `featured ${p.slug}`)
|
|
136
|
+
if (!r.ok) throw new InputError(r.balance ? `You have ${r.balance} Featured day${r.balance === 1 ? '' : 's'} left.` : 'You have no Featured days. Redeem a code or use an invite.', 'days')
|
|
137
|
+
this.store.extendFeatured(p.id, want)
|
|
138
|
+
return this.store.product(p.id)!
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Someone new signed up through a scarce invite code: the code is spent, both get
|
|
143
|
+
* INVITE_BONUS_DAYS Featured days, and the invite counts in the invite program
|
|
144
|
+
* (the same invites row an invite link makes, so a sign-up is never counted twice).
|
|
145
|
+
*/
|
|
146
|
+
async acceptInvite(code: string, user: User): Promise<{ ok: boolean; reason?: string; inviterId?: string }> {
|
|
147
|
+
const r = await this.affiliates.redeemInvite(code, user.id)
|
|
148
|
+
if (!r.ok) return { ok: false, reason: r.reason }
|
|
149
|
+
const bonus = { unit: FEATURED_DAYS, amount: INVITE_BONUS_DAYS } as const
|
|
150
|
+
await this.credits.grant(user.id, bonus, `invited by ${r.inviterId}`)
|
|
151
|
+
await this.credits.grant(r.inviterId, bonus, `invited ${user.id}`)
|
|
152
|
+
this.store.recordInvite(user.id, this.store.inviteCode(r.inviterId))
|
|
153
|
+
return { ok: true, inviterId: r.inviterId }
|
|
154
|
+
}
|
|
78
155
|
|
|
79
156
|
/** A product page's public address. */
|
|
80
157
|
link(p: Pick<Product, 'slug'>): string {
|
|
81
158
|
return `${this.config.origin}/p/${p.slug}`
|
|
82
159
|
}
|
|
83
160
|
|
|
84
|
-
/** Read the URL and make a draft. A URL already live comes back as `existing` instead. */
|
|
85
|
-
async submit(input: { url: string; makerName?: string; makerProfile?: string }, submitter: string, user: User | null = null): Promise<{ product: Product; existing: boolean }> {
|
|
161
|
+
/** Read the URL and make a draft. A URL already live comes back as `existing` instead. `unlimited` (the admin) skips the hourly cap. */
|
|
162
|
+
async submit(input: { url: string; makerName?: string; makerProfile?: string }, submitter: string, user: User | null = null, unlimited = false): Promise<{ product: Product; existing: boolean }> {
|
|
86
163
|
let url: URL
|
|
87
164
|
try {
|
|
88
165
|
url = normalizeUrl(input.url)
|
|
@@ -91,7 +168,7 @@ export class BuiltIndie {
|
|
|
91
168
|
}
|
|
92
169
|
const already = this.store.byUrl(url.href)
|
|
93
170
|
if (already?.status === 'live') return { product: already, existing: true }
|
|
94
|
-
if (this.store.submissionsFrom(submitter, Date.now() - 3600_000) >= 10) throw new InputError('That is a lot of launches in an hour. Try again later.')
|
|
171
|
+
if (!unlimited && this.store.submissionsFrom(submitter, Date.now() - 3600_000) >= 10) throw new InputError('That is a lot of launches in an hour. Try again later.')
|
|
95
172
|
const info = await this.read(url.href)
|
|
96
173
|
const id = newId()
|
|
97
174
|
const pictures = await this.pictures(id, info)
|
|
@@ -129,22 +206,88 @@ export class BuiltIndie {
|
|
|
129
206
|
return createHmac('sha256', this.config.claimSecret ?? 'built.indie').update(`claim:${user.id}:${p.domain}`).digest('base64url').slice(0, 24)
|
|
130
207
|
}
|
|
131
208
|
|
|
132
|
-
/**
|
|
133
|
-
|
|
209
|
+
/**
|
|
210
|
+
* Two proofs, either one claims it: the TXT record at _built-indie.<domain> (or on the
|
|
211
|
+
* domain itself, and its apex for www), or, failing DNS, our badge on the homepage
|
|
212
|
+
* carrying this person's badge id (`/images.svg?id=<id>`, the easy way). Kept for
|
|
213
|
+
* badges already placed: `/badge/<slug>.svg?claim=<token>`, or the bare
|
|
214
|
+
* `built-indie-verify=<token>` anywhere in the HTML, e.g. a meta tag. A badge that
|
|
215
|
+
* claims also earns the followed link and the ✓, as `verify` would.
|
|
216
|
+
*/
|
|
217
|
+
async claim(p: Product, user: User): Promise<{ ok: boolean; looked: string[]; method?: 'dns' | 'badge' }> {
|
|
134
218
|
if (p.status === 'hidden') throw new InputError('This product was taken down.')
|
|
135
219
|
if (p.ownerId === user.id) return { ok: true, looked: [] }
|
|
136
|
-
const
|
|
220
|
+
const token = this.claimToken(p, user)
|
|
221
|
+
const want = `${CLAIM_PREFIX}${token}`
|
|
137
222
|
const bare = p.domain.replace(/^www\./, '')
|
|
138
223
|
const names = [...new Set([`${CLAIM_LABEL}.${bare}`, bare, p.domain])]
|
|
224
|
+
const looked = [...names, p.url]
|
|
139
225
|
const lookup = this.config.resolveTxt ?? publicTxt
|
|
140
226
|
for (const name of names) {
|
|
141
227
|
const records = await lookup(name).catch(() => [] as string[][])
|
|
142
228
|
if (records.some((chunks) => chunks.join('').trim() === want)) {
|
|
143
|
-
this.
|
|
144
|
-
return { ok: true, looked:
|
|
229
|
+
this.claimed(p, user, false)
|
|
230
|
+
return { ok: true, looked, method: 'dns' }
|
|
145
231
|
}
|
|
146
232
|
}
|
|
147
|
-
|
|
233
|
+
const html = await this.homepage(p)
|
|
234
|
+
const own = this.badgeIds(html).some((id) => {
|
|
235
|
+
const b = this.store.badge(id)
|
|
236
|
+
return b?.productId === p.id && b.userId === user.id
|
|
237
|
+
})
|
|
238
|
+
if (own) {
|
|
239
|
+
this.claimed(p, user, true)
|
|
240
|
+
return { ok: true, looked, method: 'badge' }
|
|
241
|
+
}
|
|
242
|
+
if (this.hasClaimBadge(html, token)) {
|
|
243
|
+
this.claimed(p, user, this.linksBack(html))
|
|
244
|
+
return { ok: true, looked, method: 'badge' }
|
|
245
|
+
}
|
|
246
|
+
return { ok: false, looked }
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/** The claim took: ownership, karma for it, and for the badge when it was the proof. */
|
|
250
|
+
private claimed(p: Product, user: User, verified: boolean): void {
|
|
251
|
+
this.store.claim(p.id, user.id)
|
|
252
|
+
if (verified) this.store.setVerified(p.id, Date.now())
|
|
253
|
+
const now = this.store.product(p.id)!
|
|
254
|
+
if (now.status === 'live') this.karma.claimed(now, user.id)
|
|
255
|
+
if (verified && now.status === 'live') this.karma.badgeVerified(now)
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/** The product's homepage HTML, read the safe way (no private addresses, robots honoured); '' if it will not load. */
|
|
259
|
+
private async homepage(p: Product): Promise<string> {
|
|
260
|
+
const r = await safeFetch(p.url, { ...this.config.fetch, accept: 'text/html' }).catch(() => null)
|
|
261
|
+
return r && r.status < 300 ? new TextDecoder().decode(r.body) : ''
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
private ourHosts(): string {
|
|
265
|
+
return `(?:${new URL(this.config.origin).hostname.replace(/\./g, '\\.')}|built\\.indie)`
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/** A followed-link-worthy link back to us. */
|
|
269
|
+
private linksBack(html: string): boolean {
|
|
270
|
+
return new RegExp(`href=["']?https?://${this.ourHosts()}(/|["'])`, 'i').test(html)
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/** Every link or image URL in the page that points at our host. */
|
|
274
|
+
private ourUrls(html: string): string[] {
|
|
275
|
+
if (!html) return []
|
|
276
|
+
return [...html.matchAll(new RegExp(`(?:href|src)\\s*=\\s*["']?((?:https?:)?//${this.ourHosts()}/[^"'\\s>]*)`, 'gi'))].map((m) => m[1]!)
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/** The ids of every `/images.svg?id=<id>` badge on our host in the page. */
|
|
280
|
+
badgeIds(html: string): string[] {
|
|
281
|
+
const ids = this.ourUrls(html).map((u) => u.match(/\/images\.svg\?(?:[^#]*?(?:&|&))?id=([A-Za-z0-9_-]+)/)?.[1])
|
|
282
|
+
return [...new Set(ids.filter((id): id is string => Boolean(id)))]
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/** A link or image on our host whose URL carries claim=<token>, or the bare verify string anywhere. */
|
|
286
|
+
private hasClaimBadge(html: string, token: string): boolean {
|
|
287
|
+
if (!html) return false
|
|
288
|
+
const exact = (prefix: string) => new RegExp(`${prefix}${token}(?![A-Za-z0-9_-])`)
|
|
289
|
+
if (exact(CLAIM_PREFIX).test(html)) return true
|
|
290
|
+
return this.ourUrls(html).some((u) => exact('[?&](?:amp;)?claim=').test(u))
|
|
148
291
|
}
|
|
149
292
|
|
|
150
293
|
private async read(url: string): Promise<SiteInfo> {
|
|
@@ -175,7 +318,86 @@ export class BuiltIndie {
|
|
|
175
318
|
publish(p: Product): Product {
|
|
176
319
|
if (p.status === 'hidden') throw new InputError('This product was taken down.')
|
|
177
320
|
if (!p.name.trim()) throw new InputError('Give it a name', 'name')
|
|
178
|
-
|
|
321
|
+
const live = this.store.update(p.id, { status: 'live' })!
|
|
322
|
+
this.karma.launched(live)
|
|
323
|
+
// A claim made while it was a draft pays once it is live.
|
|
324
|
+
if (live.ownerId) this.karma.claimed(live, live.ownerId)
|
|
325
|
+
// An invited member's first live listing earns whoever invited them a free Featured week.
|
|
326
|
+
const inviter = p.submitterUser ? this.store.earnInvite(p.submitterUser, p.id) : null
|
|
327
|
+
if (inviter) {
|
|
328
|
+
this.karma.inviteConverted(inviter, p.id)
|
|
329
|
+
this.onInviteEarned?.(inviter, live)
|
|
330
|
+
}
|
|
331
|
+
return live
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// What members do on a listing. Anyone may vote; comments and reviews need an account.
|
|
335
|
+
|
|
336
|
+
/** One vote per visitor (their address); a signed-in voter is noted and earns karma. True when it counted. */
|
|
337
|
+
vote(p: Product, voter: string, user: User | null = null): boolean {
|
|
338
|
+
if (p.status !== 'live') throw new InputError('Only a live listing takes votes.')
|
|
339
|
+
if (!this.store.vote(p.id, voter, Date.now(), user?.id ?? null)) return false
|
|
340
|
+
this.karma.voted(p, user?.id ?? null)
|
|
341
|
+
return true
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
comment(p: Product, user: User, text: string, parentId?: string | null): Comment {
|
|
345
|
+
if (p.status !== 'live') throw new InputError('Only a live listing takes comments.')
|
|
346
|
+
const body = String(text ?? '').replace(/\r\n?/g, '\n').replace(/\n{3,}/g, '\n\n').trim().slice(0, 2000)
|
|
347
|
+
if (body.length < 2) throw new InputError('Write something first.', 'body')
|
|
348
|
+
let parent: Comment | null = null
|
|
349
|
+
if (parentId) {
|
|
350
|
+
parent = this.store.comment(parentId)
|
|
351
|
+
if (!parent || parent.productId !== p.id || parent.deletedAt) throw new InputError('That comment is gone.', 'parent')
|
|
352
|
+
// One level of replies: answering a reply answers its thread.
|
|
353
|
+
if (parent.parentId) parent = this.store.comment(parent.parentId)
|
|
354
|
+
}
|
|
355
|
+
const c = this.store.addComment({ productId: p.id, userId: user.id, parentId: parent?.id ?? null, body })
|
|
356
|
+
this.karma.commented(p, user.id, c)
|
|
357
|
+
return c
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/** Delete a comment: its author, or the admin. Takes back the karma it earned. */
|
|
361
|
+
deleteComment(id: string, user: User | null, admin = false): Comment {
|
|
362
|
+
const c = this.store.comment(id)
|
|
363
|
+
if (!c || c.deletedAt) throw new InputError('No such comment.')
|
|
364
|
+
if (!admin && c.userId !== user?.id) throw new InputError('Only its author can delete a comment.')
|
|
365
|
+
this.store.deleteComment(id)
|
|
366
|
+
this.karma.uncommented(id)
|
|
367
|
+
return c
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
review(p: Product, user: User, rating: unknown, text: unknown): Review {
|
|
371
|
+
if (p.status !== 'live') throw new InputError('Only a live listing takes reviews.')
|
|
372
|
+
const stars = Number(rating)
|
|
373
|
+
if (!Number.isInteger(stars) || stars < 1 || stars > 5) throw new InputError('Pick 1 to 5 stars.', 'rating')
|
|
374
|
+
const body = String(text ?? '').replace(/\r\n?/g, '\n').trim().slice(0, 2000)
|
|
375
|
+
const r = this.store.putReview({ productId: p.id, userId: user.id, rating: stars, body })
|
|
376
|
+
this.karma.reviewed(p, user.id, r)
|
|
377
|
+
return r
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
deleteReview(p: Product, userId: string): boolean {
|
|
381
|
+
const gone = this.store.deleteReview(p.id, userId)
|
|
382
|
+
if (gone) this.karma.unreviewed(p, userId)
|
|
383
|
+
return gone
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/** A share button clicked. Pays a signed-in member (once per listing, network and day). */
|
|
387
|
+
share(p: Product, user: User | null, network: string): { counted: boolean } {
|
|
388
|
+
if (p.status !== 'live') throw new InputError('Only a live listing can be shared.')
|
|
389
|
+
if (!(SHARE_NETWORKS as readonly string[]).includes(network)) throw new InputError(`network is one of ${SHARE_NETWORKS.join(', ')}`, 'network')
|
|
390
|
+
return { counted: this.karma.shared(p, user?.id ?? null, network) }
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/** Called when a listing earns its submitter's inviter a credit: the server mails them and refreshes the board. */
|
|
394
|
+
onInviteEarned?: (inviterId: string, product: Product) => void
|
|
395
|
+
|
|
396
|
+
/** Spend one invite credit on a live listing: featured for the usual number of days, free. */
|
|
397
|
+
redeemCredit(p: Product, user: User): Product {
|
|
398
|
+
if (p.status !== 'live') throw new InputError('Only a live listing can be featured.')
|
|
399
|
+
if (!this.store.redeemCredit(user.id, p.id, this.config.featureDays)) throw new InputError('No invite credits left. Invite a maker who launches to earn one.')
|
|
400
|
+
return this.store.product(p.id)!
|
|
179
401
|
}
|
|
180
402
|
|
|
181
403
|
async refetch(p: Product): Promise<Product> {
|
|
@@ -193,7 +415,7 @@ export class BuiltIndie {
|
|
|
193
415
|
}
|
|
194
416
|
if (e.tagline !== undefined) patch.tagline = clip(e.tagline, 90)
|
|
195
417
|
if (e.description !== undefined) patch.description = clip(e.description, 1200)
|
|
196
|
-
if (e.tags !== undefined) patch.tags =
|
|
418
|
+
if (e.tags !== undefined) patch.tags = cleanTags(list(e.tags))
|
|
197
419
|
if (e.makerName !== undefined) patch.makerName = clip(e.makerName, 60)
|
|
198
420
|
if (e.videos !== undefined) {
|
|
199
421
|
const videos: Video[] = []
|
|
@@ -249,13 +471,12 @@ export class BuiltIndie {
|
|
|
249
471
|
return this.store.update(p.id, { makerProfile: r.url.href, makerId: maker.id, makerVerified: verified, makerName: clip(name?.trim() || profile.name, 60) })!
|
|
250
472
|
}
|
|
251
473
|
|
|
252
|
-
/** Has the maker linked back to us from their homepage? */
|
|
474
|
+
/** Has the maker linked back to us from their homepage, or put this listing's badge (any account's id) on it? */
|
|
253
475
|
async verify(p: Product): Promise<boolean> {
|
|
254
|
-
const
|
|
255
|
-
const
|
|
256
|
-
const ours = new URL(this.config.origin).hostname
|
|
257
|
-
const ok = new RegExp(`href=["']?https?://(${ours.replace(/\./g, '\\.')}|built\\.indie)(/|["'])`, 'i').test(html)
|
|
476
|
+
const html = await this.homepage(p)
|
|
477
|
+
const ok = this.linksBack(html) || this.badgeIds(html).some((id) => this.store.badge(id)?.productId === p.id)
|
|
258
478
|
this.store.setVerified(p.id, ok ? Date.now() : null)
|
|
479
|
+
if (ok && p.status === 'live') this.karma.badgeVerified(p)
|
|
259
480
|
return ok
|
|
260
481
|
}
|
|
261
482
|
|
|
@@ -319,61 +540,73 @@ export class BuiltIndie {
|
|
|
319
540
|
)
|
|
320
541
|
}
|
|
321
542
|
|
|
322
|
-
/**
|
|
323
|
-
|
|
543
|
+
/**
|
|
544
|
+
* Start a CoinPay hosted checkout session to feature a product; returns where to send
|
|
545
|
+
* the buyer. The buyer picks any coin or PayPal on CoinPay's page, so there is no
|
|
546
|
+
* currency picker here.
|
|
547
|
+
*/
|
|
548
|
+
async feature(p: Product, buyer: Buyer): Promise<{ checkoutUrl: string; ref: string; sessionId: string; methods: string[] }> {
|
|
324
549
|
const cp = this.config.coinpay
|
|
325
550
|
if (!cp) throw new InputError('Featuring is not set up on this server yet.')
|
|
326
551
|
if (p.status !== 'live') throw new InputError('Publish it first.')
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
552
|
+
const email = validEmail(buyer.email)
|
|
553
|
+
if (!email) throw new InputError('Enter your email: CoinPay sends the receipt there', 'email')
|
|
554
|
+
const accountId = String(buyer.accountId ?? '').trim() || email
|
|
555
|
+
const ref = `fp_${randomBytes(9).toString('base64url')}`
|
|
556
|
+
const link = this.link(p)
|
|
557
|
+
const client = new CoinPay({ token: cp.apiKey, base: cp.baseUrl, fetch: cp.fetch ?? fetch })
|
|
558
|
+
let session
|
|
559
|
+
try {
|
|
560
|
+
session = await client.createCheckoutSession({
|
|
333
561
|
amount: this.config.featureUsd,
|
|
334
562
|
currency: 'USD',
|
|
335
|
-
blockchain: chain,
|
|
336
|
-
payment_method: 'crypto',
|
|
337
563
|
description: `built.indie: feature ${p.name} for ${this.config.featureDays} days`,
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
564
|
+
customerEmail: email,
|
|
565
|
+
accountId,
|
|
566
|
+
property: new URL(this.config.origin || 'https://built.indie.pit.world').hostname,
|
|
567
|
+
metadata: { product_id: p.id, product: 'feature', days: this.config.featureDays, payment_ref: ref },
|
|
568
|
+
successUrl: `${link}?featured=1`,
|
|
569
|
+
cancelUrl: link,
|
|
570
|
+
expiresInMinutes: 60,
|
|
571
|
+
})
|
|
572
|
+
} catch (e) {
|
|
573
|
+
throw new Error(`CoinPay said ${e instanceof CoinPayError ? e.status : 'error'}: ${(e as Error).message.slice(0, 200)}`)
|
|
574
|
+
}
|
|
575
|
+
if (!session?.id || !session.url) throw new Error('CoinPay returned no checkout session')
|
|
349
576
|
// Written before the buyer leaves: a webhook can beat the redirect.
|
|
350
|
-
this.store.addPayment({ ref, productId: p.id, amountUsd: this.config.featureUsd, chain, days: this.config.featureDays })
|
|
351
|
-
return { checkoutUrl:
|
|
577
|
+
this.store.addPayment({ ref, productId: p.id, amountUsd: this.config.featureUsd, chain: 'checkout', days: this.config.featureDays, checkoutSessionId: session.id, email, accountId })
|
|
578
|
+
return { checkoutUrl: session.url, ref, sessionId: session.id, methods: session.methods ?? [] }
|
|
352
579
|
}
|
|
353
580
|
|
|
354
|
-
/**
|
|
355
|
-
|
|
581
|
+
/**
|
|
582
|
+
* CoinPay's webhook: `X-CoinPay-Signature: t=<unix>,v1=<hmac of "t.body">` over the raw
|
|
583
|
+
* bytes; the body is nested, `{ id: evt_..., type, data: { payment_id, status,
|
|
584
|
+
* checkout_session_id, metadata } }`. Paid = payment.confirmed (a PayPal capture has
|
|
585
|
+
* status 'completed') or payment.forwarded; one session can send both, so settling is
|
|
586
|
+
* once per local payment.
|
|
587
|
+
*/
|
|
588
|
+
webhook(raw: string, header: string | undefined, nowSeconds = Date.now() / 1000): { ok: boolean; status?: number; settled?: boolean } {
|
|
356
589
|
const cp = this.config.coinpay
|
|
357
590
|
if (!cp?.webhookSecret || !header) return { ok: false, status: 401 }
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
if (!Number.isFinite(t) || !parts.v1 || Math.abs(nowSeconds - t) > 300) return { ok: false, status: 401 }
|
|
361
|
-
const expected = Buffer.from(createHmac('sha256', cp.webhookSecret).update(`${parts.t}.${raw}`).digest('hex'), 'hex')
|
|
362
|
-
const given = Buffer.from(String(parts.v1), 'hex')
|
|
363
|
-
if (expected.length !== given.length || !timingSafeEqual(expected, given)) return { ok: false, status: 401 }
|
|
364
|
-
let payload: any
|
|
591
|
+
if (!verifyWebhook(raw, header, cp.webhookSecret, nowSeconds * 1000)) return { ok: false, status: 401 }
|
|
592
|
+
let payload: unknown
|
|
365
593
|
try {
|
|
366
594
|
payload = JSON.parse(raw)
|
|
367
595
|
} catch {
|
|
368
596
|
return { ok: false, status: 400 }
|
|
369
597
|
}
|
|
370
|
-
const
|
|
371
|
-
const
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
return { ok: true }
|
|
598
|
+
const ev = readWebhook(payload)
|
|
599
|
+
const row =
|
|
600
|
+
(ev.checkoutSessionId && this.store.paymentBySession(ev.checkoutSessionId)) ||
|
|
601
|
+
(typeof ev.metadata.payment_ref === 'string' && this.store.payment(ev.metadata.payment_ref)) ||
|
|
602
|
+
(ev.paymentId && this.store.payment(ev.paymentId)) ||
|
|
603
|
+
null
|
|
604
|
+
if (!row) return { ok: true }
|
|
605
|
+
if (ev.paymentId) this.store.notePaymentId(row.ref, ev.paymentId, ev.method || null)
|
|
606
|
+
if (ev.paid) return { ok: true, settled: this.store.settle(row.ref) }
|
|
607
|
+
const status = ev.status || ev.type.replace(/^payment\./, '')
|
|
608
|
+
if (status) this.store.setPaymentStatus(row.ref, status)
|
|
609
|
+
return { ok: true, settled: false }
|
|
377
610
|
}
|
|
378
611
|
}
|
|
379
612
|
|
package/src/tags.ts
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tags: one shape for every tag, and topics inferred from a listing's own words
|
|
3
|
+
* when its homepage declares none (no keywords meta, JSON-LD category or
|
|
4
|
+
* opensite.json tags). Inference is a fixed vocabulary, so it is cheap,
|
|
5
|
+
* offline and the same on every run.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export const MAX_TAGS = 6
|
|
9
|
+
|
|
10
|
+
/** Lower case, no leading #, only a-z 0-9 space + # . -, 2 to 24 characters; null if nothing is left. */
|
|
11
|
+
export function normalizeTag(raw: string): string | null {
|
|
12
|
+
const t = String(raw)
|
|
13
|
+
.toLowerCase()
|
|
14
|
+
.replace(/^#/, '')
|
|
15
|
+
.replace(/application$/, '')
|
|
16
|
+
.replace(/[^a-z0-9 +#.-]/g, '')
|
|
17
|
+
.replace(/\s+/g, ' ')
|
|
18
|
+
.trim()
|
|
19
|
+
return t.length >= 2 && t.length <= 24 ? t : null
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Normalized, unique, at most `max`. */
|
|
23
|
+
export function cleanTags(raw: Iterable<string>, max = MAX_TAGS): string[] {
|
|
24
|
+
const out = new Set<string>()
|
|
25
|
+
for (const r of raw) {
|
|
26
|
+
const t = normalizeTag(r)
|
|
27
|
+
if (t) out.add(t)
|
|
28
|
+
if (out.size >= max) break
|
|
29
|
+
}
|
|
30
|
+
return [...out]
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Topic, and the words that mean it. Earlier topics win a tie. */
|
|
34
|
+
const TOPICS: [string, RegExp][] = [
|
|
35
|
+
['ai', /\b(ai|llms?|gpt|claude|codex|opencode|agentic|ai agents?|open models?|whisper(\.cpp)?)\b/g],
|
|
36
|
+
['agents', /\b(agents?|agentic)\b/g],
|
|
37
|
+
['developer tools', /\b(developers?|cli|apis?|sdk|codebase|coding|git|webhooks?|npm|registry|registries|ci\/?cd)\b/g],
|
|
38
|
+
['game', /\b(games?|gaming|gamers?|play|shooter|multiplayer|co-op|campaign)\b/g],
|
|
39
|
+
['tactical', /\b(operatives?|tactical|combat|military|war)\b/g],
|
|
40
|
+
['sports', /\b(sports?|youth-sports|athletes?|league|fixtures|teams? in the world)\b/g],
|
|
41
|
+
['crypto', /\b(crypto|bitcoin|btc|eth|usdc|sol|stablecoins?|lightning|web3|wallets?|coinpay)\b/g],
|
|
42
|
+
['payments', /\b(payments?|checkout|pay link|get paid|pays you|invoices?|billing|pay by the)\b/g],
|
|
43
|
+
['sms', /\b(sms|texts?|texting|phone numbers?|sim( cards?)?|carrier)\b/g],
|
|
44
|
+
['verification', /\b(otp|2fa|verification( codes?)?|one-time codes?)\b/g],
|
|
45
|
+
['email', /\b(e-?mail|newsletters?|inbox)\b/g],
|
|
46
|
+
['voice', /\b(voice|calls?|dials?|phone tree|on hold)\b/g],
|
|
47
|
+
['customer support', /\b(customer support|phone tree|helpdesk|support tickets?)\b/g],
|
|
48
|
+
['video', /\b(videos?|reels?|films?|demo video|highlight)\b/g],
|
|
49
|
+
['tv', /\b(iptv|tv( guide)?|live-tv|channels?|m3u|xtream)\b/g],
|
|
50
|
+
['music', /\b(music|spotify|albums?|songs?|genres?)\b/g],
|
|
51
|
+
['audio', /\b(audio|podcasts?|recordings?|transcripts?|transcri\w+)\b/g],
|
|
52
|
+
['news', /\b(news|newsrooms?|headlines|journalism|the desk)\b/g],
|
|
53
|
+
['alerts', /\b(reminders?|alerts?|notifications?|get told|never miss|before it drops)\b/g],
|
|
54
|
+
['jobs', /\b(jobs?|hiring|freelanc\w*|gigs?|careers?)\b/g],
|
|
55
|
+
['health', /\b(health|telehealth|medical|patients?|clinics?|prescriptions?|labs)\b/g],
|
|
56
|
+
['food', /\b(recipes?|cookbooks?|food|cooking|ingredients)\b/g],
|
|
57
|
+
['cannabis', /\b(cannabis|dispensar(y|ies)|weed)\b/g],
|
|
58
|
+
['directory', /\b(directory|directories|listings?|search engine|compared)\b/g],
|
|
59
|
+
['data', /\b(database|datasets?|structured data|feeds?|json|real-time public data)\b/g],
|
|
60
|
+
['marketing', /\b(ads|advertis\w*|promot\w*|seo|marketing|launch sites|submissions?)\b/g],
|
|
61
|
+
['automation', /\b(automat\w*|workflows?|deterministic)\b/g],
|
|
62
|
+
['open source', /\b(open-source|open source|foss|self-host\w*)\b/g],
|
|
63
|
+
['security', /\b(security|malware|encrypt\w*|vulnerab\w*|privacy|audit)\b/g],
|
|
64
|
+
['photos', /\b(photos?|images?|pictures?|backgrounds?|camera)\b/g],
|
|
65
|
+
['terminal', /\b(terminal|ssh|tmux|bbs|bulletin board)\b/g],
|
|
66
|
+
['no-code', /\b(no-code|your own words|describe what you want)\b/g],
|
|
67
|
+
['open standards', /\b(standards|schemas?|specs?|conventions|primitives)\b/g],
|
|
68
|
+
['shopping', /\b(shop|buy|marketplace|e-?commerce|store)\b/g],
|
|
69
|
+
['art', /\b(tattoos?|artistry|artist)\b/g],
|
|
70
|
+
['automotive', /\b(auto detailing|cars?|automotive)\b/g],
|
|
71
|
+
['cigars', /\b(cigars?|spliffs?|tobacco)\b/g],
|
|
72
|
+
['events', /\b(events?|meetups?|herf|every other sunday)\b/g],
|
|
73
|
+
['productivity', /\b(productivity|organi[sz]e|searchable|right folder)\b/g],
|
|
74
|
+
]
|
|
75
|
+
|
|
76
|
+
/** Up to `max` topics the text is about, most mentioned first. */
|
|
77
|
+
export function inferTags(text: string, max = 4): string[] {
|
|
78
|
+
const t = ` ${text.toLowerCase()} `
|
|
79
|
+
return TOPICS.map(([tag, re], i) => ({ tag, i, n: t.match(re)?.length ?? 0 }))
|
|
80
|
+
.filter((s) => s.n > 0)
|
|
81
|
+
// "never miss a game" is a match, not a video game.
|
|
82
|
+
.filter((s, _, all) => s.tag !== 'game' || !all.some((o) => o.tag === 'sports'))
|
|
83
|
+
.sort((a, b) => b.n - a.n || a.i - b.i)
|
|
84
|
+
.slice(0, max)
|
|
85
|
+
.map((s) => s.tag)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** What a listing is tagged with: its declared tags, else topics from its name, tagline and description. */
|
|
89
|
+
export function tagsFor(declared: string[], text: { name?: string; tagline?: string; description?: string }): string[] {
|
|
90
|
+
const own = cleanTags(declared)
|
|
91
|
+
return own.length ? own : inferTags([text.name, text.tagline, text.description].filter(Boolean).join('. '))
|
|
92
|
+
}
|