birdclaw 0.8.2 → 0.8.4
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/CHANGELOG.md +33 -0
- package/README.md +10 -8
- package/bin/birdclaw.mjs +4 -66
- package/dist/cli/birdclaw.js +28165 -0
- package/dist/client/assets/AvatarChip-DLoqM5xD.js +1 -0
- package/dist/client/assets/ProfilePreview-BejAuYsJ.js +1 -0
- package/dist/client/assets/SavedTimelineView-BIS7HKpp.js +1 -0
- package/dist/client/assets/SmartTimestamp-BR0-9se2.js +1 -0
- package/dist/client/assets/TimelineRouteFrame-CyGPj0eM.js +1 -0
- package/dist/client/assets/blocks-7UDKQp8H.js +1 -0
- package/dist/client/assets/bookmarks-CbBkMvXk.js +1 -0
- package/dist/client/assets/chunk-C0phanGV.js +1 -0
- package/dist/client/assets/circle-check-C_Vq3ElD.js +1 -0
- package/dist/client/assets/client-stream-contracts-ys3XhWG4.js +5 -0
- package/dist/client/assets/createLucideIcon-JV8isFFR.js +1 -0
- package/dist/client/assets/data-sources-Cm2bpNvg.js +1 -0
- package/dist/client/assets/discuss-DTjaOugw.js +1 -0
- package/dist/client/assets/dms-CGd6Eogq.js +1 -0
- package/dist/client/assets/inbox-BHlGuOy2.js +1 -0
- package/dist/client/assets/index-CEIG7S6s.js +31 -0
- package/dist/client/assets/index-bpFrQ5yG.css +1 -0
- package/dist/client/assets/likes-cLrcAQem.js +1 -0
- package/dist/client/assets/links-CM9QO1LC.js +1 -0
- package/dist/client/assets/mapbox-BxTlqXt9.js +2 -0
- package/dist/client/assets/mapbox-gl-dgf_hX3a.js +3215 -0
- package/dist/client/assets/mentions-CxKCgzIf.js +1 -0
- package/dist/client/assets/network-map-B5a3whqj.js +2 -0
- package/dist/client/assets/profile-analyze-CRtNEwlb.js +1 -0
- package/dist/client/assets/profile-hydration-client-YEtJsNTv.js +1 -0
- package/dist/client/assets/profiles._handle-C82_HoKX.js +1 -0
- package/dist/client/assets/query-client-fYu2rzRC.js +17 -0
- package/dist/client/assets/rate-limits-ScaEwdVK.js +1 -0
- package/dist/client/assets/react-dom-DHl6Ug7F.js +1 -0
- package/dist/client/assets/react-nBylq1U5.js +1 -0
- package/dist/client/assets/repeat-2-xCwHM9hD.js +1 -0
- package/dist/client/assets/routes-q7ZaW4En.js +1 -0
- package/dist/client/assets/schemas-I9r6F6ya.js +64 -0
- package/dist/client/assets/search-Dq5kYsxA.js +1 -0
- package/dist/client/assets/sparkles-CQVlvHsZ.js +1 -0
- package/dist/client/assets/styles-B8s3tt3r.css +2 -0
- package/dist/client/assets/today-CeG1O7Py.js +1 -0
- package/dist/client/assets/triangle-alert-CBL7s2pf.js +1 -0
- package/dist/client/assets/useTimelineRouteData-1HsqgvUi.js +1 -0
- package/dist/client/assets/users-CHbKlqUr.js +1 -0
- package/dist/server/assets/AvatarChip-zUDAEmjL.js +105 -0
- package/dist/server/assets/FeedState-ntW0Q2GB.js +214 -0
- package/dist/server/assets/ProfileAnalysisStream-BFvhVdhn.js +198 -0
- package/dist/server/assets/ProfilePreview-C6gAor_j.js +752 -0
- package/dist/server/assets/SavedTimelineView-BG1jnBp1.js +64 -0
- package/dist/server/assets/SmartTimestamp-DfwE1NGf.js +45 -0
- package/dist/server/assets/SyncNowButton-BOn_IBXS.js +76 -0
- package/dist/server/assets/TimelineRouteFrame-CU7ilM7U.js +85 -0
- package/dist/server/assets/TweetRichText-DhCVv1-6.js +74 -0
- package/dist/server/assets/_tanstack-start-manifest_v-C-ZRnG_n.js +148 -0
- package/dist/server/assets/account-selection-CTdFGWRk.js +44 -0
- package/dist/server/assets/blocks-C1mgGTB3.js +299 -0
- package/dist/server/assets/blocks-DSgQghqP.js +17 -0
- package/dist/server/assets/bookmarks-BJN8tykY.js +14 -0
- package/dist/server/assets/client-stream-contracts-C2xbWVjm.js +769 -0
- package/dist/server/assets/data-sources-BnA1Q94p.js +180 -0
- package/dist/server/assets/discuss-CJ1dau1j.js +15 -0
- package/dist/server/assets/discuss-QTo08NCN.js +338 -0
- package/dist/server/assets/dms-BuLj4bp8.js +368 -0
- package/dist/server/assets/dms-DAyQrVM2.js +240 -0
- package/dist/server/assets/empty-plugin-adapters-DzrEkdBF.js +5 -0
- package/dist/server/assets/inbox-CkGOaW1q.js +214 -0
- package/dist/server/assets/inbox-DpWd1EzC.js +146 -0
- package/dist/server/assets/likes-DBOAtsJU.js +14 -0
- package/dist/server/assets/links-DAhSjynu.js +240 -0
- package/dist/server/assets/links-DmH5lxHO.js +468 -0
- package/dist/server/assets/mentions-BH2MzKtB.js +26 -0
- package/dist/server/assets/network-map-D3CzRcC4.js +224 -0
- package/dist/server/assets/network-map-D5DoLOwR.js +582 -0
- package/dist/server/assets/profile-analyze-BP65PED0.js +90 -0
- package/dist/server/assets/profile-analyze-C1NoM0tV.js +9 -0
- package/dist/server/assets/profile-hydration-client-GJ4kwJS8.js +133 -0
- package/dist/server/assets/profiles._handle-DjwlN_ac.js +184 -0
- package/dist/server/assets/profiles._handle-DsLNj2Ca.js +12 -0
- package/dist/server/assets/query-client-BdauHBgd.js +923 -0
- package/dist/server/assets/rate-limits-VAr2qzYD.js +262 -0
- package/dist/server/assets/route-search-BUlQ5lGM.js +117 -0
- package/dist/server/assets/router-DqkwSPiM.js +17026 -0
- package/dist/server/assets/routes-A9c9ZhwN.js +26 -0
- package/dist/server/assets/start-FaDOTmto.js +4 -0
- package/dist/server/assets/today-B5hVM57Z.js +17 -0
- package/dist/server/assets/today-fvGiZ7ud.js +303 -0
- package/dist/server/assets/ui-qbmcMSSX.js +137 -0
- package/{src/components/useDebouncedValue.ts → dist/server/assets/useDebouncedValue-DOnA6Xcy.js} +4 -4
- package/dist/server/assets/useTimelineRouteData-27cxynxa.js +1101 -0
- package/dist/server/server.js +5520 -0
- package/package.json +27 -34
- package/playwright.config.ts +0 -35
- package/scripts/browser-perf.mjs +0 -427
- package/scripts/build-docs-site.mjs +0 -967
- package/scripts/docs-site-assets.mjs +0 -311
- package/scripts/run-vitest.mjs +0 -21
- package/scripts/sanitize-node-options.mjs +0 -23
- package/scripts/start-test-server.mjs +0 -42
- package/src/cli-moderation.ts +0 -210
- package/src/cli.ts +0 -2336
- package/src/components/AccountSwitcher.tsx +0 -186
- package/src/components/AppNav.tsx +0 -118
- package/src/components/AvatarChip.tsx +0 -58
- package/src/components/BrandMark.tsx +0 -67
- package/src/components/ConversationThread.tsx +0 -131
- package/src/components/DmWorkspace.tsx +0 -286
- package/src/components/EmbeddedTweetCard.tsx +0 -55
- package/src/components/FeedState.tsx +0 -147
- package/src/components/FloatingPreview.tsx +0 -382
- package/src/components/InboxCard.tsx +0 -152
- package/src/components/LinkPreviewCard.tsx +0 -292
- package/src/components/MarkdownViewer.tsx +0 -777
- package/src/components/ProfileAnalysisStream.tsx +0 -428
- package/src/components/ProfilePreview.tsx +0 -129
- package/src/components/SavedTimelineView.tsx +0 -155
- package/src/components/SmartTimestamp.tsx +0 -72
- package/src/components/SyncNowButton.tsx +0 -140
- package/src/components/ThemeSlider.tsx +0 -80
- package/src/components/TimelineCard.tsx +0 -533
- package/src/components/TimelineRouteFrame.tsx +0 -186
- package/src/components/TweetArticleCard.tsx +0 -66
- package/src/components/TweetMediaGrid.tsx +0 -185
- package/src/components/TweetRichText.tsx +0 -159
- package/src/components/account-selection.ts +0 -64
- package/src/components/useTimelineRouteData.ts +0 -323
- package/src/lib/account-sync-job.ts +0 -666
- package/src/lib/actions-transport.ts +0 -300
- package/src/lib/api-client.ts +0 -419
- package/src/lib/archive-finder.ts +0 -185
- package/src/lib/archive-import.ts +0 -2764
- package/src/lib/authored-live.ts +0 -1072
- package/src/lib/avatar-cache.ts +0 -349
- package/src/lib/backup.ts +0 -2741
- package/src/lib/bird-actions.ts +0 -294
- package/src/lib/bird-command.ts +0 -130
- package/src/lib/bird.ts +0 -1140
- package/src/lib/blocklist.ts +0 -136
- package/src/lib/blocks-write.ts +0 -167
- package/src/lib/blocks.ts +0 -389
- package/src/lib/bookmark-sync-job.ts +0 -536
- package/src/lib/client-cache.ts +0 -109
- package/src/lib/config.ts +0 -224
- package/src/lib/conversation-surface.ts +0 -205
- package/src/lib/data-sources.ts +0 -219
- package/src/lib/db.ts +0 -1135
- package/src/lib/dms-live.ts +0 -933
- package/src/lib/effect-runtime.ts +0 -45
- package/src/lib/follow-graph.ts +0 -1097
- package/src/lib/geocoding.ts +0 -296
- package/src/lib/http-effect.ts +0 -222
- package/src/lib/identity-search-index.ts +0 -369
- package/src/lib/inbox.ts +0 -241
- package/src/lib/link-index.ts +0 -641
- package/src/lib/link-insights.ts +0 -860
- package/src/lib/link-preview-metadata.ts +0 -754
- package/src/lib/location.ts +0 -137
- package/src/lib/media-fetch.ts +0 -860
- package/src/lib/media-includes.ts +0 -165
- package/src/lib/mention-threads-live.ts +0 -917
- package/src/lib/mentions-export.ts +0 -147
- package/src/lib/mentions-live.ts +0 -1238
- package/src/lib/moderation-target.ts +0 -216
- package/src/lib/moderation-write.ts +0 -131
- package/src/lib/mutes-write.ts +0 -167
- package/src/lib/mutes.ts +0 -84
- package/src/lib/ndjson-stream.ts +0 -106
- package/src/lib/network-map.ts +0 -382
- package/src/lib/openai.ts +0 -117
- package/src/lib/period-digest.ts +0 -1552
- package/src/lib/present.ts +0 -118
- package/src/lib/profile-affiliation-hydration.ts +0 -228
- package/src/lib/profile-affiliations.ts +0 -262
- package/src/lib/profile-analysis.ts +0 -1272
- package/src/lib/profile-bio-entities.ts +0 -318
- package/src/lib/profile-history.ts +0 -164
- package/src/lib/profile-hydration.ts +0 -180
- package/src/lib/profile-replies.ts +0 -77
- package/src/lib/profile-resolver.ts +0 -650
- package/src/lib/queries.ts +0 -2381
- package/src/lib/research.ts +0 -652
- package/src/lib/search-discussion.ts +0 -1016
- package/src/lib/seed.ts +0 -613
- package/src/lib/sqlite.ts +0 -147
- package/src/lib/sync-cache.ts +0 -65
- package/src/lib/theme-transition.ts +0 -113
- package/src/lib/theme.tsx +0 -165
- package/src/lib/timeline-collections-live.ts +0 -581
- package/src/lib/timeline-live.ts +0 -452
- package/src/lib/tweet-account-edges.ts +0 -48
- package/src/lib/tweet-lookup.ts +0 -46
- package/src/lib/tweet-render.ts +0 -430
- package/src/lib/tweet-search-live.ts +0 -566
- package/src/lib/types.ts +0 -723
- package/src/lib/ui.ts +0 -529
- package/src/lib/url-expansion-store.ts +0 -110
- package/src/lib/url-expansion.ts +0 -343
- package/src/lib/url-safety.ts +0 -220
- package/src/lib/web-sync.ts +0 -516
- package/src/lib/whois.ts +0 -1079
- package/src/lib/x-profile.ts +0 -365
- package/src/lib/x-web.ts +0 -199
- package/src/lib/xurl-rate-limits.ts +0 -267
- package/src/lib/xurl.ts +0 -1677
- package/src/routeTree.gen.ts +0 -723
- package/src/router.tsx +0 -20
- package/src/routes/__root.tsx +0 -79
- package/src/routes/api/action.tsx +0 -147
- package/src/routes/api/avatar.tsx +0 -50
- package/src/routes/api/blocks.tsx +0 -35
- package/src/routes/api/conversation.tsx +0 -45
- package/src/routes/api/data-sources.tsx +0 -24
- package/src/routes/api/inbox.tsx +0 -43
- package/src/routes/api/link-insights.tsx +0 -85
- package/src/routes/api/link-preview.tsx +0 -47
- package/src/routes/api/network-map.tsx +0 -55
- package/src/routes/api/period-digest.tsx +0 -101
- package/src/routes/api/profile-analysis.tsx +0 -97
- package/src/routes/api/profile-hydrate.tsx +0 -54
- package/src/routes/api/query.tsx +0 -115
- package/src/routes/api/search-discussion.tsx +0 -113
- package/src/routes/api/status.tsx +0 -28
- package/src/routes/api/sync.tsx +0 -105
- package/src/routes/api/xurl-rate-limits.tsx +0 -24
- package/src/routes/blocks.tsx +0 -377
- package/src/routes/bookmarks.tsx +0 -18
- package/src/routes/data-sources.tsx +0 -257
- package/src/routes/discuss.tsx +0 -419
- package/src/routes/dms.tsx +0 -432
- package/src/routes/inbox.tsx +0 -226
- package/src/routes/index.tsx +0 -32
- package/src/routes/likes.tsx +0 -18
- package/src/routes/links.tsx +0 -994
- package/src/routes/mentions.tsx +0 -32
- package/src/routes/network-map.tsx +0 -1035
- package/src/routes/profile-analyze.tsx +0 -112
- package/src/routes/profiles.$handle.tsx +0 -228
- package/src/routes/rate-limits.tsx +0 -309
- package/src/routes/today.tsx +0 -511
- package/src/styles.css +0 -257
- package/tsconfig.json +0 -29
- package/vite.config.ts +0 -29
- /package/{public → dist/client}/birdclaw-mark.png +0 -0
- /package/{public → dist/client}/favicon.ico +0 -0
- /package/{public → dist/client}/logo192.png +0 -0
- /package/{public → dist/client}/logo512.png +0 -0
- /package/{public → dist/client}/manifest.json +0 -0
- /package/{public → dist/client}/robots.txt +0 -0
package/src/lib/geocoding.ts
DELETED
|
@@ -1,296 +0,0 @@
|
|
|
1
|
-
import { z } from "zod";
|
|
2
|
-
import { getNativeDb } from "./db";
|
|
3
|
-
import { coordinatesFromLocationKey, normalizeLocationKey } from "./location";
|
|
4
|
-
import type { Database } from "./sqlite";
|
|
5
|
-
|
|
6
|
-
const OpenCageGeometrySchema = z.object({ lat: z.number(), lng: z.number() });
|
|
7
|
-
const OpenCageResultSchema = z.object({
|
|
8
|
-
geometry: OpenCageGeometrySchema,
|
|
9
|
-
confidence: z.number().int().min(0).max(10).optional(),
|
|
10
|
-
formatted: z.string().optional(),
|
|
11
|
-
components: z
|
|
12
|
-
.object({ country_code: z.string().optional() })
|
|
13
|
-
.loose()
|
|
14
|
-
.optional(),
|
|
15
|
-
bounds: z
|
|
16
|
-
.object({
|
|
17
|
-
northeast: OpenCageGeometrySchema.optional(),
|
|
18
|
-
southwest: OpenCageGeometrySchema.optional(),
|
|
19
|
-
})
|
|
20
|
-
.optional(),
|
|
21
|
-
});
|
|
22
|
-
const OpenCageResponseSchema = z.object({
|
|
23
|
-
results: z.array(OpenCageResultSchema),
|
|
24
|
-
status: z.object({ code: z.number(), message: z.string() }).loose(),
|
|
25
|
-
});
|
|
26
|
-
|
|
27
|
-
export interface GeocodeResult {
|
|
28
|
-
normalizedKey: string;
|
|
29
|
-
original: string;
|
|
30
|
-
lat: number;
|
|
31
|
-
lng: number;
|
|
32
|
-
formatted?: string;
|
|
33
|
-
countryCode?: string;
|
|
34
|
-
confidence?: number;
|
|
35
|
-
provider: "opencage" | "coords";
|
|
36
|
-
approxRadiusM?: number;
|
|
37
|
-
bounds?: Record<string, unknown>;
|
|
38
|
-
components?: Record<string, unknown>;
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
export class GeocodeRateLimitError extends Error {
|
|
42
|
-
constructor() {
|
|
43
|
-
super("opencage:429");
|
|
44
|
-
this.name = "GeocodeRateLimitError";
|
|
45
|
-
}
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
const SQLITE_IN_CHUNK_SIZE = 400;
|
|
49
|
-
|
|
50
|
-
export function getOpenCageApiKey() {
|
|
51
|
-
return process.env.OPENCAGE_API_KEY?.trim() || null;
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
function approximateRadiusM(bounds: GeocodeResult["bounds"]) {
|
|
55
|
-
const northeast = bounds?.northeast as
|
|
56
|
-
| { lat?: unknown; lng?: unknown }
|
|
57
|
-
| undefined;
|
|
58
|
-
const southwest = bounds?.southwest as
|
|
59
|
-
| { lat?: unknown; lng?: unknown }
|
|
60
|
-
| undefined;
|
|
61
|
-
if (
|
|
62
|
-
typeof northeast?.lat !== "number" ||
|
|
63
|
-
typeof northeast.lng !== "number" ||
|
|
64
|
-
typeof southwest?.lat !== "number" ||
|
|
65
|
-
typeof southwest.lng !== "number"
|
|
66
|
-
) {
|
|
67
|
-
return undefined;
|
|
68
|
-
}
|
|
69
|
-
const latMeters = Math.abs(northeast.lat - southwest.lat) * 111_320;
|
|
70
|
-
const lngMeters =
|
|
71
|
-
Math.abs(northeast.lng - southwest.lng) *
|
|
72
|
-
Math.cos((((northeast.lat + southwest.lat) / 2) * Math.PI) / 180) *
|
|
73
|
-
111_320;
|
|
74
|
-
return Math.max(500, Math.min(120_000, Math.hypot(latMeters, lngMeters) / 2));
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
export function readCachedGeocodes(
|
|
78
|
-
keys: readonly string[],
|
|
79
|
-
db = getNativeDb(),
|
|
80
|
-
) {
|
|
81
|
-
if (keys.length === 0) return new Map<string, GeocodeResult>();
|
|
82
|
-
const rows: Array<Record<string, unknown>> = [];
|
|
83
|
-
for (let index = 0; index < keys.length; index += SQLITE_IN_CHUNK_SIZE) {
|
|
84
|
-
const chunk = keys.slice(index, index + SQLITE_IN_CHUNK_SIZE);
|
|
85
|
-
const placeholders = chunk.map(() => "?").join(",");
|
|
86
|
-
rows.push(
|
|
87
|
-
...(db
|
|
88
|
-
.prepare(
|
|
89
|
-
`
|
|
90
|
-
select normalized_key, original, lat, lng, formatted, country_code,
|
|
91
|
-
confidence, provider, approx_radius_m, bounds_json, components_json
|
|
92
|
-
from geocoded_locations
|
|
93
|
-
where normalized_key in (${placeholders})
|
|
94
|
-
`,
|
|
95
|
-
)
|
|
96
|
-
.all(...chunk) as Array<Record<string, unknown>>),
|
|
97
|
-
);
|
|
98
|
-
}
|
|
99
|
-
const map = new Map<string, GeocodeResult>();
|
|
100
|
-
for (const row of rows) {
|
|
101
|
-
const key = String(row.normalized_key);
|
|
102
|
-
map.set(key, {
|
|
103
|
-
normalizedKey: key,
|
|
104
|
-
original: String(row.original),
|
|
105
|
-
lat: Number(row.lat),
|
|
106
|
-
lng: Number(row.lng),
|
|
107
|
-
formatted: typeof row.formatted === "string" ? row.formatted : undefined,
|
|
108
|
-
countryCode:
|
|
109
|
-
typeof row.country_code === "string" ? row.country_code : undefined,
|
|
110
|
-
confidence:
|
|
111
|
-
typeof row.confidence === "number" ? row.confidence : undefined,
|
|
112
|
-
provider: row.provider === "coords" ? "coords" : "opencage",
|
|
113
|
-
approxRadiusM:
|
|
114
|
-
typeof row.approx_radius_m === "number"
|
|
115
|
-
? row.approx_radius_m
|
|
116
|
-
: undefined,
|
|
117
|
-
bounds: parseJsonRecord(row.bounds_json),
|
|
118
|
-
components: parseJsonRecord(row.components_json),
|
|
119
|
-
});
|
|
120
|
-
}
|
|
121
|
-
return map;
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
export function readSuppressedGeocodeKeys(
|
|
125
|
-
keys: readonly string[],
|
|
126
|
-
db = getNativeDb(),
|
|
127
|
-
) {
|
|
128
|
-
if (keys.length === 0) return new Set<string>();
|
|
129
|
-
const now = new Date().toISOString();
|
|
130
|
-
const rows: Array<{ normalized_key: string }> = [];
|
|
131
|
-
for (let index = 0; index < keys.length; index += SQLITE_IN_CHUNK_SIZE) {
|
|
132
|
-
const chunk = keys.slice(index, index + SQLITE_IN_CHUNK_SIZE);
|
|
133
|
-
const placeholders = chunk.map(() => "?").join(",");
|
|
134
|
-
rows.push(
|
|
135
|
-
...(db
|
|
136
|
-
.prepare(
|
|
137
|
-
`
|
|
138
|
-
select normalized_key
|
|
139
|
-
from geocoded_locations_unresolved
|
|
140
|
-
where normalized_key in (${placeholders})
|
|
141
|
-
and (ttl_until is null or ttl_until > ?)
|
|
142
|
-
`,
|
|
143
|
-
)
|
|
144
|
-
.all(...chunk, now) as Array<{ normalized_key: string }>),
|
|
145
|
-
);
|
|
146
|
-
}
|
|
147
|
-
return new Set(rows.map((row) => row.normalized_key));
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
export function storeGeocode(result: GeocodeResult, db = getNativeDb()) {
|
|
151
|
-
const now = new Date().toISOString();
|
|
152
|
-
db.prepare(
|
|
153
|
-
`
|
|
154
|
-
insert into geocoded_locations (
|
|
155
|
-
normalized_key, original, lat, lng, formatted, country_code, confidence,
|
|
156
|
-
provider, approx_radius_m, bounds_json, components_json, hits, created_at,
|
|
157
|
-
last_used_at
|
|
158
|
-
) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?)
|
|
159
|
-
on conflict(normalized_key) do update set
|
|
160
|
-
original = excluded.original,
|
|
161
|
-
lat = excluded.lat,
|
|
162
|
-
lng = excluded.lng,
|
|
163
|
-
formatted = coalesce(excluded.formatted, geocoded_locations.formatted),
|
|
164
|
-
country_code = coalesce(excluded.country_code, geocoded_locations.country_code),
|
|
165
|
-
confidence = coalesce(excluded.confidence, geocoded_locations.confidence),
|
|
166
|
-
provider = excluded.provider,
|
|
167
|
-
approx_radius_m = coalesce(excluded.approx_radius_m, geocoded_locations.approx_radius_m),
|
|
168
|
-
bounds_json = excluded.bounds_json,
|
|
169
|
-
components_json = excluded.components_json,
|
|
170
|
-
hits = geocoded_locations.hits + 1,
|
|
171
|
-
last_used_at = excluded.last_used_at
|
|
172
|
-
`,
|
|
173
|
-
).run(
|
|
174
|
-
result.normalizedKey,
|
|
175
|
-
result.original,
|
|
176
|
-
result.lat,
|
|
177
|
-
result.lng,
|
|
178
|
-
result.formatted ?? null,
|
|
179
|
-
result.countryCode?.toUpperCase() ?? null,
|
|
180
|
-
result.confidence ?? null,
|
|
181
|
-
result.provider,
|
|
182
|
-
result.approxRadiusM ?? null,
|
|
183
|
-
JSON.stringify(result.bounds ?? {}),
|
|
184
|
-
JSON.stringify(result.components ?? {}),
|
|
185
|
-
now,
|
|
186
|
-
now,
|
|
187
|
-
);
|
|
188
|
-
db.prepare(
|
|
189
|
-
"delete from geocoded_locations_unresolved where normalized_key = ?",
|
|
190
|
-
).run(result.normalizedKey);
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
export function storeUnresolvedGeocode(
|
|
194
|
-
normalizedKey: string,
|
|
195
|
-
original: string,
|
|
196
|
-
reason: string,
|
|
197
|
-
db: Database = getNativeDb(),
|
|
198
|
-
) {
|
|
199
|
-
const now = new Date().toISOString();
|
|
200
|
-
const ttl = new Date(Date.now() + 7 * 24 * 60 * 60_000).toISOString();
|
|
201
|
-
db.prepare(
|
|
202
|
-
`
|
|
203
|
-
insert into geocoded_locations_unresolved (
|
|
204
|
-
normalized_key, original, reason, last_attempted_at, ttl_until
|
|
205
|
-
) values (?, ?, ?, ?, ?)
|
|
206
|
-
on conflict(normalized_key) do update set
|
|
207
|
-
original = excluded.original,
|
|
208
|
-
reason = excluded.reason,
|
|
209
|
-
last_attempted_at = excluded.last_attempted_at,
|
|
210
|
-
ttl_until = excluded.ttl_until
|
|
211
|
-
`,
|
|
212
|
-
).run(normalizedKey, original, reason, now, ttl);
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
export async function geocodeLocation(
|
|
216
|
-
original: string,
|
|
217
|
-
db: Database = getNativeDb(),
|
|
218
|
-
signal?: AbortSignal,
|
|
219
|
-
): Promise<GeocodeResult | null> {
|
|
220
|
-
if (signal?.aborted) throw new Error("geocode aborted");
|
|
221
|
-
const normalizedKey = normalizeLocationKey(original);
|
|
222
|
-
if (!normalizedKey) return null;
|
|
223
|
-
|
|
224
|
-
const coord = coordinatesFromLocationKey(normalizedKey);
|
|
225
|
-
if (coord) {
|
|
226
|
-
const result: GeocodeResult = {
|
|
227
|
-
normalizedKey,
|
|
228
|
-
original,
|
|
229
|
-
lat: coord.lat,
|
|
230
|
-
lng: coord.lng,
|
|
231
|
-
formatted: original,
|
|
232
|
-
provider: "coords",
|
|
233
|
-
approxRadiusM: 500,
|
|
234
|
-
};
|
|
235
|
-
storeGeocode(result, db);
|
|
236
|
-
return result;
|
|
237
|
-
}
|
|
238
|
-
|
|
239
|
-
const key = getOpenCageApiKey();
|
|
240
|
-
if (!key) return null;
|
|
241
|
-
|
|
242
|
-
const url = new URL("https://api.opencagedata.com/geocode/v1/json");
|
|
243
|
-
url.searchParams.set("q", original);
|
|
244
|
-
url.searchParams.set("key", key);
|
|
245
|
-
url.searchParams.set("limit", "1");
|
|
246
|
-
url.searchParams.set("no_annotations", "1");
|
|
247
|
-
|
|
248
|
-
const response = await fetch(url, { signal });
|
|
249
|
-
if (!response.ok) {
|
|
250
|
-
if (response.status === 429) {
|
|
251
|
-
throw new GeocodeRateLimitError();
|
|
252
|
-
}
|
|
253
|
-
storeUnresolvedGeocode(
|
|
254
|
-
normalizedKey,
|
|
255
|
-
original,
|
|
256
|
-
`opencage:${String(response.status)}`,
|
|
257
|
-
db,
|
|
258
|
-
);
|
|
259
|
-
return null;
|
|
260
|
-
}
|
|
261
|
-
const payload = OpenCageResponseSchema.parse(await response.json());
|
|
262
|
-
const first = payload.results[0];
|
|
263
|
-
if (!first) {
|
|
264
|
-
storeUnresolvedGeocode(normalizedKey, original, "no-result", db);
|
|
265
|
-
return null;
|
|
266
|
-
}
|
|
267
|
-
|
|
268
|
-
const bounds = first.bounds as Record<string, unknown> | undefined;
|
|
269
|
-
const result: GeocodeResult = {
|
|
270
|
-
normalizedKey,
|
|
271
|
-
original,
|
|
272
|
-
lat: first.geometry.lat,
|
|
273
|
-
lng: first.geometry.lng,
|
|
274
|
-
formatted: first.formatted,
|
|
275
|
-
countryCode: first.components?.country_code?.toUpperCase(),
|
|
276
|
-
confidence: first.confidence,
|
|
277
|
-
provider: "opencage",
|
|
278
|
-
approxRadiusM: approximateRadiusM(bounds),
|
|
279
|
-
bounds,
|
|
280
|
-
components: first.components as Record<string, unknown> | undefined,
|
|
281
|
-
};
|
|
282
|
-
storeGeocode(result, db);
|
|
283
|
-
return result;
|
|
284
|
-
}
|
|
285
|
-
|
|
286
|
-
function parseJsonRecord(value: unknown) {
|
|
287
|
-
if (typeof value !== "string" || value.length === 0) return undefined;
|
|
288
|
-
try {
|
|
289
|
-
const parsed = JSON.parse(value) as unknown;
|
|
290
|
-
return parsed && typeof parsed === "object" && !Array.isArray(parsed)
|
|
291
|
-
? (parsed as Record<string, unknown>)
|
|
292
|
-
: undefined;
|
|
293
|
-
} catch {
|
|
294
|
-
return undefined;
|
|
295
|
-
}
|
|
296
|
-
}
|
package/src/lib/http-effect.ts
DELETED
|
@@ -1,222 +0,0 @@
|
|
|
1
|
-
import { Effect } from "effect";
|
|
2
|
-
import { runEffectPromise, tryPromise } from "./effect-runtime";
|
|
3
|
-
|
|
4
|
-
const LOCAL_HOSTS = new Set(["localhost", "127.0.0.1", "::1", "[::1]"]);
|
|
5
|
-
|
|
6
|
-
export function jsonResponse(data: unknown, init?: ResponseInit) {
|
|
7
|
-
const headers = new Headers(init?.headers);
|
|
8
|
-
headers.set("content-type", "application/json");
|
|
9
|
-
return new Response(JSON.stringify(data), {
|
|
10
|
-
...init,
|
|
11
|
-
headers,
|
|
12
|
-
});
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
export function requestJsonEffect<T = Record<string, unknown>>(
|
|
16
|
-
request: Request,
|
|
17
|
-
fallback?: T,
|
|
18
|
-
): Effect.Effect<T, unknown> {
|
|
19
|
-
return tryPromise(() => request.json() as Promise<T>).pipe(
|
|
20
|
-
Effect.catchAll((error) =>
|
|
21
|
-
fallback === undefined ? Effect.fail(error) : Effect.succeed(fallback),
|
|
22
|
-
),
|
|
23
|
-
);
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
export function runRouteEffect<A, E>(effect: Effect.Effect<A, E>): Promise<A> {
|
|
27
|
-
return runEffectPromise(effect);
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
function normalizedHost(value: string) {
|
|
31
|
-
return value.toLowerCase().replace(/^\[|\]$/g, "");
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
function isTestEnvironment() {
|
|
35
|
-
return process.env.NODE_ENV === "test" || process.env.VITEST === "true";
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
function requestCookie(request: Request, name: string) {
|
|
39
|
-
const cookie = request.headers.get("cookie");
|
|
40
|
-
if (!cookie) return { found: false, value: null };
|
|
41
|
-
for (const part of cookie.split(";")) {
|
|
42
|
-
const [key, ...rest] = part.trim().split("=");
|
|
43
|
-
if (key === name) {
|
|
44
|
-
try {
|
|
45
|
-
return { found: true, value: decodeURIComponent(rest.join("=")) };
|
|
46
|
-
} catch {
|
|
47
|
-
return { found: true, value: null };
|
|
48
|
-
}
|
|
49
|
-
}
|
|
50
|
-
}
|
|
51
|
-
return { found: false, value: null };
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
function configuredWebToken() {
|
|
55
|
-
const token = process.env.BIRDCLAW_WEB_TOKEN?.trim();
|
|
56
|
-
return token || null;
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
function requestWebTokenStatus(request: Request) {
|
|
60
|
-
const token = configuredWebToken();
|
|
61
|
-
if (!token)
|
|
62
|
-
return {
|
|
63
|
-
configured: false,
|
|
64
|
-
valid: false,
|
|
65
|
-
fromCookie: false,
|
|
66
|
-
provided: false,
|
|
67
|
-
};
|
|
68
|
-
const headerToken = request.headers.get("x-birdclaw-token");
|
|
69
|
-
const cookieToken = requestCookie(request, "birdclaw_token");
|
|
70
|
-
const provided = headerToken !== null || cookieToken.found;
|
|
71
|
-
const cookieValue = cookieToken.value;
|
|
72
|
-
const valid = headerToken === token || cookieValue === token;
|
|
73
|
-
return {
|
|
74
|
-
configured: true,
|
|
75
|
-
valid,
|
|
76
|
-
fromCookie: cookieValue === token && headerToken !== token,
|
|
77
|
-
provided,
|
|
78
|
-
};
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
function isLocalWebHost(value: string) {
|
|
82
|
-
const host = normalizedHost(value);
|
|
83
|
-
return LOCAL_HOSTS.has(host) || host.endsWith(".localhost");
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
function allowsUnauthenticatedLocalWeb() {
|
|
87
|
-
return process.env.BIRDCLAW_LOCAL_WEB === "1";
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
function hasForwardedRequestHeaders(request: Request) {
|
|
91
|
-
return (
|
|
92
|
-
request.headers.has("forwarded") ||
|
|
93
|
-
request.headers.has("x-forwarded-for") ||
|
|
94
|
-
request.headers.has("x-forwarded-proto") ||
|
|
95
|
-
request.headers.has("x-forwarded-host") ||
|
|
96
|
-
request.headers.has("x-real-ip")
|
|
97
|
-
);
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
function firstForwardedValue(value: string | null) {
|
|
101
|
-
return value?.split(",")[0]?.trim() || null;
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
function forwardedHeaderPair(request: Request, key: "host" | "proto") {
|
|
105
|
-
const forwarded = firstForwardedValue(request.headers.get("forwarded"));
|
|
106
|
-
if (!forwarded) return null;
|
|
107
|
-
for (const part of forwarded.split(";")) {
|
|
108
|
-
const [name, ...rest] = part.trim().split("=");
|
|
109
|
-
if (name?.toLowerCase() !== key) continue;
|
|
110
|
-
const value = rest.join("=").trim().replace(/^"|"$/g, "");
|
|
111
|
-
return value || null;
|
|
112
|
-
}
|
|
113
|
-
return null;
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
function forwardedOrigin(request: Request) {
|
|
117
|
-
const proto =
|
|
118
|
-
firstForwardedValue(request.headers.get("x-forwarded-proto")) ??
|
|
119
|
-
forwardedHeaderPair(request, "proto");
|
|
120
|
-
const host =
|
|
121
|
-
firstForwardedValue(request.headers.get("x-forwarded-host")) ??
|
|
122
|
-
forwardedHeaderPair(request, "host");
|
|
123
|
-
if (!proto || !host) return null;
|
|
124
|
-
const normalizedProto = proto.toLowerCase();
|
|
125
|
-
if (normalizedProto !== "http" && normalizedProto !== "https") return null;
|
|
126
|
-
return `${normalizedProto}://${host}`;
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
function sameRequestOrigin(request: Request, origin: string, url: URL) {
|
|
130
|
-
return origin === url.origin || origin === forwardedOrigin(request);
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
export function sensitiveRequestErrorResponse(request: Request) {
|
|
134
|
-
const url = new URL(request.url);
|
|
135
|
-
const token = requestWebTokenStatus(request);
|
|
136
|
-
const isLocalRequest =
|
|
137
|
-
allowsUnauthenticatedLocalWeb() &&
|
|
138
|
-
isLocalWebHost(url.hostname) &&
|
|
139
|
-
!hasForwardedRequestHeaders(request);
|
|
140
|
-
const fetchSite = request.headers.get("sec-fetch-site");
|
|
141
|
-
const origin = request.headers.get("origin");
|
|
142
|
-
const allowRemoteEnv = process.env.BIRDCLAW_ALLOW_REMOTE_WEB === "1";
|
|
143
|
-
const allowTrustedRemote = allowRemoteEnv && !token.configured;
|
|
144
|
-
|
|
145
|
-
if (isTestEnvironment() && !token.valid) return null;
|
|
146
|
-
|
|
147
|
-
if (origin && !sameRequestOrigin(request, origin, url)) {
|
|
148
|
-
return jsonResponse(
|
|
149
|
-
{ ok: false, message: "Cross-origin web API access is disabled" },
|
|
150
|
-
{ status: 403 },
|
|
151
|
-
);
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
if (fetchSite === "cross-site") {
|
|
155
|
-
return jsonResponse(
|
|
156
|
-
{ ok: false, message: "Cross-site web API access is disabled" },
|
|
157
|
-
{ status: 403 },
|
|
158
|
-
);
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
if (
|
|
162
|
-
!isTestEnvironment() &&
|
|
163
|
-
!token.configured &&
|
|
164
|
-
!isLocalRequest &&
|
|
165
|
-
!allowTrustedRemote
|
|
166
|
-
) {
|
|
167
|
-
return jsonResponse(
|
|
168
|
-
{
|
|
169
|
-
ok: false,
|
|
170
|
-
message:
|
|
171
|
-
"Remote API access requires BIRDCLAW_ALLOW_REMOTE_WEB=1 for a trusted private proxy, or BIRDCLAW_WEB_TOKEN for tokened access",
|
|
172
|
-
},
|
|
173
|
-
{ status: 403 },
|
|
174
|
-
);
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
if (token.configured && !token.valid && (token.provided || !isLocalRequest)) {
|
|
178
|
-
return jsonResponse(
|
|
179
|
-
{ ok: false, message: "Invalid web token" },
|
|
180
|
-
{ status: 403 },
|
|
181
|
-
);
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
const allowRemote = allowRemoteEnv && (token.valid || allowTrustedRemote);
|
|
185
|
-
if (!allowRemote && !isLocalRequest) {
|
|
186
|
-
return jsonResponse(
|
|
187
|
-
{ ok: false, message: "Remote web API access is disabled" },
|
|
188
|
-
{ status: 403 },
|
|
189
|
-
);
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
if (
|
|
193
|
-
token.fromCookie &&
|
|
194
|
-
fetchSite !== "same-origin" &&
|
|
195
|
-
fetchSite !== "same-site"
|
|
196
|
-
) {
|
|
197
|
-
return jsonResponse(
|
|
198
|
-
{ ok: false, message: "Same-origin browser request required" },
|
|
199
|
-
{ status: 403 },
|
|
200
|
-
);
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
return null;
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
export function parseBoundedInteger(
|
|
207
|
-
value: number | string | null | undefined,
|
|
208
|
-
{
|
|
209
|
-
defaultValue,
|
|
210
|
-
min = 1,
|
|
211
|
-
max,
|
|
212
|
-
}: { defaultValue?: number; min?: number; max: number },
|
|
213
|
-
) {
|
|
214
|
-
if (value === null || value === undefined || value === "")
|
|
215
|
-
return defaultValue;
|
|
216
|
-
if (typeof value === "string" && !/^\d+$/.test(value.trim())) {
|
|
217
|
-
return defaultValue;
|
|
218
|
-
}
|
|
219
|
-
const parsed = typeof value === "number" ? value : Number(value);
|
|
220
|
-
if (!Number.isSafeInteger(parsed)) return defaultValue;
|
|
221
|
-
return Math.min(max, Math.max(min, parsed));
|
|
222
|
-
}
|