@profullstack/builtindie 0.0.0-stage → 0.1.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/OpenStack.md +74 -0
- package/README.md +63 -2
- package/bin/builtindie +3 -0
- package/package.json +35 -4
- package/public/app.js +35 -0
- package/public/favicon.svg +1 -0
- package/public/icon-192.png +0 -0
- package/public/icon-512.png +0 -0
- package/public/icons/openicon.json +574 -0
- package/public/icons/svg/apple.svg +1 -0
- package/public/icons/svg/arrow-up.svg +1 -0
- package/public/icons/svg/bluesky.svg +1 -0
- package/public/icons/svg/check.svg +1 -0
- package/public/icons/svg/chrome.svg +1 -0
- package/public/icons/svg/code.svg +1 -0
- package/public/icons/svg/discord.svg +1 -0
- package/public/icons/svg/dollar.svg +1 -0
- package/public/icons/svg/external-link.svg +1 -0
- package/public/icons/svg/github.svg +1 -0
- package/public/icons/svg/globe.svg +1 -0
- package/public/icons/svg/instagram.svg +1 -0
- package/public/icons/svg/layers.svg +1 -0
- package/public/icons/svg/link.svg +1 -0
- package/public/icons/svg/linkedin.svg +1 -0
- package/public/icons/svg/mail.svg +1 -0
- package/public/icons/svg/product-hunt.svg +1 -0
- package/public/icons/svg/reddit.svg +1 -0
- package/public/icons/svg/rocket.svg +1 -0
- package/public/icons/svg/search.svg +1 -0
- package/public/icons/svg/share.svg +1 -0
- package/public/icons/svg/star.svg +1 -0
- package/public/icons/svg/tag.svg +1 -0
- package/public/icons/svg/telegram.svg +1 -0
- package/public/icons/svg/terminal.svg +1 -0
- package/public/icons/svg/tiktok.svg +1 -0
- package/public/icons/svg/trophy.svg +1 -0
- package/public/icons/svg/user.svg +1 -0
- package/public/icons/svg/x.svg +1 -0
- package/public/icons/svg/youtube.svg +1 -0
- package/public/install.sh +44 -0
- package/public/manifest.webmanifest +20 -0
- package/public/moshpit-root-ca.crt +11 -0
- package/public/style.css +162 -0
- package/public/sw.js +30 -0
- package/src/auth.ts +96 -0
- package/src/cli.ts +203 -0
- package/src/client.ts +115 -0
- package/src/db.ts +412 -0
- package/src/fetch.ts +708 -0
- package/src/html.ts +525 -0
- package/src/icons.ts +56 -0
- package/src/mcp.ts +79 -0
- package/src/media.ts +160 -0
- package/src/ring.ts +86 -0
- package/src/server.ts +655 -0
- package/src/service.ts +369 -0
- package/src/tui.ts +127 -0
- package/src/version.ts +1 -0
package/src/client.ts
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The built.indie API from anywhere: the CLI, the TUI and the MCP server use this.
|
|
3
|
+
* BUILTINDIE_URL points it at another instance; BUILTINDIE_KEY (bi_…) is your
|
|
4
|
+
* account's API key (built.indie/me), for claiming and editing your products.
|
|
5
|
+
*/
|
|
6
|
+
export type ApiProduct = {
|
|
7
|
+
slug: string
|
|
8
|
+
name: string
|
|
9
|
+
tagline: string
|
|
10
|
+
description: string
|
|
11
|
+
url: string
|
|
12
|
+
domain: string
|
|
13
|
+
status: string
|
|
14
|
+
votes: number
|
|
15
|
+
tags: string[]
|
|
16
|
+
logo: string | null
|
|
17
|
+
card_image: string | null
|
|
18
|
+
screenshot: string | null
|
|
19
|
+
images: string[]
|
|
20
|
+
videos: { kind: string; url: string; embed: string | null }[]
|
|
21
|
+
socials: Record<string, string>
|
|
22
|
+
llms: { url: string; title: string; summary: string } | null
|
|
23
|
+
profile: { url: string; kind: string | null; name: string; headline: string } | null
|
|
24
|
+
maker: { name: string; profile: string | null; verified: boolean; page: string | null } | null
|
|
25
|
+
verified: boolean
|
|
26
|
+
featured_until: string | null
|
|
27
|
+
launched_at: string | null
|
|
28
|
+
page: string
|
|
29
|
+
badge: string
|
|
30
|
+
opensite: string
|
|
31
|
+
openprofile: string
|
|
32
|
+
award?: { day: string; rank: number } | null
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export type Launched = { product: ApiProduct; claim_url?: string; existing?: boolean }
|
|
36
|
+
export type ClaimRecord = { type: string; name: string; value: string }
|
|
37
|
+
|
|
38
|
+
export class ApiError extends Error {
|
|
39
|
+
constructor(message: string, readonly status: number) {
|
|
40
|
+
super(message)
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export class BuiltIndieClient {
|
|
45
|
+
readonly base: string
|
|
46
|
+
constructor(base = process.env.BUILTINDIE_URL ?? 'https://built.indie.pit.world', private readonly key = process.env.BUILTINDIE_KEY, private readonly doFetch: typeof fetch = fetch) {
|
|
47
|
+
this.base = base.replace(/\/+$/, '')
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
private async call<T>(method: string, path: string, body?: unknown, key = this.key): Promise<T> {
|
|
51
|
+
const response = await this.doFetch(this.base + path, {
|
|
52
|
+
method,
|
|
53
|
+
headers: { accept: 'application/json', ...(body ? { 'content-type': 'application/json' } : {}), ...(key ? { authorization: `Bearer ${key}` } : {}) },
|
|
54
|
+
...(body ? { body: JSON.stringify(body) } : {}),
|
|
55
|
+
signal: AbortSignal.timeout(90_000),
|
|
56
|
+
})
|
|
57
|
+
const data = (await response.json().catch(() => ({}))) as { error?: string }
|
|
58
|
+
if (!response.ok) throw new ApiError(data.error ?? `built.indie answered ${response.status}`, response.status)
|
|
59
|
+
return data as T
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
launch(url: string, o: { makerName?: string; makerProfile?: string; name?: string; tagline?: string; description?: string; tags?: string[]; videos?: string[]; publish?: boolean } = {}): Promise<Launched> {
|
|
63
|
+
return this.call('POST', '/api/v1/products', { url, maker_name: o.makerName, maker_profile: o.makerProfile, name: o.name, tagline: o.tagline, description: o.description, tags: o.tags, videos: o.videos, publish: o.publish ?? true })
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
preview(url: string): Promise<Record<string, unknown>> {
|
|
67
|
+
return this.call('POST', '/api/v1/preview', { url })
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
list(window = 'today', limit = 30): Promise<{ window: string; products: ApiProduct[] }> {
|
|
71
|
+
return this.call('GET', `/api/v1/products?window=${encodeURIComponent(window)}&limit=${limit}`)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
search(q: string): Promise<{ products: ApiProduct[] }> {
|
|
75
|
+
return this.call('GET', `/api/v1/products?q=${encodeURIComponent(q)}`)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
product(slug: string): Promise<ApiProduct> {
|
|
79
|
+
return this.call('GET', `/api/v1/products/${encodeURIComponent(slug)}`)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
vote(slug: string): Promise<{ slug: string; votes: number; counted: boolean }> {
|
|
83
|
+
return this.call('POST', `/api/v1/products/${encodeURIComponent(slug)}/vote`, {})
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
private needKey(key?: string): string {
|
|
87
|
+
const k = key ?? this.key
|
|
88
|
+
if (!k) throw new ApiError('Set BUILTINDIE_KEY to your API key: sign in at built.indie.pit.world/me and make one', 401)
|
|
89
|
+
return k
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
claimRecord(slug: string): Promise<{ owner: boolean; record: ClaimRecord; markdown: string }> {
|
|
93
|
+
return this.call('GET', `/api/v1/products/${encodeURIComponent(slug)}/claim`, undefined, this.needKey())
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
claim(slug: string): Promise<{ claimed: boolean; looked: string[]; record: ClaimRecord; markdown?: string }> {
|
|
97
|
+
return this.call('POST', `/api/v1/products/${encodeURIComponent(slug)}/claim`, {}, this.needKey())
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
me(): Promise<{ email: string; products: (ApiProduct & { owner: boolean })[] }> {
|
|
101
|
+
return this.call('GET', '/api/v1/me', undefined, this.needKey())
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
edit(slug: string, patch: Record<string, unknown>, key?: string): Promise<ApiProduct> {
|
|
105
|
+
return this.call('PATCH', `/api/v1/products/${encodeURIComponent(slug)}`, patch, this.needKey(key))
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
act(slug: string, action: 'publish' | 'refetch', key?: string): Promise<ApiProduct> {
|
|
109
|
+
return this.call('POST', `/api/v1/products/${encodeURIComponent(slug)}/${action}`, {}, this.needKey(key))
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
verify(slug: string): Promise<{ verified: boolean }> {
|
|
113
|
+
return this.call('POST', `/api/v1/products/${encodeURIComponent(slug)}/verify`, {})
|
|
114
|
+
}
|
|
115
|
+
}
|
package/src/db.ts
ADDED
|
@@ -0,0 +1,412 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* built.indie's state: one SQLite file (pictures live beside it, see media.ts).
|
|
3
|
+
*
|
|
4
|
+
* A product is a URL someone launched. Anyone may launch any URL, signed in or
|
|
5
|
+
* not: it is free. Who may change it afterwards:
|
|
6
|
+
* - its owner: whoever claimed it by putting our TXT record in the domain's DNS;
|
|
7
|
+
* - until then, the signed-in person who submitted it.
|
|
8
|
+
* An account is an email address (magic link, auth.ts). A vote is one per
|
|
9
|
+
* visitor per product, the visitor being a keyed hash of their address.
|
|
10
|
+
*/
|
|
11
|
+
import { Database } from 'bun:sqlite'
|
|
12
|
+
import { createHash, createHmac, randomBytes } from 'node:crypto'
|
|
13
|
+
import type { Pricing, Profile, Stack, Video } from './fetch.js'
|
|
14
|
+
|
|
15
|
+
/** What a product says about itself in the other LogicSRC files: OpenStack.md, OpenSaaS, OpenIcon. */
|
|
16
|
+
export type Extras = { stack: Stack | null; pricing: Pricing | null; openicon: string | null }
|
|
17
|
+
|
|
18
|
+
export type Status = 'draft' | 'live' | 'hidden'
|
|
19
|
+
|
|
20
|
+
export type Product = {
|
|
21
|
+
id: string
|
|
22
|
+
slug: string
|
|
23
|
+
url: string
|
|
24
|
+
domain: string
|
|
25
|
+
name: string
|
|
26
|
+
tagline: string
|
|
27
|
+
description: string
|
|
28
|
+
logo: string | null
|
|
29
|
+
ogImage: string | null
|
|
30
|
+
screenshot: string | null
|
|
31
|
+
images: string[]
|
|
32
|
+
videos: Video[]
|
|
33
|
+
socials: Record<string, string>
|
|
34
|
+
tags: string[]
|
|
35
|
+
themeColor: string | null
|
|
36
|
+
llms: { url: string; title: string; summary: string } | null
|
|
37
|
+
profile: Profile | null
|
|
38
|
+
record: Record<string, unknown> | null
|
|
39
|
+
extras: Extras
|
|
40
|
+
makerName: string
|
|
41
|
+
makerProfile: string | null
|
|
42
|
+
makerId: string | null
|
|
43
|
+
makerVerified: boolean
|
|
44
|
+
ownerId: string | null
|
|
45
|
+
claimedAt: number | null
|
|
46
|
+
submitterUser: string | null
|
|
47
|
+
status: Status
|
|
48
|
+
votes: number
|
|
49
|
+
verifiedAt: number | null
|
|
50
|
+
featuredUntil: number | null
|
|
51
|
+
launchedAt: number | null
|
|
52
|
+
createdAt: number
|
|
53
|
+
updatedAt: number
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export type User = { id: string; email: string; createdAt: number }
|
|
57
|
+
|
|
58
|
+
export type Maker = { id: string; name: string; profileUrl: string; headline: string; avatar: string | null; web: string | null; accounts: string[]; createdAt: number }
|
|
59
|
+
|
|
60
|
+
export type Window = 'today' | 'week' | 'month' | 'all' | 'new' | 'featured'
|
|
61
|
+
|
|
62
|
+
export const hashKey = (key: string) => createHash('sha256').update(key).digest('hex')
|
|
63
|
+
export const newId = (bytes = 9) => randomBytes(bytes).toString('base64url')
|
|
64
|
+
export const makerIdFor = (profileUrl: string) => createHash('sha256').update(profileUrl.toLowerCase()).digest('base64url').slice(0, 10)
|
|
65
|
+
|
|
66
|
+
/** Paths that are pages, never product slugs. */
|
|
67
|
+
const RESERVED = new Set(['submit', 'new', 'top', 'api', 'm', 'p', 'b', 'makers', 'help', 'about', 'feed', 'badge', 'hooks', 'health', 'ring', 'well-known', 'admin', 'today', 'week', 'month', 'all', 'featured', 'edit'])
|
|
68
|
+
|
|
69
|
+
export function slugify(name: string): string {
|
|
70
|
+
const s = name
|
|
71
|
+
.normalize('NFKD')
|
|
72
|
+
.replace(/[̀-ͯ]/g, '')
|
|
73
|
+
.toLowerCase()
|
|
74
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
75
|
+
.replace(/^-+|-+$/g, '')
|
|
76
|
+
.slice(0, 48)
|
|
77
|
+
return s.length >= 2 && !RESERVED.has(s) ? s : `product-${s || newId(3).toLowerCase()}`
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** The day a timestamp falls on, UTC, as 2026-10-19. Launch days are UTC days. */
|
|
81
|
+
export const dayOf = (ms: number) => new Date(ms).toISOString().slice(0, 10)
|
|
82
|
+
const startOfDay = (ms: number) => Date.parse(`${dayOf(ms)}T00:00:00Z`)
|
|
83
|
+
|
|
84
|
+
export class Store {
|
|
85
|
+
readonly db: Database
|
|
86
|
+
constructor(path: string, private readonly secret: string) {
|
|
87
|
+
if (!secret || secret.length < 16) throw new Error('BUILTINDIE_KEY must be set (32 random bytes, hex)')
|
|
88
|
+
this.db = new Database(path, { create: true })
|
|
89
|
+
this.db.exec('PRAGMA journal_mode = WAL; PRAGMA busy_timeout = 5000; PRAGMA foreign_keys = ON;')
|
|
90
|
+
this.db.exec(`
|
|
91
|
+
CREATE TABLE IF NOT EXISTS products (
|
|
92
|
+
id TEXT PRIMARY KEY,
|
|
93
|
+
slug TEXT NOT NULL UNIQUE,
|
|
94
|
+
url TEXT NOT NULL,
|
|
95
|
+
url_key TEXT NOT NULL,
|
|
96
|
+
domain TEXT NOT NULL,
|
|
97
|
+
name TEXT NOT NULL,
|
|
98
|
+
tagline TEXT NOT NULL DEFAULT '',
|
|
99
|
+
description TEXT NOT NULL DEFAULT '',
|
|
100
|
+
logo TEXT,
|
|
101
|
+
og_image TEXT,
|
|
102
|
+
screenshot TEXT,
|
|
103
|
+
images TEXT NOT NULL DEFAULT '[]',
|
|
104
|
+
videos TEXT NOT NULL DEFAULT '[]',
|
|
105
|
+
socials TEXT NOT NULL DEFAULT '{}',
|
|
106
|
+
tags TEXT NOT NULL DEFAULT '[]',
|
|
107
|
+
theme_color TEXT,
|
|
108
|
+
llms TEXT,
|
|
109
|
+
profile TEXT,
|
|
110
|
+
record TEXT,
|
|
111
|
+
extras TEXT,
|
|
112
|
+
maker_name TEXT NOT NULL DEFAULT '',
|
|
113
|
+
maker_profile TEXT,
|
|
114
|
+
maker_id TEXT,
|
|
115
|
+
maker_verified INTEGER NOT NULL DEFAULT 0,
|
|
116
|
+
status TEXT NOT NULL DEFAULT 'draft',
|
|
117
|
+
votes INTEGER NOT NULL DEFAULT 0,
|
|
118
|
+
owner_id TEXT,
|
|
119
|
+
claimed_at INTEGER,
|
|
120
|
+
submitter_user TEXT,
|
|
121
|
+
submitter TEXT NOT NULL DEFAULT '',
|
|
122
|
+
verified_at INTEGER,
|
|
123
|
+
featured_until INTEGER,
|
|
124
|
+
launched_at INTEGER,
|
|
125
|
+
created_at INTEGER NOT NULL,
|
|
126
|
+
updated_at INTEGER NOT NULL
|
|
127
|
+
);
|
|
128
|
+
CREATE INDEX IF NOT EXISTS products_launched ON products(status, launched_at DESC);
|
|
129
|
+
CREATE INDEX IF NOT EXISTS products_url ON products(url_key);
|
|
130
|
+
CREATE INDEX IF NOT EXISTS products_maker ON products(maker_id);
|
|
131
|
+
CREATE INDEX IF NOT EXISTS products_owner ON products(owner_id);
|
|
132
|
+
CREATE INDEX IF NOT EXISTS products_submitter ON products(submitter_user);
|
|
133
|
+
CREATE TABLE IF NOT EXISTS users (id TEXT PRIMARY KEY, email TEXT NOT NULL UNIQUE, created_at INTEGER NOT NULL);
|
|
134
|
+
CREATE TABLE IF NOT EXISTS sessions (id_hash TEXT PRIMARY KEY, user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, created_at INTEGER NOT NULL, expires_at INTEGER NOT NULL);
|
|
135
|
+
CREATE TABLE IF NOT EXISTS magic_links (token_hash TEXT PRIMARY KEY, email TEXT NOT NULL, next TEXT, created_at INTEGER NOT NULL, expires_at INTEGER NOT NULL);
|
|
136
|
+
CREATE INDEX IF NOT EXISTS magic_links_email ON magic_links(email, created_at);
|
|
137
|
+
CREATE TABLE IF NOT EXISTS api_keys (key_hash TEXT PRIMARY KEY, user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, created_at INTEGER NOT NULL);
|
|
138
|
+
CREATE TABLE IF NOT EXISTS votes (
|
|
139
|
+
product_id TEXT NOT NULL REFERENCES products(id) ON DELETE CASCADE,
|
|
140
|
+
voter TEXT NOT NULL,
|
|
141
|
+
created_at INTEGER NOT NULL,
|
|
142
|
+
PRIMARY KEY (product_id, voter)
|
|
143
|
+
);
|
|
144
|
+
CREATE INDEX IF NOT EXISTS votes_time ON votes(created_at);
|
|
145
|
+
CREATE TABLE IF NOT EXISTS makers (
|
|
146
|
+
id TEXT PRIMARY KEY,
|
|
147
|
+
name TEXT NOT NULL,
|
|
148
|
+
profile_url TEXT NOT NULL UNIQUE,
|
|
149
|
+
headline TEXT NOT NULL DEFAULT '',
|
|
150
|
+
avatar TEXT,
|
|
151
|
+
web TEXT,
|
|
152
|
+
accounts TEXT NOT NULL DEFAULT '[]',
|
|
153
|
+
created_at INTEGER NOT NULL
|
|
154
|
+
);
|
|
155
|
+
CREATE TABLE IF NOT EXISTS payments (
|
|
156
|
+
ref TEXT PRIMARY KEY,
|
|
157
|
+
product_id TEXT NOT NULL REFERENCES products(id) ON DELETE CASCADE,
|
|
158
|
+
amount_usd REAL NOT NULL,
|
|
159
|
+
chain TEXT NOT NULL,
|
|
160
|
+
days INTEGER NOT NULL,
|
|
161
|
+
status TEXT NOT NULL DEFAULT 'pending',
|
|
162
|
+
created_at INTEGER NOT NULL,
|
|
163
|
+
updated_at INTEGER NOT NULL
|
|
164
|
+
);
|
|
165
|
+
`)
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** One visitor, per product, without keeping their address. */
|
|
169
|
+
voterFor(ip: string): string {
|
|
170
|
+
return createHmac('sha256', this.secret).update(`voter:${ip}`).digest('base64url').slice(0, 22)
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
private toProduct(row: any): Product | null {
|
|
174
|
+
if (!row) return null
|
|
175
|
+
const j = (v: string | null, d: unknown) => (v ? JSON.parse(v) : d)
|
|
176
|
+
return {
|
|
177
|
+
id: row.id,
|
|
178
|
+
slug: row.slug,
|
|
179
|
+
url: row.url,
|
|
180
|
+
domain: row.domain,
|
|
181
|
+
name: row.name,
|
|
182
|
+
tagline: row.tagline,
|
|
183
|
+
description: row.description,
|
|
184
|
+
logo: row.logo,
|
|
185
|
+
ogImage: row.og_image,
|
|
186
|
+
screenshot: row.screenshot,
|
|
187
|
+
images: j(row.images, []),
|
|
188
|
+
videos: j(row.videos, []),
|
|
189
|
+
socials: j(row.socials, {}),
|
|
190
|
+
tags: j(row.tags, []),
|
|
191
|
+
themeColor: row.theme_color,
|
|
192
|
+
llms: j(row.llms, null),
|
|
193
|
+
profile: j(row.profile, null),
|
|
194
|
+
record: j(row.record, null),
|
|
195
|
+
extras: j(row.extras, { stack: null, pricing: null, openicon: null }),
|
|
196
|
+
makerName: row.maker_name,
|
|
197
|
+
makerProfile: row.maker_profile,
|
|
198
|
+
makerId: row.maker_id,
|
|
199
|
+
makerVerified: Boolean(row.maker_verified),
|
|
200
|
+
ownerId: row.owner_id,
|
|
201
|
+
claimedAt: row.claimed_at,
|
|
202
|
+
submitterUser: row.submitter_user,
|
|
203
|
+
status: row.status,
|
|
204
|
+
votes: row.votes,
|
|
205
|
+
verifiedAt: row.verified_at,
|
|
206
|
+
featuredUntil: row.featured_until,
|
|
207
|
+
launchedAt: row.launched_at,
|
|
208
|
+
createdAt: row.created_at,
|
|
209
|
+
updatedAt: row.updated_at,
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** The same product typed two ways: no scheme, no www, no trailing slash, no query. */
|
|
214
|
+
static urlKey(url: string): string {
|
|
215
|
+
const u = new URL(url)
|
|
216
|
+
return `${u.hostname.replace(/^www\./, '')}${u.pathname.replace(/\/+$/, '')}`.toLowerCase()
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
byUrl(url: string): Product | null {
|
|
220
|
+
return this.toProduct(this.db.query("SELECT * FROM products WHERE url_key = ? AND status != 'hidden' ORDER BY created_at LIMIT 1").get(Store.urlKey(url)))
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
freeSlug(name: string): string {
|
|
224
|
+
const base = slugify(name)
|
|
225
|
+
let slug = base
|
|
226
|
+
for (let n = 2; this.db.query('SELECT 1 FROM products WHERE slug = ?').get(slug); n++) slug = `${base}-${n}`
|
|
227
|
+
return slug
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
create(p: Omit<Product, 'votes' | 'verifiedAt' | 'featuredUntil' | 'launchedAt' | 'createdAt' | 'updatedAt' | 'makerId' | 'makerVerified' | 'ownerId' | 'claimedAt'>, submitter: string, now = Date.now()): Product {
|
|
231
|
+
this.db
|
|
232
|
+
.query(
|
|
233
|
+
`INSERT INTO products (id, slug, url, url_key, domain, name, tagline, description, logo, og_image, screenshot, images, videos, socials, tags, theme_color, llms, profile, record, extras, maker_name, maker_profile, status, submitter_user, submitter, launched_at, created_at, updated_at)
|
|
234
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
235
|
+
)
|
|
236
|
+
.run(p.id, p.slug, p.url, Store.urlKey(p.url), p.domain, p.name, p.tagline, p.description, p.logo, p.ogImage, p.screenshot, JSON.stringify(p.images), JSON.stringify(p.videos), JSON.stringify(p.socials), JSON.stringify(p.tags), p.themeColor, p.llms ? JSON.stringify(p.llms) : null, p.profile ? JSON.stringify(p.profile) : null, p.record ? JSON.stringify(p.record) : null, JSON.stringify(p.extras), p.makerName, p.makerProfile, p.status, p.submitterUser, submitter, p.status === 'live' ? now : null, now, now)
|
|
237
|
+
return this.product(p.id)!
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/** The owner, or (until someone claims it) the signed-in person who submitted it. */
|
|
241
|
+
canEdit(p: Product, user: User | null): boolean {
|
|
242
|
+
if (!user || p.status === 'hidden') return false
|
|
243
|
+
return p.ownerId ? p.ownerId === user.id : p.submitterUser === user.id
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
claim(id: string, userId: string, now = Date.now()): void {
|
|
247
|
+
this.db.query('UPDATE products SET owner_id = ?, claimed_at = ?, updated_at = ? WHERE id = ?').run(userId, now, now, id)
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/** Everything a person owns or submitted, newest first. */
|
|
251
|
+
mine(userId: string): Product[] {
|
|
252
|
+
return (this.db.query("SELECT * FROM products WHERE (owner_id = ? OR (owner_id IS NULL AND submitter_user = ?)) AND status != 'hidden' ORDER BY created_at DESC").all(userId, userId) as any[]).map((r) => this.toProduct(r)!)
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// Accounts: an email address, sessions, magic links, API keys. Secrets are kept as SHA-256 only.
|
|
256
|
+
|
|
257
|
+
findOrCreateUser(email: string, now = Date.now()): User {
|
|
258
|
+
this.db.query('INSERT OR IGNORE INTO users (id, email, created_at) VALUES (?, ?, ?)').run(newId(), email.toLowerCase(), now)
|
|
259
|
+
return this.toUser(this.db.query('SELECT * FROM users WHERE email = ?').get(email.toLowerCase()))!
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
user(id: string): User | null {
|
|
263
|
+
return this.toUser(this.db.query('SELECT * FROM users WHERE id = ?').get(id))
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
private toUser(r: any): User | null {
|
|
267
|
+
return r ? { id: r.id, email: r.email, createdAt: r.created_at } : null
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
newApiKey(userId: string, now = Date.now()): string {
|
|
271
|
+
const key = `bi_${randomBytes(24).toString('base64url')}`
|
|
272
|
+
this.db.query('DELETE FROM api_keys WHERE user_id = ?').run(userId)
|
|
273
|
+
this.db.query('INSERT INTO api_keys (key_hash, user_id, created_at) VALUES (?, ?, ?)').run(hashKey(key), userId, now)
|
|
274
|
+
return key
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
byApiKey(key: string): User | null {
|
|
278
|
+
if (!key.startsWith('bi_')) return null
|
|
279
|
+
const r: any = this.db.query('SELECT user_id FROM api_keys WHERE key_hash = ?').get(hashKey(key))
|
|
280
|
+
return r ? this.user(r.user_id) : null
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/** Change what the maker may change. Only known columns; JSON for the lists. */
|
|
284
|
+
update(id: string, patch: Partial<Pick<Product, 'name' | 'tagline' | 'description' | 'logo' | 'ogImage' | 'screenshot' | 'images' | 'videos' | 'socials' | 'tags' | 'themeColor' | 'llms' | 'profile' | 'record' | 'extras' | 'makerName' | 'makerProfile' | 'makerId' | 'makerVerified' | 'status' | 'url' | 'domain'>>, now = Date.now()): Product | null {
|
|
285
|
+
const cols: Record<string, string> = { name: 'name', tagline: 'tagline', description: 'description', logo: 'logo', ogImage: 'og_image', screenshot: 'screenshot', images: 'images', videos: 'videos', socials: 'socials', tags: 'tags', themeColor: 'theme_color', llms: 'llms', profile: 'profile', record: 'record', extras: 'extras', makerName: 'maker_name', makerProfile: 'maker_profile', makerId: 'maker_id', makerVerified: 'maker_verified', status: 'status', url: 'url', domain: 'domain' }
|
|
286
|
+
const sets: string[] = []
|
|
287
|
+
const values: (string | number | null)[] = []
|
|
288
|
+
for (const [k, v] of Object.entries(patch)) {
|
|
289
|
+
const col = cols[k]
|
|
290
|
+
if (!col || v === undefined) continue
|
|
291
|
+
sets.push(`${col} = ?`)
|
|
292
|
+
values.push(v === null ? null : typeof v === 'object' ? JSON.stringify(v) : typeof v === 'boolean' ? Number(v) : v)
|
|
293
|
+
if (k === 'url') sets.push('url_key = ?'), values.push(Store.urlKey(String(v)))
|
|
294
|
+
}
|
|
295
|
+
if (patch.status === 'live') sets.push('launched_at = COALESCE(launched_at, ?)'), values.push(now)
|
|
296
|
+
if (!sets.length) return this.product(id)
|
|
297
|
+
sets.push('updated_at = ?')
|
|
298
|
+
values.push(now, id)
|
|
299
|
+
this.db.query(`UPDATE products SET ${sets.join(', ')} WHERE id = ?`).run(...values)
|
|
300
|
+
return this.product(id)
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
product(id: string): Product | null {
|
|
304
|
+
return this.toProduct(this.db.query('SELECT * FROM products WHERE id = ?').get(id))
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
bySlug(slug: string): Product | null {
|
|
308
|
+
return this.toProduct(this.db.query('SELECT * FROM products WHERE slug = ?').get(slug.toLowerCase()))
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
delete(id: string): void {
|
|
312
|
+
this.db.query('DELETE FROM products WHERE id = ?').run(id)
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/** Drafts nobody published within a day; returns their ids so the pictures go too. */
|
|
316
|
+
sweepDrafts(now = Date.now()): string[] {
|
|
317
|
+
return (this.db.query("DELETE FROM products WHERE status = 'draft' AND created_at < ? RETURNING id").all(now - 86400_000) as { id: string }[]).map((r) => r.id)
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
submissionsFrom(submitter: string, sinceMs: number): number {
|
|
321
|
+
return (this.db.query('SELECT COUNT(*) AS n FROM products WHERE submitter = ? AND created_at > ?').get(submitter, sinceMs) as { n: number }).n
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/** Live products for a window, best first. `today` etc. are UTC launch days; `new` is newest first. */
|
|
325
|
+
list(window: Window = 'today', limit = 30, offset = 0, now = Date.now()): Product[] {
|
|
326
|
+
const day = 86400_000
|
|
327
|
+
const since = { today: startOfDay(now), week: now - 7 * day, month: now - 30 * day, all: 0, new: 0, featured: 0 }[window]
|
|
328
|
+
const where = window === 'featured' ? "status = 'live' AND featured_until > ?" : "status = 'live' AND launched_at >= ?"
|
|
329
|
+
const order = window === 'new' ? 'launched_at DESC' : 'votes DESC, launched_at ASC'
|
|
330
|
+
return (this.db.query(`SELECT * FROM products WHERE ${where} ORDER BY ${order} LIMIT ? OFFSET ?`).all(window === 'featured' ? now : since, limit, offset) as any[]).map((r) => this.toProduct(r)!)
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
onDay(day: string, limit = 50): Product[] {
|
|
334
|
+
const from = Date.parse(`${day}T00:00:00Z`)
|
|
335
|
+
return (this.db.query("SELECT * FROM products WHERE status = 'live' AND launched_at >= ? AND launched_at < ? ORDER BY votes DESC, launched_at ASC LIMIT ?").all(from, from + 86400_000, limit) as any[]).map((r) => this.toProduct(r)!)
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
byMaker(makerId: string): Product[] {
|
|
339
|
+
return (this.db.query("SELECT * FROM products WHERE maker_id = ? AND status = 'live' ORDER BY launched_at DESC").all(makerId) as any[]).map((r) => this.toProduct(r)!)
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
search(q: string, limit = 30): Product[] {
|
|
343
|
+
const like = `%${q.replace(/[\\%_]/g, (c) => `\\${c}`)}%`
|
|
344
|
+
return (this.db.query("SELECT * FROM products WHERE status = 'live' AND (name LIKE ? ESCAPE '\\' OR tagline LIKE ? ESCAPE '\\' OR domain LIKE ? ESCAPE '\\' OR tags LIKE ? ESCAPE '\\') ORDER BY votes DESC LIMIT ?").all(like, like, like, like, limit) as any[]).map((r) => this.toProduct(r)!)
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
count(): { products: number; votes: number } {
|
|
348
|
+
return this.db.query("SELECT (SELECT COUNT(*) FROM products WHERE status = 'live') AS products, (SELECT COUNT(*) FROM votes) AS votes").get() as { products: number; votes: number }
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/** #1 of its launch day, once that day is over. */
|
|
352
|
+
awards(productId: string, now = Date.now()): { day: string; rank: number } | null {
|
|
353
|
+
const p = this.product(productId)
|
|
354
|
+
if (!p?.launchedAt || startOfDay(p.launchedAt) >= startOfDay(now)) return null
|
|
355
|
+
const day = dayOf(p.launchedAt)
|
|
356
|
+
const rank = this.onDay(day, 3).findIndex((x) => x.id === productId)
|
|
357
|
+
return rank >= 0 ? { day, rank: rank + 1 } : null
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/** One vote per visitor per product. True when it counted. */
|
|
361
|
+
vote(productId: string, voter: string, now = Date.now()): boolean {
|
|
362
|
+
const r = this.db.query('INSERT OR IGNORE INTO votes (product_id, voter, created_at) VALUES (?, ?, ?)').run(productId, voter, now)
|
|
363
|
+
if (!r.changes) return false
|
|
364
|
+
this.db.query('UPDATE products SET votes = votes + 1 WHERE id = ?').run(productId)
|
|
365
|
+
return true
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
voted(productId: string, voter: string): boolean {
|
|
369
|
+
return Boolean(this.db.query('SELECT 1 FROM votes WHERE product_id = ? AND voter = ?').get(productId, voter))
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
setVerified(id: string, at: number | null): void {
|
|
373
|
+
this.db.query('UPDATE products SET verified_at = ? WHERE id = ?').run(at, id)
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
upsertMaker(m: Omit<Maker, 'id' | 'createdAt'>, now = Date.now()): Maker {
|
|
377
|
+
const id = makerIdFor(m.profileUrl)
|
|
378
|
+
this.db
|
|
379
|
+
.query(
|
|
380
|
+
`INSERT INTO makers (id, name, profile_url, headline, avatar, web, accounts, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
381
|
+
ON CONFLICT(id) DO UPDATE SET name = excluded.name, headline = excluded.headline, avatar = excluded.avatar, web = excluded.web, accounts = excluded.accounts`,
|
|
382
|
+
)
|
|
383
|
+
.run(id, m.name, m.profileUrl, m.headline, m.avatar, m.web, JSON.stringify(m.accounts), now)
|
|
384
|
+
return this.maker(id)!
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
maker(id: string): Maker | null {
|
|
388
|
+
const r: any = this.db.query('SELECT * FROM makers WHERE id = ?').get(id)
|
|
389
|
+
return r ? { id: r.id, name: r.name, profileUrl: r.profile_url, headline: r.headline, avatar: r.avatar, web: r.web, accounts: JSON.parse(r.accounts), createdAt: r.created_at } : null
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
addPayment(p: { ref: string; productId: string; amountUsd: number; chain: string; days: number }, now = Date.now()): void {
|
|
393
|
+
this.db.query('INSERT OR IGNORE INTO payments (ref, product_id, amount_usd, chain, days, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)').run(p.ref, p.productId, p.amountUsd, p.chain, p.days, now, now)
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
payment(ref: string): { ref: string; productId: string; days: number; status: string } | null {
|
|
397
|
+
const r: any = this.db.query('SELECT ref, product_id, days, status FROM payments WHERE ref = ?').get(ref)
|
|
398
|
+
return r ? { ref: r.ref, productId: r.product_id, days: r.days, status: r.status } : null
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/** Mark a payment settled and extend the product's featured time, exactly once per payment. */
|
|
402
|
+
settle(ref: string, now = Date.now()): boolean {
|
|
403
|
+
const r: any = this.db.query("UPDATE payments SET status = 'paid', updated_at = ? WHERE ref = ? AND status != 'paid' RETURNING product_id, days").get(now, ref)
|
|
404
|
+
if (!r) return false
|
|
405
|
+
this.db.query('UPDATE products SET featured_until = MAX(COALESCE(featured_until, 0), ?) + ? WHERE id = ?').run(now, r.days * 86400_000, r.product_id)
|
|
406
|
+
return true
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
setPaymentStatus(ref: string, status: string, now = Date.now()): void {
|
|
410
|
+
this.db.query("UPDATE payments SET status = ?, updated_at = ? WHERE ref = ? AND status != 'paid'").run(status, now, ref)
|
|
411
|
+
}
|
|
412
|
+
}
|