nandi/gleanpublic⑂ Fork 0
⑂ cef27d7
Commits
⬇ Clone ▾
git clone https://git.rickub.com/nandi/glean.git
git clone ssh://git@rickub.com/nandi/glean.git

Host key fingerprint (ed25519): SHA256:iycHnxEyq0Q7uyVpB7JlznP0G7JrTPXLYRcAU5CSLhc — verify it before your first connect.

feat: glean mvpUnverified

Julien Robert committed 2026-04-20T09:49:27+02:00 Browse files
cef27d7 parent: ac90b90
modified .gitignore +7 -0
@@ -20,3 +20,10 @@ go.work.sum
2020
2121 # env file
2222 .env
23+
24+# database
25+*.db
26+*.db.journal
27+
28+# tailwind output
29+static/output.css
@@ -20,3 +20,10 @@ go.work.sum
20 20
21 # env file21 # env file
22 .env22 .env
23+
24+# database
25+*.db
26+*.db.journal
27+
28+# tailwind output
29+static/output.css
added docs/design.md +315 -0
new file mode 100644
@@ -0,0 +1,315 @@
1+# Design System Inspired by Spotify
2+
3+## 1. Visual Theme & Atmosphere
4+
5+Spotify's web interface is a dark, immersive music player that wraps listeners in a near-black cocoon (`#121212`, `#181818`, `#1f1f1f`) where album art and content become the primary source of color. The design philosophy is "content-first darkness" — the UI recedes into shadow so that music, podcasts, and playlists can glow. Every surface is a shade of charcoal, creating a theater-like environment where the only true color comes from the brand Accent Purple (`#a855f7`) and the album artwork itself.
6+
7+The typography uses SpotifyMixUI and SpotifyMixUITitle — proprietary fonts from the CircularSp family (Circular by Lineto, customized for Spotify) with an extensive fallback stack that includes Arabic, Hebrew, Cyrillic, Greek, Devanagari, and CJK fonts, reflecting Spotify's global reach. The type system is compact and functional: 700 (bold) for emphasis and navigation, 600 (semibold) for secondary emphasis, and 400 (regular) for body. Buttons use uppercase with positive letter-spacing (1.4px–2px) for a systematic, label-like quality.
8+
9+What distinguishes Spotify is its pill-and-circle geometry. Primary buttons use 500px–9999px radius (full pill), circular play buttons use 50% radius, and search inputs are 500px pills. Combined with heavy shadows (`rgba(0,0,0,0.5) 0px 8px 24px`) on elevated elements and a unique inset border-shadow combo (`rgb(18,18,18) 0px 1px 0px, rgb(124,124,124) 0px 0px 0px 1px inset`), the result is an interface that feels like a premium audio device — tactile, rounded, and built for touch.
10+
11+**Key Characteristics:**
12+
13+- Near-black immersive dark theme (`#121212`–`#1f1f1f`) — UI disappears behind content
14+- Accent Purple (`#a855f7`) as singular brand accent — never decorative, always functional
15+- SpotifyMixUI/CircularSp font family with global script support
16+- Pill buttons (500px–9999px) and circular controls (50%) — rounded, touch-optimized
17+- Uppercase button labels with wide letter-spacing (1.4px–2px)
18+- Heavy shadows on elevated elements (`rgba(0,0,0,0.5) 0px 8px 24px`)
19+- Semantic colors: negative red (`#f3727f`), warning orange (`#ffa42b`), announcement blue (`#539df5`)
20+- Album art as the primary color source — the UI is achromatic by design
21+- Light/dark theme toggle — persisted in localStorage, dark is default
22+
23+## 2. Color Palette & Roles
24+
25+All theme-dependent colors use CSS custom properties defined on `:root` (dark, default) and `[data-theme="light"]` (light). Accent and semantic colors are constant across themes.
26+
27+### Tailwind Token Mapping
28+
29+The `spot.*` namespace provides semantic tokens. Theme-dependent tokens resolve to CSS variables.
30+
31+| Token | CSS Variable | Dark (default) | Light | Use |
32+| ---------------------- | --------------------- | -------------------------------- | -------------------------------- | -------------------------------- |
33+| `spot.purple` | — | `#a855f7` | `#a855f7` | Primary accent |
34+| `spot.purple-border` | — | `#9333ea` | `#9333ea` | Accent border variant |
35+| `spot.bg` | `--spot-bg` | `#121212` | `#f5f5f5` | Page background |
36+| `spot.surface` | `--spot-surface` | `#181818` | `#ffffff` | Cards, containers, sidebar |
37+| `spot.hover` | `--spot-hover` | `#1f1f1f` | `#f3f4f6` | Interactive surface, input bg |
38+| `spot.hover-50` | `--spot-hover-50` | `rgba(31,31,31,0.5)` | `rgba(243,244,246,0.5)` | Card hover with transparency |
39+| `spot.text` | `--spot-text` | `#ffffff` | `#111827` | Primary text, headings |
40+| `spot.secondary` | `--spot-secondary` | `#b3b3b3` | `#6b7280` | Secondary text, muted labels |
41+| `spot.body` | `--spot-body` | `#cbcbcb` | `#374151` | Article body text |
42+| `spot.muted` | `--spot-muted` | `#4d4d4d` | `#9ca3af` | Very muted text, rank numbers |
43+| `spot.divider` | `--spot-divider` | `rgba(77,77,77,0.2)` | `#e5e7eb` | Subtle border dividers |
44+| `spot.divider-30` | `--spot-divider-30` | `rgba(77,77,77,0.3)` | `#d1d5db` | Slightly stronger dividers, hr |
45+| `spot.outline` | `--spot-outline` | `#7c7c7c` | `#d1d5db` | Visible borders on buttons/inputs|
46+| `spot.placeholder` | `--spot-placeholder` | `#4d4d4d` | `#9ca3af` | Input placeholder text |
47+| `spot.active-pill-bg` | `--spot-active-bg` | `#ffffff` | `#111827` | Active filter pill background |
48+| `spot.active-pill-text`| `--spot-active-text` | `#121212` | `#ffffff` | Active filter pill text |
49+| `spot.shadow` | `--spot-shadow` | `rgba(0,0,0,0.3) 0px 8px 8px` | `rgba(0,0,0,0.08) 0px 2px 8px` | Card elevation shadow |
50+| `spot.shadow-heavy` | `--spot-shadow-heavy` | `rgba(0,0,0,0.5) 0px 8px 24px` | `rgba(0,0,0,0.1) 0px 4px 16px` | Dialog/elevated panel shadow |
51+| `spot.red` | — | `#f3727f` | `#f3727f` | Error states |
52+| `spot.orange` | — | `#ffa42b` | `#ffa42b` | Warning states |
53+| `spot.blue` | — | `#539df5` | `#539df5` | Info states |
54+
55+### Shadows
56+
57+- **Card** (`var(--spot-shadow)`): Cards, dropdowns
58+- **Heavy** (`var(--spot-shadow-heavy)`): Dialogs, menus, elevated panels
59+- **Inset Border** (`rgb(18,18,18) 0px 1px 0px, rgb(124,124,124) 0px 0px 0px 1px inset`): Input border-shadow combo (dark only)
60+
61+## 3. Theme Toggle
62+
63+The app supports dark (default) and light themes. Theme preference is stored in `localStorage` under the key `theme`.
64+
65+### Implementation
66+
67+- CSS custom properties are defined on `:root` for dark and `[data-theme="light"]` for light
68+- A `<script>` block runs before render to set `data-theme` on `<html>`, preventing flash of wrong theme
69+- Toggle buttons appear in the sidebar footer (desktop) and mobile top bar
70+- Icon: moon (when in dark mode) / sun (when in light mode)
71+- Default: dark
72+
73+### CSS Variables
74+
75+```css
76+:root {
77+ --spot-bg: #121212;
78+ --spot-surface: #181818;
79+ --spot-hover: #1f1f1f;
80+ --spot-hover-50: rgba(31,31,31,0.5);
81+ --spot-text: #ffffff;
82+ --spot-secondary: #b3b3b3;
83+ --spot-body: #cbcbcb;
84+ --spot-muted: #4d4d4d;
85+ --spot-divider: rgba(77,77,77,0.2);
86+ --spot-divider-30: rgba(77,77,77,0.3);
87+ --spot-outline: #7c7c7c;
88+ --spot-placeholder: #4d4d4d;
89+ --spot-active-bg: #ffffff;
90+ --spot-active-text: #121212;
91+ --spot-shadow: rgba(0,0,0,0.3) 0px 8px 8px;
92+ --spot-shadow-heavy: rgba(0,0,0,0.5) 0px 8px 24px;
93+}
94+[data-theme="light"] {
95+ --spot-bg: #f5f5f5;
96+ --spot-surface: #ffffff;
97+ --spot-hover: #f3f4f6;
98+ --spot-hover-50: rgba(243,244,246,0.5);
99+ --spot-text: #111827;
100+ --spot-secondary: #6b7280;
101+ --spot-body: #374151;
102+ --spot-muted: #9ca3af;
103+ --spot-divider: #e5e7eb;
104+ --spot-divider-30: #d1d5db;
105+ --spot-outline: #d1d5db;
106+ --spot-placeholder: #9ca3af;
107+ --spot-active-bg: #111827;
108+ --spot-active-text: #ffffff;
109+ --spot-shadow: rgba(0,0,0,0.08) 0px 2px 8px;
110+ --spot-shadow-heavy: rgba(0,0,0,0.1) 0px 4px 16px;
111+}
112+```
113+
114+## 4. Typography Rules
115+
116+### Font Families
117+
118+- **Title**: `SpotifyMixUITitle`, fallbacks: `CircularSp-Arab, CircularSp-Hebr, CircularSp-Cyrl, CircularSp-Grek, CircularSp-Deva, Helvetica Neue, helvetica, arial, Hiragino Sans, Hiragino Kaku Gothic ProN, Meiryo, MS Gothic`
119+- **UI / Body**: `SpotifyMixUI`, same fallback stack
120+
121+### Hierarchy
122+
123+| Role | Font | Size | Weight | Line Height | Letter Spacing | Notes |
124+| ---------------- | ----------------- | ---------------- | ------- | ------------ | -------------- | ---------------------------- |
125+| Section Title | SpotifyMixUITitle | 24px (1.50rem) | 700 | normal | normal | Bold title weight |
126+| Feature Heading | SpotifyMixUI | 18px (1.13rem) | 600 | 1.30 (tight) | normal | Semibold section heads |
127+| Body Bold | SpotifyMixUI | 16px (1.00rem) | 700 | normal | normal | Emphasized text |
128+| Body | SpotifyMixUI | 16px (1.00rem) | 400 | normal | normal | Standard body |
129+| Button Uppercase | SpotifyMixUI | 14px (0.88rem) | 600–700 | 1.00 (tight) | 1.4px–2px | `text-transform: uppercase` |
130+| Button | SpotifyMixUI | 14px (0.88rem) | 700 | normal | 0.14px | Standard button |
131+| Nav Link Bold | SpotifyMixUI | 14px (0.88rem) | 700 | normal | normal | Navigation |
132+| Nav Link | SpotifyMixUI | 14px (0.88rem) | 400 | normal | normal | Inactive nav |
133+| Caption Bold | SpotifyMixUI | 14px (0.88rem) | 700 | 1.50–1.54 | normal | Bold metadata |
134+| Caption | SpotifyMixUI | 14px (0.88rem) | 400 | normal | normal | Metadata |
135+| Small Bold | SpotifyMixUI | 12px (0.75rem) | 700 | 1.50 | normal | Tags, counts |
136+| Small | SpotifyMixUI | 12px (0.75rem) | 400 | normal | normal | Fine print |
137+| Badge | SpotifyMixUI | 10.5px (0.66rem) | 600 | 1.33 | normal | `text-transform: capitalize` |
138+| Micro | SpotifyMixUI | 10px (0.63rem) | 400 | normal | normal | Smallest text |
139+
140+### Principles
141+
142+- **Bold/regular binary**: Most text is either 700 (bold) or 400 (regular), with 600 used sparingly. This creates a clear visual hierarchy through weight contrast rather than size variation.
143+- **Uppercase buttons as system**: Button labels use uppercase + wide letter-spacing (1.4px–2px), creating a systematic "label" voice distinct from content text.
144+- **Compact sizing**: The range is 10px–24px — narrower than most systems. Spotify's type is compact and functional, designed for scanning playlists, not reading articles.
145+- **Global script support**: The extensive fallback stack (Arabic, Hebrew, Cyrillic, Greek, Devanagari, CJK) reflects Spotify's 180+ market reach.
146+
147+## 5. Component Stylings
148+
149+### Buttons
150+
151+**Accent Pill**
152+
153+- Background: `#a855f7`
154+- Text: `var(--spot-bg)` (dark in dark mode, light in light mode)
155+- Padding: 8px 16px
156+- Radius: 9999px (full pill)
157+- Use: Primary CTAs, add buttons
158+
159+**Dark Pill**
160+
161+- Background: `var(--spot-hover)`
162+- Text: `var(--spot-text)` or `var(--spot-secondary)`
163+- Padding: 8px 16px
164+- Radius: 9999px (full pill)
165+- Use: Navigation pills, secondary actions
166+
167+**Outlined Pill**
168+
169+- Background: transparent
170+- Text: `var(--spot-text)`
171+- Border: `1px solid var(--spot-outline)`
172+- Radius: 9999px
173+- Use: Follow buttons, secondary actions
174+
175+**Circular Play**
176+
177+- Background: `#a855f7`
178+- Text: `var(--spot-bg)`
179+- Padding: 12px
180+- Radius: 50% (circle)
181+- Use: Play/pause controls
182+
183+### Cards & Containers
184+
185+- Background: `var(--spot-surface)`
186+- Radius: 6px–8px
187+- No visible borders on most cards
188+- Hover: `var(--spot-hover-50)` background
189+- Shadow: `var(--spot-shadow)` on elevated
190+
191+### Inputs
192+
193+- Background: `var(--spot-hover)`
194+- Text: `var(--spot-text)`
195+- Radius: 500px (pill)
196+- Focus ring: `#a855f7`
197+- Placeholder: `var(--spot-placeholder)`
198+
199+### Navigation
200+
201+- Sidebar: `var(--spot-bg)` background
202+- Active items: 14px weight 700, `var(--spot-text)`
203+- Inactive items: 14px weight 400, `var(--spot-secondary)`
204+- Circular icon buttons (50% radius)
205+- Brand logo top-left in purple
206+
207+## 6. Layout Principles
208+
209+### Spacing System
210+
211+- Base unit: 8px
212+- Scale: 1px, 2px, 3px, 4px, 5px, 6px, 8px, 10px, 12px, 14px, 15px, 16px, 20px
213+
214+### Grid & Container
215+
216+- Sidebar (fixed) + main content area
217+- Grid-based album/playlist cards
218+- Responsive content area fills remaining space
219+
220+### Whitespace Philosophy
221+
222+- **Dark compression**: Spotify packs content densely — playlist grids, track lists, and navigation are all tightly spaced. The dark background provides visual rest between elements without needing large gaps.
223+- **Content density over breathing room**: This is an app, not a marketing site. Every pixel serves the listening experience.
224+
225+### Border Radius Scale
226+
227+- Minimal (2px): Badges, explicit tags
228+- Subtle (4px): Inputs, small elements
229+- Standard (6px): Album art containers, cards
230+- Comfortable (8px): Sections, dialogs
231+- Medium (10px–20px): Panels, overlay elements
232+- Large (100px): Large pill buttons
233+- Pill (500px): Primary buttons, search input
234+- Full Pill (9999px): Navigation pills, search
235+- Circle (50%): Play buttons, avatars, icons
236+
237+## 7. Depth & Elevation
238+
239+| Level | Treatment | Use |
240+| ------------------ | ---------------------------- | ------------------------------ |
241+| Base (Level 0) | `var(--spot-bg)` background | Deepest layer, page background |
242+| Surface (Level 1) | `var(--spot-surface)` | Cards, sidebar, containers |
243+| Elevated (Level 2) | `var(--spot-shadow)` | Dropdown menus, hover cards |
244+| Dialog (Level 3) | `var(--spot-shadow-heavy)` | Modals, overlays, menus |
245+
246+## 8. Do's and Don'ts
247+
248+### Do
249+
250+- Use semantic color tokens (`spot-bg`, `spot-surface`, `spot-text`, etc.) — they adapt to theme
251+- Apply Accent Purple (`#a855f7`) only for play controls, active states, and primary CTAs
252+- Use pill shape (500px–9999px) for all buttons — circular (50%) for play controls
253+- Apply uppercase + wide letter-spacing (1.4px–2px) on button labels
254+- Keep typography compact (10px–24px range) — this is an app, not a magazine
255+- Use theme-aware shadows via CSS variables
256+- Test all components in both dark and light themes
257+
258+### Don't
259+
260+- Don't use Accent Purple decoratively or on backgrounds — it's functional only
261+- Don't hardcode theme-dependent colors — use CSS variable-backed tokens
262+- Don't skip the pill/circle geometry on buttons — square buttons break the identity
263+- Don't use hardcoded shadow values — use `shadow-spot` and `shadow-spot-heavy`
264+- Don't add additional brand colors — purple + achromatic grays is the complete palette
265+- Don't use `text-white` or `bg-white` directly — use `text-spot-text` and `bg-spot-active-pill-bg`
266+- Don't expose raw gray borders — use `border-spot-divider` or `border-spot-outline`
267+
268+## 9. Responsive Behavior
269+
270+### Breakpoints
271+
272+| Name | Width | Key Changes |
273+| ------------- | ----------- | --------------------- |
274+| Mobile Small | <425px | Compact mobile layout |
275+| Mobile | 425–576px | Standard mobile |
276+| Tablet | 576–768px | 2-column grid |
277+| Tablet Large | 768–896px | Expanded layout |
278+| Desktop Small | 896–1024px | Sidebar visible |
279+| Desktop | 1024–1280px | Full desktop layout |
280+| Large Desktop | >1280px | Expanded grid |
281+
282+### Collapsing Strategy
283+
284+- Sidebar: full → collapsed → hidden
285+- Album grid: 5 columns → 3 → 2 → 1
286+- Search: pill input maintained, width adjusts
287+- Navigation: sidebar → bottom bar on mobile
288+- Theme toggle: always accessible in sidebar footer / mobile header
289+
290+## 10. Agent Prompt Guide
291+
292+### Quick Color Reference
293+
294+| Role | Token | Value (dark) |
295+| -------------- | -------------------- | -------------- |
296+| Background | `bg-spot-bg` | `#121212` |
297+| Surface | `bg-spot-surface` | `#181818` |
298+| Hover | `bg-spot-hover` | `#1f1f1f` |
299+| Text primary | `text-spot-text` | `#ffffff` |
300+| Text secondary | `text-spot-secondary`| `#b3b3b3` |
301+| Text body | `text-spot-body` | `#cbcbcb` |
302+| Text muted | `text-spot-muted` | `#4d4d4d` |
303+| Accent | `text-spot-purple` | `#a855f7` |
304+| Divider | `border-spot-divider`| `rgba(...)` |
305+| Outline | `border-spot-outline`| `#7c7c7c` |
306+| Error | `text-spot-red` | `#f3727f` |
307+
308+### Iteration Guide
309+
310+1. Use semantic tokens (`spot-bg`, `spot-surface`, `spot-text`) — they handle theme switching
311+2. Accent Purple (`spot-purple`) for functional highlights only (active, CTA)
312+3. Pill everything — 500px for large, 9999px for small, 50% for circular
313+4. Uppercase + wide tracking on buttons — the systematic label voice
314+5. Theme-aware shadows via `shadow-spot` and `shadow-spot-heavy`
315+6. Never hardcode `text-white` or `bg-white` — use semantic tokens
new file mode 100644
@@ -0,0 +1,315 @@
1+# Design System Inspired by Spotify
2+
3+## 1. Visual Theme & Atmosphere
4+
5+Spotify's web interface is a dark, immersive music player that wraps listeners in a near-black cocoon (`#121212`, `#181818`, `#1f1f1f`) where album art and content become the primary source of color. The design philosophy is "content-first darkness" — the UI recedes into shadow so that music, podcasts, and playlists can glow. Every surface is a shade of charcoal, creating a theater-like environment where the only true color comes from the brand Accent Purple (`#a855f7`) and the album artwork itself.
6+
7+The typography uses SpotifyMixUI and SpotifyMixUITitle — proprietary fonts from the CircularSp family (Circular by Lineto, customized for Spotify) with an extensive fallback stack that includes Arabic, Hebrew, Cyrillic, Greek, Devanagari, and CJK fonts, reflecting Spotify's global reach. The type system is compact and functional: 700 (bold) for emphasis and navigation, 600 (semibold) for secondary emphasis, and 400 (regular) for body. Buttons use uppercase with positive letter-spacing (1.4px–2px) for a systematic, label-like quality.
8+
9+What distinguishes Spotify is its pill-and-circle geometry. Primary buttons use 500px–9999px radius (full pill), circular play buttons use 50% radius, and search inputs are 500px pills. Combined with heavy shadows (`rgba(0,0,0,0.5) 0px 8px 24px`) on elevated elements and a unique inset border-shadow combo (`rgb(18,18,18) 0px 1px 0px, rgb(124,124,124) 0px 0px 0px 1px inset`), the result is an interface that feels like a premium audio device — tactile, rounded, and built for touch.
10+
11+**Key Characteristics:**
12+
13+- Near-black immersive dark theme (`#121212`–`#1f1f1f`) — UI disappears behind content
14+- Accent Purple (`#a855f7`) as singular brand accent — never decorative, always functional
15+- SpotifyMixUI/CircularSp font family with global script support
16+- Pill buttons (500px–9999px) and circular controls (50%) — rounded, touch-optimized
17+- Uppercase button labels with wide letter-spacing (1.4px–2px)
18+- Heavy shadows on elevated elements (`rgba(0,0,0,0.5) 0px 8px 24px`)
19+- Semantic colors: negative red (`#f3727f`), warning orange (`#ffa42b`), announcement blue (`#539df5`)
20+- Album art as the primary color source — the UI is achromatic by design
21+- Light/dark theme toggle — persisted in localStorage, dark is default
22+
23+## 2. Color Palette & Roles
24+
25+All theme-dependent colors use CSS custom properties defined on `:root` (dark, default) and `[data-theme="light"]` (light). Accent and semantic colors are constant across themes.
26+
27+### Tailwind Token Mapping
28+
29+The `spot.*` namespace provides semantic tokens. Theme-dependent tokens resolve to CSS variables.
30+
31+| Token | CSS Variable | Dark (default) | Light | Use |
32+| ---------------------- | --------------------- | -------------------------------- | -------------------------------- | -------------------------------- |
33+| `spot.purple` | — | `#a855f7` | `#a855f7` | Primary accent |
34+| `spot.purple-border` | — | `#9333ea` | `#9333ea` | Accent border variant |
35+| `spot.bg` | `--spot-bg` | `#121212` | `#f5f5f5` | Page background |
36+| `spot.surface` | `--spot-surface` | `#181818` | `#ffffff` | Cards, containers, sidebar |
37+| `spot.hover` | `--spot-hover` | `#1f1f1f` | `#f3f4f6` | Interactive surface, input bg |
38+| `spot.hover-50` | `--spot-hover-50` | `rgba(31,31,31,0.5)` | `rgba(243,244,246,0.5)` | Card hover with transparency |
39+| `spot.text` | `--spot-text` | `#ffffff` | `#111827` | Primary text, headings |
40+| `spot.secondary` | `--spot-secondary` | `#b3b3b3` | `#6b7280` | Secondary text, muted labels |
41+| `spot.body` | `--spot-body` | `#cbcbcb` | `#374151` | Article body text |
42+| `spot.muted` | `--spot-muted` | `#4d4d4d` | `#9ca3af` | Very muted text, rank numbers |
43+| `spot.divider` | `--spot-divider` | `rgba(77,77,77,0.2)` | `#e5e7eb` | Subtle border dividers |
44+| `spot.divider-30` | `--spot-divider-30` | `rgba(77,77,77,0.3)` | `#d1d5db` | Slightly stronger dividers, hr |
45+| `spot.outline` | `--spot-outline` | `#7c7c7c` | `#d1d5db` | Visible borders on buttons/inputs|
46+| `spot.placeholder` | `--spot-placeholder` | `#4d4d4d` | `#9ca3af` | Input placeholder text |
47+| `spot.active-pill-bg` | `--spot-active-bg` | `#ffffff` | `#111827` | Active filter pill background |
48+| `spot.active-pill-text`| `--spot-active-text` | `#121212` | `#ffffff` | Active filter pill text |
49+| `spot.shadow` | `--spot-shadow` | `rgba(0,0,0,0.3) 0px 8px 8px` | `rgba(0,0,0,0.08) 0px 2px 8px` | Card elevation shadow |
50+| `spot.shadow-heavy` | `--spot-shadow-heavy` | `rgba(0,0,0,0.5) 0px 8px 24px` | `rgba(0,0,0,0.1) 0px 4px 16px` | Dialog/elevated panel shadow |
51+| `spot.red` | — | `#f3727f` | `#f3727f` | Error states |
52+| `spot.orange` | — | `#ffa42b` | `#ffa42b` | Warning states |
53+| `spot.blue` | — | `#539df5` | `#539df5` | Info states |
54+
55+### Shadows
56+
57+- **Card** (`var(--spot-shadow)`): Cards, dropdowns
58+- **Heavy** (`var(--spot-shadow-heavy)`): Dialogs, menus, elevated panels
59+- **Inset Border** (`rgb(18,18,18) 0px 1px 0px, rgb(124,124,124) 0px 0px 0px 1px inset`): Input border-shadow combo (dark only)
60+
61+## 3. Theme Toggle
62+
63+The app supports dark (default) and light themes. Theme preference is stored in `localStorage` under the key `theme`.
64+
65+### Implementation
66+
67+- CSS custom properties are defined on `:root` for dark and `[data-theme="light"]` for light
68+- A `<script>` block runs before render to set `data-theme` on `<html>`, preventing flash of wrong theme
69+- Toggle buttons appear in the sidebar footer (desktop) and mobile top bar
70+- Icon: moon (when in dark mode) / sun (when in light mode)
71+- Default: dark
72+
73+### CSS Variables
74+
75+```css
76+:root {
77+ --spot-bg: #121212;
78+ --spot-surface: #181818;
79+ --spot-hover: #1f1f1f;
80+ --spot-hover-50: rgba(31,31,31,0.5);
81+ --spot-text: #ffffff;
82+ --spot-secondary: #b3b3b3;
83+ --spot-body: #cbcbcb;
84+ --spot-muted: #4d4d4d;
85+ --spot-divider: rgba(77,77,77,0.2);
86+ --spot-divider-30: rgba(77,77,77,0.3);
87+ --spot-outline: #7c7c7c;
88+ --spot-placeholder: #4d4d4d;
89+ --spot-active-bg: #ffffff;
90+ --spot-active-text: #121212;
91+ --spot-shadow: rgba(0,0,0,0.3) 0px 8px 8px;
92+ --spot-shadow-heavy: rgba(0,0,0,0.5) 0px 8px 24px;
93+}
94+[data-theme="light"] {
95+ --spot-bg: #f5f5f5;
96+ --spot-surface: #ffffff;
97+ --spot-hover: #f3f4f6;
98+ --spot-hover-50: rgba(243,244,246,0.5);
99+ --spot-text: #111827;
100+ --spot-secondary: #6b7280;
101+ --spot-body: #374151;
102+ --spot-muted: #9ca3af;
103+ --spot-divider: #e5e7eb;
104+ --spot-divider-30: #d1d5db;
105+ --spot-outline: #d1d5db;
106+ --spot-placeholder: #9ca3af;
107+ --spot-active-bg: #111827;
108+ --spot-active-text: #ffffff;
109+ --spot-shadow: rgba(0,0,0,0.08) 0px 2px 8px;
110+ --spot-shadow-heavy: rgba(0,0,0,0.1) 0px 4px 16px;
111+}
112+```
113+
114+## 4. Typography Rules
115+
116+### Font Families
117+
118+- **Title**: `SpotifyMixUITitle`, fallbacks: `CircularSp-Arab, CircularSp-Hebr, CircularSp-Cyrl, CircularSp-Grek, CircularSp-Deva, Helvetica Neue, helvetica, arial, Hiragino Sans, Hiragino Kaku Gothic ProN, Meiryo, MS Gothic`
119+- **UI / Body**: `SpotifyMixUI`, same fallback stack
120+
121+### Hierarchy
122+
123+| Role | Font | Size | Weight | Line Height | Letter Spacing | Notes |
124+| ---------------- | ----------------- | ---------------- | ------- | ------------ | -------------- | ---------------------------- |
125+| Section Title | SpotifyMixUITitle | 24px (1.50rem) | 700 | normal | normal | Bold title weight |
126+| Feature Heading | SpotifyMixUI | 18px (1.13rem) | 600 | 1.30 (tight) | normal | Semibold section heads |
127+| Body Bold | SpotifyMixUI | 16px (1.00rem) | 700 | normal | normal | Emphasized text |
128+| Body | SpotifyMixUI | 16px (1.00rem) | 400 | normal | normal | Standard body |
129+| Button Uppercase | SpotifyMixUI | 14px (0.88rem) | 600–700 | 1.00 (tight) | 1.4px–2px | `text-transform: uppercase` |
130+| Button | SpotifyMixUI | 14px (0.88rem) | 700 | normal | 0.14px | Standard button |
131+| Nav Link Bold | SpotifyMixUI | 14px (0.88rem) | 700 | normal | normal | Navigation |
132+| Nav Link | SpotifyMixUI | 14px (0.88rem) | 400 | normal | normal | Inactive nav |
133+| Caption Bold | SpotifyMixUI | 14px (0.88rem) | 700 | 1.50–1.54 | normal | Bold metadata |
134+| Caption | SpotifyMixUI | 14px (0.88rem) | 400 | normal | normal | Metadata |
135+| Small Bold | SpotifyMixUI | 12px (0.75rem) | 700 | 1.50 | normal | Tags, counts |
136+| Small | SpotifyMixUI | 12px (0.75rem) | 400 | normal | normal | Fine print |
137+| Badge | SpotifyMixUI | 10.5px (0.66rem) | 600 | 1.33 | normal | `text-transform: capitalize` |
138+| Micro | SpotifyMixUI | 10px (0.63rem) | 400 | normal | normal | Smallest text |
139+
140+### Principles
141+
142+- **Bold/regular binary**: Most text is either 700 (bold) or 400 (regular), with 600 used sparingly. This creates a clear visual hierarchy through weight contrast rather than size variation.
143+- **Uppercase buttons as system**: Button labels use uppercase + wide letter-spacing (1.4px–2px), creating a systematic "label" voice distinct from content text.
144+- **Compact sizing**: The range is 10px–24px — narrower than most systems. Spotify's type is compact and functional, designed for scanning playlists, not reading articles.
145+- **Global script support**: The extensive fallback stack (Arabic, Hebrew, Cyrillic, Greek, Devanagari, CJK) reflects Spotify's 180+ market reach.
146+
147+## 5. Component Stylings
148+
149+### Buttons
150+
151+**Accent Pill**
152+
153+- Background: `#a855f7`
154+- Text: `var(--spot-bg)` (dark in dark mode, light in light mode)
155+- Padding: 8px 16px
156+- Radius: 9999px (full pill)
157+- Use: Primary CTAs, add buttons
158+
159+**Dark Pill**
160+
161+- Background: `var(--spot-hover)`
162+- Text: `var(--spot-text)` or `var(--spot-secondary)`
163+- Padding: 8px 16px
164+- Radius: 9999px (full pill)
165+- Use: Navigation pills, secondary actions
166+
167+**Outlined Pill**
168+
169+- Background: transparent
170+- Text: `var(--spot-text)`
171+- Border: `1px solid var(--spot-outline)`
172+- Radius: 9999px
173+- Use: Follow buttons, secondary actions
174+
175+**Circular Play**
176+
177+- Background: `#a855f7`
178+- Text: `var(--spot-bg)`
179+- Padding: 12px
180+- Radius: 50% (circle)
181+- Use: Play/pause controls
182+
183+### Cards & Containers
184+
185+- Background: `var(--spot-surface)`
186+- Radius: 6px–8px
187+- No visible borders on most cards
188+- Hover: `var(--spot-hover-50)` background
189+- Shadow: `var(--spot-shadow)` on elevated
190+
191+### Inputs
192+
193+- Background: `var(--spot-hover)`
194+- Text: `var(--spot-text)`
195+- Radius: 500px (pill)
196+- Focus ring: `#a855f7`
197+- Placeholder: `var(--spot-placeholder)`
198+
199+### Navigation
200+
201+- Sidebar: `var(--spot-bg)` background
202+- Active items: 14px weight 700, `var(--spot-text)`
203+- Inactive items: 14px weight 400, `var(--spot-secondary)`
204+- Circular icon buttons (50% radius)
205+- Brand logo top-left in purple
206+
207+## 6. Layout Principles
208+
209+### Spacing System
210+
211+- Base unit: 8px
212+- Scale: 1px, 2px, 3px, 4px, 5px, 6px, 8px, 10px, 12px, 14px, 15px, 16px, 20px
213+
214+### Grid & Container
215+
216+- Sidebar (fixed) + main content area
217+- Grid-based album/playlist cards
218+- Responsive content area fills remaining space
219+
220+### Whitespace Philosophy
221+
222+- **Dark compression**: Spotify packs content densely — playlist grids, track lists, and navigation are all tightly spaced. The dark background provides visual rest between elements without needing large gaps.
223+- **Content density over breathing room**: This is an app, not a marketing site. Every pixel serves the listening experience.
224+
225+### Border Radius Scale
226+
227+- Minimal (2px): Badges, explicit tags
228+- Subtle (4px): Inputs, small elements
229+- Standard (6px): Album art containers, cards
230+- Comfortable (8px): Sections, dialogs
231+- Medium (10px–20px): Panels, overlay elements
232+- Large (100px): Large pill buttons
233+- Pill (500px): Primary buttons, search input
234+- Full Pill (9999px): Navigation pills, search
235+- Circle (50%): Play buttons, avatars, icons
236+
237+## 7. Depth & Elevation
238+
239+| Level | Treatment | Use |
240+| ------------------ | ---------------------------- | ------------------------------ |
241+| Base (Level 0) | `var(--spot-bg)` background | Deepest layer, page background |
242+| Surface (Level 1) | `var(--spot-surface)` | Cards, sidebar, containers |
243+| Elevated (Level 2) | `var(--spot-shadow)` | Dropdown menus, hover cards |
244+| Dialog (Level 3) | `var(--spot-shadow-heavy)` | Modals, overlays, menus |
245+
246+## 8. Do's and Don'ts
247+
248+### Do
249+
250+- Use semantic color tokens (`spot-bg`, `spot-surface`, `spot-text`, etc.) — they adapt to theme
251+- Apply Accent Purple (`#a855f7`) only for play controls, active states, and primary CTAs
252+- Use pill shape (500px–9999px) for all buttons — circular (50%) for play controls
253+- Apply uppercase + wide letter-spacing (1.4px–2px) on button labels
254+- Keep typography compact (10px–24px range) — this is an app, not a magazine
255+- Use theme-aware shadows via CSS variables
256+- Test all components in both dark and light themes
257+
258+### Don't
259+
260+- Don't use Accent Purple decoratively or on backgrounds — it's functional only
261+- Don't hardcode theme-dependent colors — use CSS variable-backed tokens
262+- Don't skip the pill/circle geometry on buttons — square buttons break the identity
263+- Don't use hardcoded shadow values — use `shadow-spot` and `shadow-spot-heavy`
264+- Don't add additional brand colors — purple + achromatic grays is the complete palette
265+- Don't use `text-white` or `bg-white` directly — use `text-spot-text` and `bg-spot-active-pill-bg`
266+- Don't expose raw gray borders — use `border-spot-divider` or `border-spot-outline`
267+
268+## 9. Responsive Behavior
269+
270+### Breakpoints
271+
272+| Name | Width | Key Changes |
273+| ------------- | ----------- | --------------------- |
274+| Mobile Small | <425px | Compact mobile layout |
275+| Mobile | 425–576px | Standard mobile |
276+| Tablet | 576–768px | 2-column grid |
277+| Tablet Large | 768–896px | Expanded layout |
278+| Desktop Small | 896–1024px | Sidebar visible |
279+| Desktop | 1024–1280px | Full desktop layout |
280+| Large Desktop | >1280px | Expanded grid |
281+
282+### Collapsing Strategy
283+
284+- Sidebar: full → collapsed → hidden
285+- Album grid: 5 columns → 3 → 2 → 1
286+- Search: pill input maintained, width adjusts
287+- Navigation: sidebar → bottom bar on mobile
288+- Theme toggle: always accessible in sidebar footer / mobile header
289+
290+## 10. Agent Prompt Guide
291+
292+### Quick Color Reference
293+
294+| Role | Token | Value (dark) |
295+| -------------- | -------------------- | -------------- |
296+| Background | `bg-spot-bg` | `#121212` |
297+| Surface | `bg-spot-surface` | `#181818` |
298+| Hover | `bg-spot-hover` | `#1f1f1f` |
299+| Text primary | `text-spot-text` | `#ffffff` |
300+| Text secondary | `text-spot-secondary`| `#b3b3b3` |
301+| Text body | `text-spot-body` | `#cbcbcb` |
302+| Text muted | `text-spot-muted` | `#4d4d4d` |
303+| Accent | `text-spot-purple` | `#a855f7` |
304+| Divider | `border-spot-divider`| `rgba(...)` |
305+| Outline | `border-spot-outline`| `#7c7c7c` |
306+| Error | `text-spot-red` | `#f3727f` |
307+
308+### Iteration Guide
309+
310+1. Use semantic tokens (`spot-bg`, `spot-surface`, `spot-text`) — they handle theme switching
311+2. Accent Purple (`spot-purple`) for functional highlights only (active, CTA)
312+3. Pill everything — 500px for large, 9999px for small, 50% for circular
313+4. Uppercase + wide tracking on buttons — the systematic label voice
314+5. Theme-aware shadows via `shadow-spot` and `shadow-spot-heavy`
315+6. Never hardcode `text-white` or `bg-white` — use semantic tokens
added docs/specs.md +839 -0
new file mode 100644
@@ -0,0 +1,839 @@
1+# Glean - Design Document
2+
3+## 1. Overview
4+
5+Glean is a social RSS reader built on the AT Protocol. It operates as an **AppView** for the `at.glean.*` lexicon namespace: it indexes records from the relay firehose, serves XRPC query endpoints, and provides the web UI at [glean.at](https://glean.at).
6+
7+Users store their RSS feed subscriptions as individual lexicon records on their PDS (one record per feed). Glean's AppView consumes the firehose, indexes those records, fetches the referenced RSS feeds, and serves both the reader UI and public XRPC APIs for the `at.glean.*` namespace.
8+
9+The core idea: your RSS subscriptions are a strong signal about your interests. When enough people expose theirs, you can discover both **people** (who reads the same things) and **content** (what similar readers follow that you don't).
10+
11+## 2. Stack
12+
13+| Layer | Technology |
14+| ---------------- | ---------------------------------- |
15+| Backend | Go |
16+| Database | SQLite (via `modernc.org/sqlite`) |
17+| Frontend | htmx + TailwindCSS |
18+| Auth | AT Protocol OAuth / DID resolution |
19+| AT Protocol role | AppView for `at.glean.*` lexicons |
20+| Data source | AT Relay firehose → SQLite index |
21+
22+## 3. AT Protocol Lexicons
23+
24+All user data lives on their PDS. The server does not own user data — it indexes and aggregates it.
25+
26+### 3.1 `at.glean.subscription`
27+
28+A single RSS feed subscription. One record per feed per user. Created automatically when a user subscribes to a feed. During onboarding, existing subscriptions can be bulk-imported from an OPML file — the OPML is parsed and individual subscription records are created.
29+
30+```json
31+{
32+ "lexicon": 1,
33+ "id": "at.glean.subscription",
34+ "defs": {
35+ "main": {
36+ "type": "record",
37+ "key": "tid",
38+ "description": "A single RSS feed subscription.",
39+ "record": {
40+ "type": "object",
41+ "required": ["feedUrl"],
42+ "properties": {
43+ "createdAt": { "type": "string", "format": "datetime" },
44+ "feedUrl": { "type": "string" },
45+ "title": { "type": "string" },
46+ "category": { "type": "string" }
47+ }
48+ }
49+ }
50+ }
51+}
52+```
53+
54+#### OPML Import/Export
55+
56+OPML is **not** part of the lexicon. It is only used as a transport format:
57+
58+- **Import (onboarding)**: User uploads an OPML file. Glean parses it, validates each feed URL, creates individual `at.glean.subscription` records on the user's PDS, and indexes them locally.
59+- **Export (offboarding)**: Glean reads the user's `at.glean.subscription` records from their PDS and generates an OPML file for download. The user's repository remains the canonical source.
60+
61+### 3.2 `at.glean.annotation`
62+
63+Reading notes on an article: quote a passage, tag it, rate it, or write a note. A user can have many annotations per article. These are public records on the PDS — users can always share an annotation on Bluesky if they want discussion.
64+
65+```json
66+{
67+ "lexicon": 1,
68+ "id": "at.glean.annotation",
69+ "defs": {
70+ "main": {
71+ "type": "record",
72+ "key": "tid",
73+ "description": "Reading note on a specific RSS article.",
74+ "record": {
75+ "type": "object",
76+ "required": ["feedUrl", "articleUrl"],
77+ "properties": {
78+ "createdAt": { "type": "string", "format": "datetime" },
79+ "feedUrl": { "type": "string" },
80+ "articleUrl": { "type": "string" },
81+ "quote": { "type": "string", "maxGraphemes": 5000 },
82+ "note": { "type": "string", "maxGraphemes": 500 },
83+ "tags": {
84+ "type": "array",
85+ "items": { "type": "string", "maxGraphemes": 50 },
86+ "maxLength": 10
87+ },
88+ "rating": { "type": "integer", "minimum": 1, "maximum": 5 }
89+ }
90+ }
91+ }
92+ }
93+}
94+```
95+
96+### 3.3 `at.glean.like`
97+
98+A user likes an article. The liked feed surfaces popular articles and feeds into discovery. Likes also feed into the recommendation system.
99+
100+```json
101+{
102+ "lexicon": 1,
103+ "id": "at.glean.like",
104+ "defs": {
105+ "main": {
106+ "type": "record",
107+ "key": "tid",
108+ "description": "Like an RSS article.",
109+ "record": {
110+ "type": "object",
111+ "required": ["feedUrl", "articleUrl"],
112+ "properties": {
113+ "createdAt": { "type": "string", "format": "datetime" },
114+ "feedUrl": { "type": "string" },
115+ "articleUrl": { "type": "string" }
116+ }
117+ }
118+ }
119+ }
120+}
121+```
122+
123+### 3.4 AppView Query Lexicons
124+
125+As an AppView, Glean serves the following XRPC query endpoints. Other AT Protocol applications can call these to access indexed `at.glean.*` data without implementing their own indexer.
126+
127+#### `at.glean.listSubscriptions`
128+
129+List subscriptions from a repo, with optional filtering.
130+
131+```
132+Input:
133+ repo: string (DID of the user)
134+ category?: string
135+ limit?: integer (default 50, max 100)
136+ cursor?: string
137+
138+Output:
139+ cursor?: string
140+ subscriptions: [{ uri, cid, value: at.glean.subscription#main, indexedAt }]
141+```
142+
143+#### `at.glean.listFeedLists`
144+
145+List subscription lists from multiple repos, with optional filtering.
146+
147+```
148+Input:
149+ actors?: string[] (filter by DIDs)
150+ limit?: integer (default 50, max 100)
151+ cursor?: string
152+
153+Output:
154+ cursor?: string
155+ feeds: [{ did, subscriptionCount, subscriptions: [{ feedUrl, title, category }] }]
156+```
157+
158+#### `at.glean.listAnnotations`
159+
160+List annotations for an article, a feed, or by a user.
161+
162+```
163+Input:
164+ feedUrl?: string
165+ articleUrl?: string
166+ author?: string (DID)
167+ limit?: integer (default 50, max 100)
168+ cursor?: string
169+
170+Output:
171+ cursor?: string
172+ annotations: [{ uri, cid, author: { did, handle }, value, indexedAt }]
173+```
174+
175+#### `at.glean.listLikes`
176+
177+List liked articles, optionally filtered by user or feed.
178+
179+```
180+Input:
181+ author?: string (DID)
182+ feedUrl?: string
183+ limit?: integer (default 50, max 100)
184+ cursor?: string
185+
186+Output:
187+ cursor?: string
188+ likes: [{ uri, cid, author: { did, handle }, value: at.glean.like#main, indexedAt }]
189+```
190+
191+#### `at.glean.getTrending`
192+
193+Articles with the most likes, forming the community feed.
194+
195+```
196+Input:
197+ limit?: integer (default 50, max 100)
198+ cursor?: string
199+ since?: string (datetime)
200+
201+Output:
202+ cursor?: string
203+ articles: [{ feedUrl, articleUrl, title, likeCount, annotations: [...] }]
204+```
205+
206+#### `at.glean.getRecommendations`
207+
208+Get feed recommendations for a user based on clustering.
209+
210+```
211+Input:
212+ repo: string (DID of the user)
213+ limit?: integer (default 20, max 50)
214+
215+Output:
216+ feeds: [{ feedUrl, title, siteUrl, description, subscriberCount, score }]
217+ people: [{ did, handle, displayName, avatar, jaccard, commonFeeds }]
218+```
219+
220+### 3.5 AppView Firehose Consumption
221+
222+Glean subscribes to the AT Relay firehose (`wss://bsky.network`) for all `at.glean.*` records:
223+
224+```
225+SUBSCRIBE collections: ["at.glean.subscription", "at.glean.annotation", "at.glean.like"]
226+```
227+
228+On each event:
229+
230+- **create**: Insert record into local SQLite, update materialized counts
231+- **delete**: Tombstone the record (soft delete to preserve foreign key integrity)
232+- **update**: Replace the record's CID and value
233+
234+The AppView does not handle writes. Users write records to their own PDS. Glean only reads them from the firehose.
235+
236+## 4. RSS Reader
237+
238+Glean is first and foremost an RSS reader. It fetches, parses, and stores articles from RSS, Atom, and JSON feeds so users can read them in a clean interface.
239+
240+### 4.1 Feed Fetching
241+
242+A background scheduler polls subscribed feeds at regular intervals.
243+
244+```
245+ ┌─────────────────────────┐
246+ │ Feed Scheduler │
247+ │ (background goroutine) │
248+ └────────┬────────────────┘
249+ │ every N minutes
250+ ┌────────▼────────────────┐
251+ │ Feed Fetcher │
252+ │ │
253+ │ 1. SELECT feeds where │
254+ │ next_fetch <= now │
255+ │ 2. Respect ETag/If-None-│
256+ │ Match / Last-Modified│
257+ │ 3. GET feed URL │
258+ │ 4. Parse XML/JSON │
259+ │ 5. Upsert articles │
260+ │ 6. Update feed metadata│
261+ └────────┬────────────────┘
262+ │
263+ ┌──────────────┼──────────────┐
264+ │ │ │
265+ ┌────▼────┐ ┌─────▼─────┐ ┌─────▼─────┐
266+ │RSS/XML │ │Atom/XML │ │JSON Feed │
267+ │Parser │ │Parser │ │Parser │
268+ └─────────┘ └───────────┘ └───────────┘
269+```
270+
271+### 4.2 Fetch Schedule
272+
273+Feeds are not all fetched at the same frequency. The scheduler adapts based on:
274+
275+- **Base interval**: Default 30 minutes
276+- **Feed-level override**: User can set per-feed refresh rate (15min / 30min / 1h / 3h / 6h / 12h / daily)
277+- **Adaptive backoff**: If a feed has not published new articles in the last N fetches, increase the interval. If it starts publishing again, decrease back.
278+- **HTTP cache**: Honor `ETag` and `Last-Modified` headers to skip parsing when nothing changed (304 Not Modified)
279+- **Error backoff**: On failure, double the interval up to 24h, reset on success
280+
281+```sql
282+ALTER TABLE feeds ADD COLUMN fetch_interval_minutes INTEGER NOT NULL DEFAULT 30;
283+ALTER TABLE feeds ADD COLUMN next_fetch_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP;
284+ALTER TABLE feeds ADD COLUMN consecutive_empty_fetches INTEGER NOT NULL DEFAULT 0;
285+ALTER TABLE feeds ADD COLUMN error_count INTEGER NOT NULL DEFAULT 0;
286+```
287+
288+### 4.3 Feed Parsing
289+
290+Go's `encoding/xml` for RSS and Atom. A simple `encoding/json` for JSON Feed.
291+
292+Each parser returns a normalized `Feed` and a slice of `Article` structs:
293+
294+```go
295+type Feed struct {
296+ URL string
297+ Title string
298+ SiteURL string
299+ Description string
300+ Type string // "rss", "atom", "json"
301+ ETag string
302+ LastModified string
303+}
304+
305+type Article struct {
306+ GUID string
307+ Title string
308+ URL string
309+ Author string
310+ Content string
311+ Summary string
312+ Published time.Time
313+ Updated time.Time
314+}
315+```
316+
317+Articles are deduplicated by `(feed_url, guid)`. On upsert, only the article metadata changes — reading state is preserved.
318+
319+### 4.4 Article Content
320+
321+Glean stores article content locally so the reading experience is fast and consistent:
322+
323+```sql
324+CREATE TABLE articles (
325+ id INTEGER PRIMARY KEY AUTOINCREMENT,
326+ feed_url TEXT NOT NULL REFERENCES feeds(feed_url),
327+ guid TEXT NOT NULL,
328+ title TEXT NOT NULL DEFAULT '',
329+ url TEXT,
330+ author TEXT,
331+ summary TEXT,
332+ content TEXT,
333+ published DATETIME,
334+ updated DATETIME,
335+ fetched_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
336+ UNIQUE(feed_url, guid)
337+);
338+```
339+
340+Content is stored as raw HTML from the feed's `<content:encoded>`, `<summary>`, or JSON Feed `content_html`. The server renders it in a sanitized view (strip `<script>`, `<iframe>`, etc.).
341+
342+### 4.5 Read State
343+
344+Read/unread state is tracked per user per article:
345+
346+```sql
347+CREATE TABLE read_state (
348+ user_did TEXT NOT NULL REFERENCES users(did),
349+ article_id INTEGER NOT NULL REFERENCES articles(id),
350+ is_read BOOLEAN NOT NULL DEFAULT 0,
351+ read_at DATETIME,
352+ PRIMARY KEY (user_did, article_id)
353+);
354+
355+CREATE INDEX idx_read_state_unread ON read_state(user_did, is_read) WHERE is_read = 0;
356+```
357+
358+### 4.6 Reading Experience
359+
360+The `/articles` page is the main reading view:
361+
362+- **River of news**: Chronological list of all unread articles across all subscriptions
363+- **Feed filter**: Narrow to a single feed or category
364+- **Mark as read**: Individual or "mark all above" / "mark all"
365+- **Like**: Endorse an article (public, synced to Bluesky PDS)
366+- **Open original**: Title links to the source article
367+- **Share**: Share to Bluesky
368+- **Keyboard navigation**: `j`/`k` to navigate, `l` to like, `m` to mark read (progressive enhancement via a small `<script>` block)
369+
370+### 4.7 Feed Discovery from Content
371+
372+Beyond the clustering system, Glean also discovers new feeds from article content:
373+
374+- **Auto-discovery**: When fetching a feed, parse `<link rel="alternate" type="application/rss+xml">` from the feed's site URL to discover related feeds
375+- **Feedfavicon**: Fetch `favicon.ico` or `/apple-touch-icon.png` from the feed's site URL for display
376+- **Dead feed detection**: If a feed fails for 7 consecutive fetches (14 days at base interval), mark it as dead. Notify the user and offer to remove it.
377+
378+## 5. System Architecture
379+
380+Glean runs as a single Go binary that fills three roles: **AppView** (indexing `at.glean.*` records from the firehose, serving XRPC queries), **RSS reader** (fetching and storing feed content), and **web UI** (htmx frontend).
381+
382+```
383+ AT Relay (bsky.network)
384+ │ firehose
385+ ▼
386+ ┌─────────────────────┐
387+ │ Go Server (glean.at)│
388+ │ │
389+ Browser ──HTTP──► │ ┌────────────────┐ │ ──XRPC queries──► Other AT apps
390+ (htmx + TW) │ │ Router │ │
391+ │ │ ┌───────────┐ │ │
392+ │ │ │ Handlers │ │ │
393+ │ │ │ (UI + XRPC)│ │ │
394+ │ │ └─────┬─────┘ │ │
395+ │ └────────┼────────┘ │
396+ │ │ │
397+ │ ┌────────▼────────┐ │ ┌──────────────────┐
398+ │ │ Service Layer │ │ │ Feed Scheduler │
399+ │ │ │──┼──sync──►│ (goroutine) │
400+ │ └────────┬────────┘ │ │ Fetcher + Parser│
401+ │ │ │ └────────┬─────────┘
402+ │ ┌────────▼────────┐ │ │
403+ │ │ SQLite │ │ RSS/Atom/JSON feeds
404+ │ │ (firehose idx, │ │
405+ │ │ articles, │ │ ┌──────────────────┐
406+ │ │ read state, │ │ │ Cluster Engine │
407+ │ │ clustering) │◄─┼────────►│ (periodic cron) │
408+ │ └─────────────────┘ │ └──────────────────┘
409+ └──────────────────────┘
410+
411+ AppView responsibilities:
412+ • Subscribe to firehose for at.glean.subscription, at.glean.annotation, at.glean.like
413+ • Index records into SQLite
414+ • Serve XRPC query endpoints (at.glean.listSubscriptions, etc.)
415+ • Host the web UI at glean.at
416+ • Write to user PDS on behalf of user (when user acts through UI)
417+```
418+
419+## 6. Database Schema (SQLite)
420+
421+### 6.1 Users
422+
423+```sql
424+CREATE TABLE users (
425+ did TEXT PRIMARY KEY,
426+ handle TEXT NOT NULL,
427+ display_name TEXT,
428+ avatar_url TEXT,
429+ indexed_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
430+ updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
431+);
432+```
433+
434+### 6.2 Feed Subscriptions
435+
436+Indexed from `at.glean.subscription` records on user PDS.
437+
438+```sql
439+CREATE TABLE subscriptions (
440+ id INTEGER PRIMARY KEY AUTOINCREMENT,
441+ user_did TEXT NOT NULL REFERENCES users(did),
442+ feed_url TEXT NOT NULL,
443+ category TEXT,
444+ added_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
445+ UNIQUE(user_did, feed_url)
446+);
447+
448+CREATE INDEX idx_subscriptions_feed ON subscriptions(feed_url);
449+CREATE INDEX idx_subscriptions_user ON subscriptions(user_did);
450+```
451+
452+### 6.3 Feeds
453+
454+Master list of all known RSS feeds.
455+
456+```sql
457+CREATE TABLE feeds (
458+ feed_url TEXT PRIMARY KEY,
459+ title TEXT,
460+ site_url TEXT,
461+ description TEXT,
462+ feed_type TEXT CHECK(feed_type IN ('rss', 'atom', 'json')),
463+ last_fetched_at DATETIME,
464+ last_error TEXT,
465+ subscriber_count INTEGER NOT NULL DEFAULT 0
466+);
467+```
468+
469+### 6.4 Articles
470+
471+Fetched from RSS feeds. Only fetched for feeds that have local subscribers.
472+
473+```sql
474+CREATE TABLE articles (
475+ id INTEGER PRIMARY KEY AUTOINCREMENT,
476+ feed_url TEXT NOT NULL REFERENCES feeds(feed_url),
477+ guid TEXT NOT NULL,
478+ title TEXT,
479+ url TEXT,
480+ author TEXT,
481+ published DATETIME,
482+ fetched_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
483+ UNIQUE(feed_url, guid)
484+);
485+
486+CREATE INDEX idx_articles_feed ON articles(feed_url);
487+CREATE INDEX idx_articles_published ON articles(published DESC);
488+```
489+
490+### 6.5 Annotations, Likes
491+
492+Local mirror of AT Protocol lexicon records for fast querying.
493+
494+```sql
495+CREATE TABLE annotations (
496+ id INTEGER PRIMARY KEY AUTOINCREMENT,
497+ uri TEXT NOT NULL UNIQUE,
498+ author_did TEXT NOT NULL REFERENCES users(did),
499+ feed_url TEXT NOT NULL,
500+ article_url TEXT NOT NULL,
501+ quote TEXT,
502+ note TEXT,
503+ tags TEXT,
504+ rating INTEGER,
505+ created_at DATETIME NOT NULL
506+);
507+
508+CREATE TABLE likes (
509+ id INTEGER PRIMARY KEY AUTOINCREMENT,
510+ uri TEXT NOT NULL UNIQUE,
511+ author_did TEXT NOT NULL REFERENCES users(did),
512+ feed_url TEXT NOT NULL,
513+ article_url TEXT NOT NULL,
514+ created_at DATETIME NOT NULL,
515+ UNIQUE(author_did, feed_url, article_url)
516+);
517+```
518+
519+### 6.6 Cluster Precomputation
520+
521+Stores precomputed similarity data to avoid recalculating on every request.
522+
523+```sql
524+CREATE TABLE feed_similarity (
525+ feed_a TEXT NOT NULL REFERENCES feeds(feed_url),
526+ feed_b TEXT NOT NULL REFERENCES feeds(feed_url),
527+ jaccard REAL NOT NULL,
528+ computed_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
529+ PRIMARY KEY (feed_a, feed_b),
530+ CHECK(feed_a < feed_b)
531+);
532+
533+CREATE TABLE user_similarity (
534+ user_a TEXT NOT NULL REFERENCES users(did),
535+ user_b TEXT NOT NULL REFERENCES users(did),
536+ jaccard REAL NOT NULL,
537+ common_feeds INTEGER NOT NULL,
538+ computed_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
539+ PRIMARY KEY (user_a, user_b),
540+ CHECK(user_a < user_b)
541+);
542+```
543+
544+## 7. Clustering & Recommendations
545+
546+Glean has two complementary recommendation signals:
547+
548+- **Subscriptions** (Jaccard similarity): "Who reads the same feeds?" → feed and people discovery
549+- **Likes** (co-occurrence): "Who likes the same articles?" → article and feed discovery
550+
551+### 7.1 Feed Co-occurrence (Jaccard Similarity)
552+
553+For any two feeds, the similarity is the Jaccard index of their subscriber sets:
554+
555+```
556+J(A, B) = |subscribers(A) ∩ subscribers(B)| / |subscribers(A) ∪ subscribers(B)|
557+```
558+
559+This is recomputed periodically (cron job) or incrementally when subscriptions change.
560+
561+### 7.2 User Similarity
562+
563+For any two users, compute Jaccard over their subscription sets:
564+
565+```
566+J(U1, U2) = |feeds(U1) ∩ feeds(U2)| / |feeds(U1) ∪ feeds(U2)|
567+```
568+
569+### 7.3 Recommendation Algorithms
570+
571+**Feed recommendations (on glean.at):**
572+
573+1. Find users with Jaccard > 0.2 (similar readers)
574+2. Collect feeds those users subscribe to that the target user does not
575+3. Rank by frequency (how many similar users subscribe) and average similarity
576+4. Return top N feeds as recommendations
577+
578+```
579+score(feed) = Σ J(target, U) for each user U subscribed to feed
580+```
581+
582+**Article recommendations (on glean.at, from likes):**
583+
584+1. Find users who liked articles that the target user also liked
585+2. Collect articles those users liked that the target has not
586+3. Rank by frequency and recency
587+4. Return top N articles as recommendations
588+
589+```
590+score(article) = Σ 1/logN(likers(article)) for each user U who liked it
591+```
592+
593+The `1/logN` weighting avoids over-recommending articles from very large feeds.
594+
595+**People recommendations (to follow on Bluesky):**
596+
597+1. Compute user similarity for all pairs
598+2. Return users with highest Jaccard, linking to their Bluesky profile for follow
599+
600+### 7.4 Implementation
601+
602+For the initial version, brute-force Jaccard with SQLite is sufficient (scale: ~10k users, ~100k subscriptions). The query is:
603+
604+```sql
605+SELECT s2.feed_url, COUNT(*) as overlap_count
606+FROM subscriptions s1
607+JOIN subscriptions s2 ON s1.feed_url = s2.feed_url
608+WHERE s1.user_did = ? AND s2.user_did != ?
609+AND s2.feed_url NOT IN (SELECT feed_url FROM subscriptions WHERE user_did = ?)
610+GROUP BY s2.feed_url
611+ORDER BY overlap_count DESC
612+LIMIT 20;
613+```
614+
615+For larger scale, move to MinHash + LSH (banded hashing) to approximate Jaccard in sub-linear time.
616+
617+### 7.5 Clustering Engine (Cron)
618+
619+A background goroutine runs on a schedule (e.g., every 6 hours):
620+
621+1. **Firehose ingestion**: Subscribe to AT Protocol firehose for `at.glean.*` records
622+2. **Index new records**: Parse lexicon records, upsert into SQLite
623+3. **Compute similarities**: Batch-update the `feed_similarity`, `user_similarity`, and `article_co_like` tables
624+4. **Generate recommendations**: Materialize top recommendations per user into cache tables
625+
626+```sql
627+CREATE TABLE user_feed_recommendations (
628+ user_did TEXT NOT NULL REFERENCES users(did),
629+ feed_url TEXT NOT NULL REFERENCES feeds(feed_url),
630+ score REAL NOT NULL,
631+ computed_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
632+ PRIMARY KEY (user_did, feed_url)
633+);
634+
635+CREATE TABLE user_article_recommendations (
636+ user_did TEXT NOT NULL REFERENCES users(did),
637+ feed_url TEXT NOT NULL,
638+ article_url TEXT NOT NULL,
639+ score REAL NOT NULL,
640+ computed_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
641+ PRIMARY KEY (user_did, feed_url, article_url)
642+);
643+```
644+
645+## 8. HTTP API / htmx Endpoints
646+
647+The server renders HTML fragments that htmx swaps into the page. No JSON API needed for the frontend.
648+
649+### 8.1 Pages
650+
651+| Route | Method | Description |
652+| ---------------------- | ------ | -------------------------------------------------------- |
653+| `/` | GET | Landing page / auth redirect |
654+| `/dashboard` | GET | Main dashboard: unread articles, recommendations sidebar |
655+| `/feeds` | GET | Manage RSS subscriptions (OPML import for onboarding) |
656+| `/feeds/opml/upload` | POST | Upload OPML file to bulk-import subscriptions |
657+| `/feeds/opml/download` | GET | Export subscriptions as OPML (offboarding) |
658+| `/feeds/add` | POST | Add a single feed URL |
659+| `/feeds/remove` | DELETE | Remove a feed |
660+| `/articles` | GET | Read articles (paginated, filterable by feed) |
661+| `/trending` | GET | Community feed: articles ranked by likes |
662+| `/discover` | GET | Feed recommendations + similar people |
663+| `/discover/feeds` | GET | Recommended feeds |
664+| `/discover/people` | GET | People with similar reading habits |
665+| `/profile/{did}` | GET | Public profile: their feeds, likes, annotations |
666+| `/articles/{id}/like` | POST | Like an article (amplify into community feed) |
667+| `/annotations` | GET | View your annotations |
668+| `/annotations/create` | POST | Create annotation on an article |
669+
670+### 8.2 htmx Patterns
671+
672+- **Feed list**: `<div hx-get="/feeds/list" hx-trigger="load">` renders the subscription list as a fragment
673+- **Infinite scroll articles**: `<div hx-get="/articles?page=2" hx-trigger="intersect">` for pagination
674+- **Like button**: `<button hx-post="/articles/{id}/like" hx-swap="outerHTML">` self-updates the button state
675+- **OPML upload**: `<form hx-post="/feeds/opml/upload" hx-encoding="multipart/form-data" hx-target="#feed-list">`
676+
677+## 9. Project Structure
678+
679+```
680+glean/
681+├── main.go # Entry point, wire everything
682+├── go.mod
683+├── go.sum
684+├── internal/
685+│ ├── atproto/
686+│ │ ├── auth.go # DID resolution, OAuth flow
687+│ │ ├── client.go # XRPC client (write to user PDS)
688+│ │ ├── firehose.go # Subscribe to AT Relay firehose
689+│ │ ├── lexicon.go # Lexicon record types + validation
690+│ │ └── xrpc.go # XRPC query handlers (AppView endpoints)
691+│ ├── db/
692+│ │ ├── db.go # SQLite connection, migrations
693+│ │ ├── user.go # User queries
694+│ │ ├── feed.go # Feed + subscription queries
695+│ │ ├── article.go # Article queries
696+│ │ ├── social.go # Like, annotation queries
697+│ │ └── cluster.go # Similarity + recommendation queries
698+│ ├── feed/
699+│ │ ├── parser.go # RSS/Atom/JSON feed parser
700+│ │ ├── fetcher.go # Fetch and parse feeds from URLs
701+│ │ └── opml.go # OPML import/export
702+│ ├── cluster/
703+│ │ ├── jaccard.go # Jaccard similarity computation
704+│ │ ├── recommender.go # Feed + people recommendation logic
705+│ │ └── cron.go # Background recomputation scheduler
706+│ ├── server/
707+│ │ ├── server.go # HTTP server, router setup
708+│ │ ├── middleware.go # Auth, logging, CSRF middleware
709+│ │ ├── session.go # Session management (cookie + DID)
710+│ │ └── handlers/
711+│ │ ├── dashboard.go
712+│ │ ├── feeds.go
713+│ │ ├── articles.go
714+│ │ ├── trending.go
715+│ │ ├── discover.go
716+│ │ ├── profile.go
717+│ │ ├── annotations.go
718+│ │ └── auth.go
719+│ └── tmpl/
720+│ ├── base.html # Base template with htmx + Tailwind
721+│ ├── partials/
722+│ │ ├── feed-list.html
723+│ │ ├── article-list.html
724+│ │ ├── like-button.html
725+│ │ ├── annotation-card.html
726+│ │ ├── recommendation-card.html
727+│ │ └── profile-card.html
728+│ ├── dashboard.html
729+│ ├── feeds.html
730+│ ├── articles.html
731+│ ├── trending.html
732+│ ├── discover.html
733+│ ├── profile.html
734+│ └── annotations.html
735+├── static/
736+│ ├── input.css # Tailwind input
737+│ └── output.css # Tailwind compiled output
738+├── docs/
739+│ └── design.md # This document
740+└── tailwind.config.js
741+```
742+
743+## 10. Auth Flow
744+
745+1. User visits `/`, clicks "Sign in with Bluesky" (or any AT Proto PDS)
746+2. Server redirects to AT Protocol OAuth authorization endpoint
747+3. User authorizes on their PDS
748+4. PDS redirects back with authorization code
749+5. Server exchanges code for access token + refresh token
750+6. Server resolves DID and creates a session (cookie with encrypted DID)
751+7. On each request, middleware decrypts session, loads user from DB (or creates if new)
752+
753+The server never stores the user's AT Protocol password. It stores the session tokens for XRPC calls to the user's PDS (to read/write their lexicon records).
754+
755+## 11. Data Flow
756+
757+### 11.1 User Imports OPML
758+
759+```
760+Browser ──POST /feeds/opml/upload──► Server
761+ │
762+ ├─► Parse OPML, extract feed URLs
763+ ├─► Fetch each feed, validate + store in `feeds` table
764+ ├─► For each feed, create an `at.glean.subscription` record
765+ │ via XRPC write to user's PDS
766+ ├─► Insert subscriptions in local `subscriptions` table
767+ └─◄ Return updated feed list fragment (htmx)
768+```
769+
770+### 11.2 Reading the Feed
771+
772+```
773+Browser ──GET /articles──► Server
774+ │
775+ ├─► Query articles for user's subscriptions
776+ ├─► Render article-list.html partial
777+ └─◄ Return HTML fragment (htmx)
778+```
779+
780+### 11.3 Recommendations
781+
782+```
783+Cron (every 6h) ──► Cluster Engine
784+ │
785+ ├─► SELECT user similarity pairs
786+ ├─► Compute recommendation scores
787+ └─► INSERT into user_feed_recommendations
788+
789+Browser ──GET /discover/feeds──► Server
790+ │
791+ ├─► SELECT from user_feed_recommendations
792+ ├─► Fetch feed metadata
793+ └─◄ Render recommendation cards (htmx)
794+```
795+
796+## 12. Key Design Decisions
797+
798+### 12.1 Why use lexicon records for feed subscriptions?
799+
800+- **User sovereignty**: Each feed subscription lives as a record on the user's PDS. They can export them, move PDS, or revoke access at any time.
801+- **Interoperability**: Any AT Protocol app can read the lexicon and integrate with Glean data.
802+- **No lock-in**: If Glean shuts down, the user's data is intact on their PDS.
803+- **OPML as transport only**: OPML is a common interchange format used solely for import (onboarding from existing readers) and export (offboarding). The canonical representation is always the lexicon — individual `at.glean.subscription` records, one per feed.
804+
805+### 12.2 Why SQLite?
806+
807+- Single-binary deployment, no external database dependency
808+- More than sufficient for the expected scale (tens of thousands of users)
809+- Go's `database/sql` interface makes it easy to swap later if needed
810+- Matches the project's philosophy of simplicity
811+
812+### 12.3 Why htmx?
813+
814+- No JavaScript build pipeline
815+- Server renders everything — simpler mental model
816+- Progressive enhancement works naturally
817+- Perfect fit for a read-centric application
818+- TailwindCSS handles styling without writing custom CSS
819+
820+### 12.4 AppView Architecture
821+
822+Glean operates as an AT Protocol AppView. This means:
823+
824+- **Read path**: All `at.glean.*` data is consumed from the Relay firehose, not by polling individual PDS instances. The firehose handler runs as a persistent goroutine, upserting records into SQLite as they arrive.
825+- **Write path**: Users write records to their own PDS (via standard AT Protocol `com.atproto.repo.createRecord` / `deleteRecord`). Glean never stores user data directly — it only indexes what the firehose delivers.
826+- **Query path**: Other AT Protocol apps can query Glean's XRPC endpoints to access indexed data (subscriptions, annotations, likes, recommendations) without building their own indexer.
827+- **Trade-off**: Article content (fetched from RSS feeds) is local-only and not part of the AT Protocol layer. Only individual feed subscription records (`at.glean.subscription`) live on the PDS.
828+
829+### 12.5 Privacy Model
830+
831+All PDS records are public. There is no notion of private data on the AT Protocol — everything stored on the repo is visible. Users should be aware that annotations, subscriptions, and likes are all public records.
832+
833+## 13. Future Considerations
834+
835+- **MinHash/LSH**: Replace brute-force Jaccard when user count exceeds ~50k
836+- **Full-text search**: Add FTS5 virtual table on articles for search
837+- **Feed groups / reading lists**: Allow users to create curated lists (separate lexicon)
838+- **Email digest**: Periodic email with top articles from subscribed feeds
839+- **Multi-AppView scaling**: Distribute firehose consumption across multiple instances behind a load balancer
new file mode 100644
@@ -0,0 +1,839 @@
1+# Glean - Design Document
2+
3+## 1. Overview
4+
5+Glean is a social RSS reader built on the AT Protocol. It operates as an **AppView** for the `at.glean.*` lexicon namespace: it indexes records from the relay firehose, serves XRPC query endpoints, and provides the web UI at [glean.at](https://glean.at).
6+
7+Users store their RSS feed subscriptions as individual lexicon records on their PDS (one record per feed). Glean's AppView consumes the firehose, indexes those records, fetches the referenced RSS feeds, and serves both the reader UI and public XRPC APIs for the `at.glean.*` namespace.
8+
9+The core idea: your RSS subscriptions are a strong signal about your interests. When enough people expose theirs, you can discover both **people** (who reads the same things) and **content** (what similar readers follow that you don't).
10+
11+## 2. Stack
12+
13+| Layer | Technology |
14+| ---------------- | ---------------------------------- |
15+| Backend | Go |
16+| Database | SQLite (via `modernc.org/sqlite`) |
17+| Frontend | htmx + TailwindCSS |
18+| Auth | AT Protocol OAuth / DID resolution |
19+| AT Protocol role | AppView for `at.glean.*` lexicons |
20+| Data source | AT Relay firehose → SQLite index |
21+
22+## 3. AT Protocol Lexicons
23+
24+All user data lives on their PDS. The server does not own user data — it indexes and aggregates it.
25+
26+### 3.1 `at.glean.subscription`
27+
28+A single RSS feed subscription. One record per feed per user. Created automatically when a user subscribes to a feed. During onboarding, existing subscriptions can be bulk-imported from an OPML file — the OPML is parsed and individual subscription records are created.
29+
30+```json
31+{
32+ "lexicon": 1,
33+ "id": "at.glean.subscription",
34+ "defs": {
35+ "main": {
36+ "type": "record",
37+ "key": "tid",
38+ "description": "A single RSS feed subscription.",
39+ "record": {
40+ "type": "object",
41+ "required": ["feedUrl"],
42+ "properties": {
43+ "createdAt": { "type": "string", "format": "datetime" },
44+ "feedUrl": { "type": "string" },
45+ "title": { "type": "string" },
46+ "category": { "type": "string" }
47+ }
48+ }
49+ }
50+ }
51+}
52+```
53+
54+#### OPML Import/Export
55+
56+OPML is **not** part of the lexicon. It is only used as a transport format:
57+
58+- **Import (onboarding)**: User uploads an OPML file. Glean parses it, validates each feed URL, creates individual `at.glean.subscription` records on the user's PDS, and indexes them locally.
59+- **Export (offboarding)**: Glean reads the user's `at.glean.subscription` records from their PDS and generates an OPML file for download. The user's repository remains the canonical source.
60+
61+### 3.2 `at.glean.annotation`
62+
63+Reading notes on an article: quote a passage, tag it, rate it, or write a note. A user can have many annotations per article. These are public records on the PDS — users can always share an annotation on Bluesky if they want discussion.
64+
65+```json
66+{
67+ "lexicon": 1,
68+ "id": "at.glean.annotation",
69+ "defs": {
70+ "main": {
71+ "type": "record",
72+ "key": "tid",
73+ "description": "Reading note on a specific RSS article.",
74+ "record": {
75+ "type": "object",
76+ "required": ["feedUrl", "articleUrl"],
77+ "properties": {
78+ "createdAt": { "type": "string", "format": "datetime" },
79+ "feedUrl": { "type": "string" },
80+ "articleUrl": { "type": "string" },
81+ "quote": { "type": "string", "maxGraphemes": 5000 },
82+ "note": { "type": "string", "maxGraphemes": 500 },
83+ "tags": {
84+ "type": "array",
85+ "items": { "type": "string", "maxGraphemes": 50 },
86+ "maxLength": 10
87+ },
88+ "rating": { "type": "integer", "minimum": 1, "maximum": 5 }
89+ }
90+ }
91+ }
92+ }
93+}
94+```
95+
96+### 3.3 `at.glean.like`
97+
98+A user likes an article. The liked feed surfaces popular articles and feeds into discovery. Likes also feed into the recommendation system.
99+
100+```json
101+{
102+ "lexicon": 1,
103+ "id": "at.glean.like",
104+ "defs": {
105+ "main": {
106+ "type": "record",
107+ "key": "tid",
108+ "description": "Like an RSS article.",
109+ "record": {
110+ "type": "object",
111+ "required": ["feedUrl", "articleUrl"],
112+ "properties": {
113+ "createdAt": { "type": "string", "format": "datetime" },
114+ "feedUrl": { "type": "string" },
115+ "articleUrl": { "type": "string" }
116+ }
117+ }
118+ }
119+ }
120+}
121+```
122+
123+### 3.4 AppView Query Lexicons
124+
125+As an AppView, Glean serves the following XRPC query endpoints. Other AT Protocol applications can call these to access indexed `at.glean.*` data without implementing their own indexer.
126+
127+#### `at.glean.listSubscriptions`
128+
129+List subscriptions from a repo, with optional filtering.
130+
131+```
132+Input:
133+ repo: string (DID of the user)
134+ category?: string
135+ limit?: integer (default 50, max 100)
136+ cursor?: string
137+
138+Output:
139+ cursor?: string
140+ subscriptions: [{ uri, cid, value: at.glean.subscription#main, indexedAt }]
141+```
142+
143+#### `at.glean.listFeedLists`
144+
145+List subscription lists from multiple repos, with optional filtering.
146+
147+```
148+Input:
149+ actors?: string[] (filter by DIDs)
150+ limit?: integer (default 50, max 100)
151+ cursor?: string
152+
153+Output:
154+ cursor?: string
155+ feeds: [{ did, subscriptionCount, subscriptions: [{ feedUrl, title, category }] }]
156+```
157+
158+#### `at.glean.listAnnotations`
159+
160+List annotations for an article, a feed, or by a user.
161+
162+```
163+Input:
164+ feedUrl?: string
165+ articleUrl?: string
166+ author?: string (DID)
167+ limit?: integer (default 50, max 100)
168+ cursor?: string
169+
170+Output:
171+ cursor?: string
172+ annotations: [{ uri, cid, author: { did, handle }, value, indexedAt }]
173+```
174+
175+#### `at.glean.listLikes`
176+
177+List liked articles, optionally filtered by user or feed.
178+
179+```
180+Input:
181+ author?: string (DID)
182+ feedUrl?: string
183+ limit?: integer (default 50, max 100)
184+ cursor?: string
185+
186+Output:
187+ cursor?: string
188+ likes: [{ uri, cid, author: { did, handle }, value: at.glean.like#main, indexedAt }]
189+```
190+
191+#### `at.glean.getTrending`
192+
193+Articles with the most likes, forming the community feed.
194+
195+```
196+Input:
197+ limit?: integer (default 50, max 100)
198+ cursor?: string
199+ since?: string (datetime)
200+
201+Output:
202+ cursor?: string
203+ articles: [{ feedUrl, articleUrl, title, likeCount, annotations: [...] }]
204+```
205+
206+#### `at.glean.getRecommendations`
207+
208+Get feed recommendations for a user based on clustering.
209+
210+```
211+Input:
212+ repo: string (DID of the user)
213+ limit?: integer (default 20, max 50)
214+
215+Output:
216+ feeds: [{ feedUrl, title, siteUrl, description, subscriberCount, score }]
217+ people: [{ did, handle, displayName, avatar, jaccard, commonFeeds }]
218+```
219+
220+### 3.5 AppView Firehose Consumption
221+
222+Glean subscribes to the AT Relay firehose (`wss://bsky.network`) for all `at.glean.*` records:
223+
224+```
225+SUBSCRIBE collections: ["at.glean.subscription", "at.glean.annotation", "at.glean.like"]
226+```
227+
228+On each event:
229+
230+- **create**: Insert record into local SQLite, update materialized counts
231+- **delete**: Tombstone the record (soft delete to preserve foreign key integrity)
232+- **update**: Replace the record's CID and value
233+
234+The AppView does not handle writes. Users write records to their own PDS. Glean only reads them from the firehose.
235+
236+## 4. RSS Reader
237+
238+Glean is first and foremost an RSS reader. It fetches, parses, and stores articles from RSS, Atom, and JSON feeds so users can read them in a clean interface.
239+
240+### 4.1 Feed Fetching
241+
242+A background scheduler polls subscribed feeds at regular intervals.
243+
244+```
245+ ┌─────────────────────────┐
246+ │ Feed Scheduler │
247+ │ (background goroutine) │
248+ └────────┬────────────────┘
249+ │ every N minutes
250+ ┌────────▼────────────────┐
251+ │ Feed Fetcher │
252+ │ │
253+ │ 1. SELECT feeds where │
254+ │ next_fetch <= now │
255+ │ 2. Respect ETag/If-None-│
256+ │ Match / Last-Modified│
257+ │ 3. GET feed URL │
258+ │ 4. Parse XML/JSON │
259+ │ 5. Upsert articles │
260+ │ 6. Update feed metadata│
261+ └────────┬────────────────┘
262+ │
263+ ┌──────────────┼──────────────┐
264+ │ │ │
265+ ┌────▼────┐ ┌─────▼─────┐ ┌─────▼─────┐
266+ │RSS/XML │ │Atom/XML │ │JSON Feed │
267+ │Parser │ │Parser │ │Parser │
268+ └─────────┘ └───────────┘ └───────────┘
269+```
270+
271+### 4.2 Fetch Schedule
272+
273+Feeds are not all fetched at the same frequency. The scheduler adapts based on:
274+
275+- **Base interval**: Default 30 minutes
276+- **Feed-level override**: User can set per-feed refresh rate (15min / 30min / 1h / 3h / 6h / 12h / daily)
277+- **Adaptive backoff**: If a feed has not published new articles in the last N fetches, increase the interval. If it starts publishing again, decrease back.
278+- **HTTP cache**: Honor `ETag` and `Last-Modified` headers to skip parsing when nothing changed (304 Not Modified)
279+- **Error backoff**: On failure, double the interval up to 24h, reset on success
280+
281+```sql
282+ALTER TABLE feeds ADD COLUMN fetch_interval_minutes INTEGER NOT NULL DEFAULT 30;
283+ALTER TABLE feeds ADD COLUMN next_fetch_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP;
284+ALTER TABLE feeds ADD COLUMN consecutive_empty_fetches INTEGER NOT NULL DEFAULT 0;
285+ALTER TABLE feeds ADD COLUMN error_count INTEGER NOT NULL DEFAULT 0;
286+```
287+
288+### 4.3 Feed Parsing
289+
290+Go's `encoding/xml` for RSS and Atom. A simple `encoding/json` for JSON Feed.
291+
292+Each parser returns a normalized `Feed` and a slice of `Article` structs:
293+
294+```go
295+type Feed struct {
296+ URL string
297+ Title string
298+ SiteURL string
299+ Description string
300+ Type string // "rss", "atom", "json"
301+ ETag string
302+ LastModified string
303+}
304+
305+type Article struct {
306+ GUID string
307+ Title string
308+ URL string
309+ Author string
310+ Content string
311+ Summary string
312+ Published time.Time
313+ Updated time.Time
314+}
315+```
316+
317+Articles are deduplicated by `(feed_url, guid)`. On upsert, only the article metadata changes — reading state is preserved.
318+
319+### 4.4 Article Content
320+
321+Glean stores article content locally so the reading experience is fast and consistent:
322+
323+```sql
324+CREATE TABLE articles (
325+ id INTEGER PRIMARY KEY AUTOINCREMENT,
326+ feed_url TEXT NOT NULL REFERENCES feeds(feed_url),
327+ guid TEXT NOT NULL,
328+ title TEXT NOT NULL DEFAULT '',
329+ url TEXT,
330+ author TEXT,
331+ summary TEXT,
332+ content TEXT,
333+ published DATETIME,
334+ updated DATETIME,
335+ fetched_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
336+ UNIQUE(feed_url, guid)
337+);
338+```
339+
340+Content is stored as raw HTML from the feed's `<content:encoded>`, `<summary>`, or JSON Feed `content_html`. The server renders it in a sanitized view (strip `<script>`, `<iframe>`, etc.).
341+
342+### 4.5 Read State
343+
344+Read/unread state is tracked per user per article:
345+
346+```sql
347+CREATE TABLE read_state (
348+ user_did TEXT NOT NULL REFERENCES users(did),
349+ article_id INTEGER NOT NULL REFERENCES articles(id),
350+ is_read BOOLEAN NOT NULL DEFAULT 0,
351+ read_at DATETIME,
352+ PRIMARY KEY (user_did, article_id)
353+);
354+
355+CREATE INDEX idx_read_state_unread ON read_state(user_did, is_read) WHERE is_read = 0;
356+```
357+
358+### 4.6 Reading Experience
359+
360+The `/articles` page is the main reading view:
361+
362+- **River of news**: Chronological list of all unread articles across all subscriptions
363+- **Feed filter**: Narrow to a single feed or category
364+- **Mark as read**: Individual or "mark all above" / "mark all"
365+- **Like**: Endorse an article (public, synced to Bluesky PDS)
366+- **Open original**: Title links to the source article
367+- **Share**: Share to Bluesky
368+- **Keyboard navigation**: `j`/`k` to navigate, `l` to like, `m` to mark read (progressive enhancement via a small `<script>` block)
369+
370+### 4.7 Feed Discovery from Content
371+
372+Beyond the clustering system, Glean also discovers new feeds from article content:
373+
374+- **Auto-discovery**: When fetching a feed, parse `<link rel="alternate" type="application/rss+xml">` from the feed's site URL to discover related feeds
375+- **Feedfavicon**: Fetch `favicon.ico` or `/apple-touch-icon.png` from the feed's site URL for display
376+- **Dead feed detection**: If a feed fails for 7 consecutive fetches (14 days at base interval), mark it as dead. Notify the user and offer to remove it.
377+
378+## 5. System Architecture
379+
380+Glean runs as a single Go binary that fills three roles: **AppView** (indexing `at.glean.*` records from the firehose, serving XRPC queries), **RSS reader** (fetching and storing feed content), and **web UI** (htmx frontend).
381+
382+```
383+ AT Relay (bsky.network)
384+ │ firehose
385+ ▼
386+ ┌─────────────────────┐
387+ │ Go Server (glean.at)│
388+ │ │
389+ Browser ──HTTP──► │ ┌────────────────┐ │ ──XRPC queries──► Other AT apps
390+ (htmx + TW) │ │ Router │ │
391+ │ │ ┌───────────┐ │ │
392+ │ │ │ Handlers │ │ │
393+ │ │ │ (UI + XRPC)│ │ │
394+ │ │ └─────┬─────┘ │ │
395+ │ └────────┼────────┘ │
396+ │ │ │
397+ │ ┌────────▼────────┐ │ ┌──────────────────┐
398+ │ │ Service Layer │ │ │ Feed Scheduler │
399+ │ │ │──┼──sync──►│ (goroutine) │
400+ │ └────────┬────────┘ │ │ Fetcher + Parser│
401+ │ │ │ └────────┬─────────┘
402+ │ ┌────────▼────────┐ │ │
403+ │ │ SQLite │ │ RSS/Atom/JSON feeds
404+ │ │ (firehose idx, │ │
405+ │ │ articles, │ │ ┌──────────────────┐
406+ │ │ read state, │ │ │ Cluster Engine │
407+ │ │ clustering) │◄─┼────────►│ (periodic cron) │
408+ │ └─────────────────┘ │ └──────────────────┘
409+ └──────────────────────┘
410+
411+ AppView responsibilities:
412+ • Subscribe to firehose for at.glean.subscription, at.glean.annotation, at.glean.like
413+ • Index records into SQLite
414+ • Serve XRPC query endpoints (at.glean.listSubscriptions, etc.)
415+ • Host the web UI at glean.at
416+ • Write to user PDS on behalf of user (when user acts through UI)
417+```
418+
419+## 6. Database Schema (SQLite)
420+
421+### 6.1 Users
422+
423+```sql
424+CREATE TABLE users (
425+ did TEXT PRIMARY KEY,
426+ handle TEXT NOT NULL,
427+ display_name TEXT,
428+ avatar_url TEXT,
429+ indexed_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
430+ updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
431+);
432+```
433+
434+### 6.2 Feed Subscriptions
435+
436+Indexed from `at.glean.subscription` records on user PDS.
437+
438+```sql
439+CREATE TABLE subscriptions (
440+ id INTEGER PRIMARY KEY AUTOINCREMENT,
441+ user_did TEXT NOT NULL REFERENCES users(did),
442+ feed_url TEXT NOT NULL,
443+ category TEXT,
444+ added_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
445+ UNIQUE(user_did, feed_url)
446+);
447+
448+CREATE INDEX idx_subscriptions_feed ON subscriptions(feed_url);
449+CREATE INDEX idx_subscriptions_user ON subscriptions(user_did);
450+```
451+
452+### 6.3 Feeds
453+
454+Master list of all known RSS feeds.
455+
456+```sql
457+CREATE TABLE feeds (
458+ feed_url TEXT PRIMARY KEY,
459+ title TEXT,
460+ site_url TEXT,
461+ description TEXT,
462+ feed_type TEXT CHECK(feed_type IN ('rss', 'atom', 'json')),
463+ last_fetched_at DATETIME,
464+ last_error TEXT,
465+ subscriber_count INTEGER NOT NULL DEFAULT 0
466+);
467+```
468+
469+### 6.4 Articles
470+
471+Fetched from RSS feeds. Only fetched for feeds that have local subscribers.
472+
473+```sql
474+CREATE TABLE articles (
475+ id INTEGER PRIMARY KEY AUTOINCREMENT,
476+ feed_url TEXT NOT NULL REFERENCES feeds(feed_url),
477+ guid TEXT NOT NULL,
478+ title TEXT,
479+ url TEXT,
480+ author TEXT,
481+ published DATETIME,
482+ fetched_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
483+ UNIQUE(feed_url, guid)
484+);
485+
486+CREATE INDEX idx_articles_feed ON articles(feed_url);
487+CREATE INDEX idx_articles_published ON articles(published DESC);
488+```
489+
490+### 6.5 Annotations, Likes
491+
492+Local mirror of AT Protocol lexicon records for fast querying.
493+
494+```sql
495+CREATE TABLE annotations (
496+ id INTEGER PRIMARY KEY AUTOINCREMENT,
497+ uri TEXT NOT NULL UNIQUE,
498+ author_did TEXT NOT NULL REFERENCES users(did),
499+ feed_url TEXT NOT NULL,
500+ article_url TEXT NOT NULL,
501+ quote TEXT,
502+ note TEXT,
503+ tags TEXT,
504+ rating INTEGER,
505+ created_at DATETIME NOT NULL
506+);
507+
508+CREATE TABLE likes (
509+ id INTEGER PRIMARY KEY AUTOINCREMENT,
510+ uri TEXT NOT NULL UNIQUE,
511+ author_did TEXT NOT NULL REFERENCES users(did),
512+ feed_url TEXT NOT NULL,
513+ article_url TEXT NOT NULL,
514+ created_at DATETIME NOT NULL,
515+ UNIQUE(author_did, feed_url, article_url)
516+);
517+```
518+
519+### 6.6 Cluster Precomputation
520+
521+Stores precomputed similarity data to avoid recalculating on every request.
522+
523+```sql
524+CREATE TABLE feed_similarity (
525+ feed_a TEXT NOT NULL REFERENCES feeds(feed_url),
526+ feed_b TEXT NOT NULL REFERENCES feeds(feed_url),
527+ jaccard REAL NOT NULL,
528+ computed_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
529+ PRIMARY KEY (feed_a, feed_b),
530+ CHECK(feed_a < feed_b)
531+);
532+
533+CREATE TABLE user_similarity (
534+ user_a TEXT NOT NULL REFERENCES users(did),
535+ user_b TEXT NOT NULL REFERENCES users(did),
536+ jaccard REAL NOT NULL,
537+ common_feeds INTEGER NOT NULL,
538+ computed_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
539+ PRIMARY KEY (user_a, user_b),
540+ CHECK(user_a < user_b)
541+);
542+```
543+
544+## 7. Clustering & Recommendations
545+
546+Glean has two complementary recommendation signals:
547+
548+- **Subscriptions** (Jaccard similarity): "Who reads the same feeds?" → feed and people discovery
549+- **Likes** (co-occurrence): "Who likes the same articles?" → article and feed discovery
550+
551+### 7.1 Feed Co-occurrence (Jaccard Similarity)
552+
553+For any two feeds, the similarity is the Jaccard index of their subscriber sets:
554+
555+```
556+J(A, B) = |subscribers(A) ∩ subscribers(B)| / |subscribers(A) ∪ subscribers(B)|
557+```
558+
559+This is recomputed periodically (cron job) or incrementally when subscriptions change.
560+
561+### 7.2 User Similarity
562+
563+For any two users, compute Jaccard over their subscription sets:
564+
565+```
566+J(U1, U2) = |feeds(U1) ∩ feeds(U2)| / |feeds(U1) ∪ feeds(U2)|
567+```
568+
569+### 7.3 Recommendation Algorithms
570+
571+**Feed recommendations (on glean.at):**
572+
573+1. Find users with Jaccard > 0.2 (similar readers)
574+2. Collect feeds those users subscribe to that the target user does not
575+3. Rank by frequency (how many similar users subscribe) and average similarity
576+4. Return top N feeds as recommendations
577+
578+```
579+score(feed) = Σ J(target, U) for each user U subscribed to feed
580+```
581+
582+**Article recommendations (on glean.at, from likes):**
583+
584+1. Find users who liked articles that the target user also liked
585+2. Collect articles those users liked that the target has not
586+3. Rank by frequency and recency
587+4. Return top N articles as recommendations
588+
589+```
590+score(article) = Σ 1/logN(likers(article)) for each user U who liked it
591+```
592+
593+The `1/logN` weighting avoids over-recommending articles from very large feeds.
594+
595+**People recommendations (to follow on Bluesky):**
596+
597+1. Compute user similarity for all pairs
598+2. Return users with highest Jaccard, linking to their Bluesky profile for follow
599+
600+### 7.4 Implementation
601+
602+For the initial version, brute-force Jaccard with SQLite is sufficient (scale: ~10k users, ~100k subscriptions). The query is:
603+
604+```sql
605+SELECT s2.feed_url, COUNT(*) as overlap_count
606+FROM subscriptions s1
607+JOIN subscriptions s2 ON s1.feed_url = s2.feed_url
608+WHERE s1.user_did = ? AND s2.user_did != ?
609+AND s2.feed_url NOT IN (SELECT feed_url FROM subscriptions WHERE user_did = ?)
610+GROUP BY s2.feed_url
611+ORDER BY overlap_count DESC
612+LIMIT 20;
613+```
614+
615+For larger scale, move to MinHash + LSH (banded hashing) to approximate Jaccard in sub-linear time.
616+
617+### 7.5 Clustering Engine (Cron)
618+
619+A background goroutine runs on a schedule (e.g., every 6 hours):
620+
621+1. **Firehose ingestion**: Subscribe to AT Protocol firehose for `at.glean.*` records
622+2. **Index new records**: Parse lexicon records, upsert into SQLite
623+3. **Compute similarities**: Batch-update the `feed_similarity`, `user_similarity`, and `article_co_like` tables
624+4. **Generate recommendations**: Materialize top recommendations per user into cache tables
625+
626+```sql
627+CREATE TABLE user_feed_recommendations (
628+ user_did TEXT NOT NULL REFERENCES users(did),
629+ feed_url TEXT NOT NULL REFERENCES feeds(feed_url),
630+ score REAL NOT NULL,
631+ computed_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
632+ PRIMARY KEY (user_did, feed_url)
633+);
634+
635+CREATE TABLE user_article_recommendations (
636+ user_did TEXT NOT NULL REFERENCES users(did),
637+ feed_url TEXT NOT NULL,
638+ article_url TEXT NOT NULL,
639+ score REAL NOT NULL,
640+ computed_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
641+ PRIMARY KEY (user_did, feed_url, article_url)
642+);
643+```
644+
645+## 8. HTTP API / htmx Endpoints
646+
647+The server renders HTML fragments that htmx swaps into the page. No JSON API needed for the frontend.
648+
649+### 8.1 Pages
650+
651+| Route | Method | Description |
652+| ---------------------- | ------ | -------------------------------------------------------- |
653+| `/` | GET | Landing page / auth redirect |
654+| `/dashboard` | GET | Main dashboard: unread articles, recommendations sidebar |
655+| `/feeds` | GET | Manage RSS subscriptions (OPML import for onboarding) |
656+| `/feeds/opml/upload` | POST | Upload OPML file to bulk-import subscriptions |
657+| `/feeds/opml/download` | GET | Export subscriptions as OPML (offboarding) |
658+| `/feeds/add` | POST | Add a single feed URL |
659+| `/feeds/remove` | DELETE | Remove a feed |
660+| `/articles` | GET | Read articles (paginated, filterable by feed) |
661+| `/trending` | GET | Community feed: articles ranked by likes |
662+| `/discover` | GET | Feed recommendations + similar people |
663+| `/discover/feeds` | GET | Recommended feeds |
664+| `/discover/people` | GET | People with similar reading habits |
665+| `/profile/{did}` | GET | Public profile: their feeds, likes, annotations |
666+| `/articles/{id}/like` | POST | Like an article (amplify into community feed) |
667+| `/annotations` | GET | View your annotations |
668+| `/annotations/create` | POST | Create annotation on an article |
669+
670+### 8.2 htmx Patterns
671+
672+- **Feed list**: `<div hx-get="/feeds/list" hx-trigger="load">` renders the subscription list as a fragment
673+- **Infinite scroll articles**: `<div hx-get="/articles?page=2" hx-trigger="intersect">` for pagination
674+- **Like button**: `<button hx-post="/articles/{id}/like" hx-swap="outerHTML">` self-updates the button state
675+- **OPML upload**: `<form hx-post="/feeds/opml/upload" hx-encoding="multipart/form-data" hx-target="#feed-list">`
676+
677+## 9. Project Structure
678+
679+```
680+glean/
681+├── main.go # Entry point, wire everything
682+├── go.mod
683+├── go.sum
684+├── internal/
685+│ ├── atproto/
686+│ │ ├── auth.go # DID resolution, OAuth flow
687+│ │ ├── client.go # XRPC client (write to user PDS)
688+│ │ ├── firehose.go # Subscribe to AT Relay firehose
689+│ │ ├── lexicon.go # Lexicon record types + validation
690+│ │ └── xrpc.go # XRPC query handlers (AppView endpoints)
691+│ ├── db/
692+│ │ ├── db.go # SQLite connection, migrations
693+│ │ ├── user.go # User queries
694+│ │ ├── feed.go # Feed + subscription queries
695+│ │ ├── article.go # Article queries
696+│ │ ├── social.go # Like, annotation queries
697+│ │ └── cluster.go # Similarity + recommendation queries
698+│ ├── feed/
699+│ │ ├── parser.go # RSS/Atom/JSON feed parser
700+│ │ ├── fetcher.go # Fetch and parse feeds from URLs
701+│ │ └── opml.go # OPML import/export
702+│ ├── cluster/
703+│ │ ├── jaccard.go # Jaccard similarity computation
704+│ │ ├── recommender.go # Feed + people recommendation logic
705+│ │ └── cron.go # Background recomputation scheduler
706+│ ├── server/
707+│ │ ├── server.go # HTTP server, router setup
708+│ │ ├── middleware.go # Auth, logging, CSRF middleware
709+│ │ ├── session.go # Session management (cookie + DID)
710+│ │ └── handlers/
711+│ │ ├── dashboard.go
712+│ │ ├── feeds.go
713+│ │ ├── articles.go
714+│ │ ├── trending.go
715+│ │ ├── discover.go
716+│ │ ├── profile.go
717+│ │ ├── annotations.go
718+│ │ └── auth.go
719+│ └── tmpl/
720+│ ├── base.html # Base template with htmx + Tailwind
721+│ ├── partials/
722+│ │ ├── feed-list.html
723+│ │ ├── article-list.html
724+│ │ ├── like-button.html
725+│ │ ├── annotation-card.html
726+│ │ ├── recommendation-card.html
727+│ │ └── profile-card.html
728+│ ├── dashboard.html
729+│ ├── feeds.html
730+│ ├── articles.html
731+│ ├── trending.html
732+│ ├── discover.html
733+│ ├── profile.html
734+│ └── annotations.html
735+├── static/
736+│ ├── input.css # Tailwind input
737+│ └── output.css # Tailwind compiled output
738+├── docs/
739+│ └── design.md # This document
740+└── tailwind.config.js
741+```
742+
743+## 10. Auth Flow
744+
745+1. User visits `/`, clicks "Sign in with Bluesky" (or any AT Proto PDS)
746+2. Server redirects to AT Protocol OAuth authorization endpoint
747+3. User authorizes on their PDS
748+4. PDS redirects back with authorization code
749+5. Server exchanges code for access token + refresh token
750+6. Server resolves DID and creates a session (cookie with encrypted DID)
751+7. On each request, middleware decrypts session, loads user from DB (or creates if new)
752+
753+The server never stores the user's AT Protocol password. It stores the session tokens for XRPC calls to the user's PDS (to read/write their lexicon records).
754+
755+## 11. Data Flow
756+
757+### 11.1 User Imports OPML
758+
759+```
760+Browser ──POST /feeds/opml/upload──► Server
761+ │
762+ ├─► Parse OPML, extract feed URLs
763+ ├─► Fetch each feed, validate + store in `feeds` table
764+ ├─► For each feed, create an `at.glean.subscription` record
765+ │ via XRPC write to user's PDS
766+ ├─► Insert subscriptions in local `subscriptions` table
767+ └─◄ Return updated feed list fragment (htmx)
768+```
769+
770+### 11.2 Reading the Feed
771+
772+```
773+Browser ──GET /articles──► Server
774+ │
775+ ├─► Query articles for user's subscriptions
776+ ├─► Render article-list.html partial
777+ └─◄ Return HTML fragment (htmx)
778+```
779+
780+### 11.3 Recommendations
781+
782+```
783+Cron (every 6h) ──► Cluster Engine
784+ │
785+ ├─► SELECT user similarity pairs
786+ ├─► Compute recommendation scores
787+ └─► INSERT into user_feed_recommendations
788+
789+Browser ──GET /discover/feeds──► Server
790+ │
791+ ├─► SELECT from user_feed_recommendations
792+ ├─► Fetch feed metadata
793+ └─◄ Render recommendation cards (htmx)
794+```
795+
796+## 12. Key Design Decisions
797+
798+### 12.1 Why use lexicon records for feed subscriptions?
799+
800+- **User sovereignty**: Each feed subscription lives as a record on the user's PDS. They can export them, move PDS, or revoke access at any time.
801+- **Interoperability**: Any AT Protocol app can read the lexicon and integrate with Glean data.
802+- **No lock-in**: If Glean shuts down, the user's data is intact on their PDS.
803+- **OPML as transport only**: OPML is a common interchange format used solely for import (onboarding from existing readers) and export (offboarding). The canonical representation is always the lexicon — individual `at.glean.subscription` records, one per feed.
804+
805+### 12.2 Why SQLite?
806+
807+- Single-binary deployment, no external database dependency
808+- More than sufficient for the expected scale (tens of thousands of users)
809+- Go's `database/sql` interface makes it easy to swap later if needed
810+- Matches the project's philosophy of simplicity
811+
812+### 12.3 Why htmx?
813+
814+- No JavaScript build pipeline
815+- Server renders everything — simpler mental model
816+- Progressive enhancement works naturally
817+- Perfect fit for a read-centric application
818+- TailwindCSS handles styling without writing custom CSS
819+
820+### 12.4 AppView Architecture
821+
822+Glean operates as an AT Protocol AppView. This means:
823+
824+- **Read path**: All `at.glean.*` data is consumed from the Relay firehose, not by polling individual PDS instances. The firehose handler runs as a persistent goroutine, upserting records into SQLite as they arrive.
825+- **Write path**: Users write records to their own PDS (via standard AT Protocol `com.atproto.repo.createRecord` / `deleteRecord`). Glean never stores user data directly — it only indexes what the firehose delivers.
826+- **Query path**: Other AT Protocol apps can query Glean's XRPC endpoints to access indexed data (subscriptions, annotations, likes, recommendations) without building their own indexer.
827+- **Trade-off**: Article content (fetched from RSS feeds) is local-only and not part of the AT Protocol layer. Only individual feed subscription records (`at.glean.subscription`) live on the PDS.
828+
829+### 12.5 Privacy Model
830+
831+All PDS records are public. There is no notion of private data on the AT Protocol — everything stored on the repo is visible. Users should be aware that annotations, subscriptions, and likes are all public records.
832+
833+## 13. Future Considerations
834+
835+- **MinHash/LSH**: Replace brute-force Jaccard when user count exceeds ~50k
836+- **Full-text search**: Add FTS5 virtual table on articles for search
837+- **Feed groups / reading lists**: Allow users to create curated lists (separate lexicon)
838+- **Email digest**: Periodic email with top articles from subscribed feeds
839+- **Multi-AppView scaling**: Distribute firehose consumption across multiple instances behind a load balancer
modified go.mod +27 -4
@@ -4,6 +4,14 @@ go 1.26.2
44
55 tool github.com/golangci/golangci-lint/cmd/golangci-lint
66
7+require (
8+ github.com/bluesky-social/indigo v0.0.0-20260417172304-7da09df6081d
9+ github.com/go-chi/chi/v5 v5.2.5
10+ github.com/go-chi/cors v1.2.2
11+ github.com/gorilla/websocket v1.5.3
12+ modernc.org/sqlite v1.49.1
13+)
14+
715 require (
816 4d63.com/gocheckcompilerdirectives v1.3.0 // indirect
917 4d63.com/gochecknoglobals v0.2.2 // indirect
@@ -43,6 +51,8 @@ require (
4351 github.com/daixiang0/gci v0.13.5 // indirect
4452 github.com/davecgh/go-spew v1.1.1 // indirect
4553 github.com/denis-tingaikin/go-header v0.5.0 // indirect
54+ github.com/dustin/go-humanize v1.0.1 // indirect
55+ github.com/earthboundkid/versioninfo/v2 v2.24.1 // indirect
4656 github.com/ettle/strcase v0.2.0 // indirect
4757 github.com/fatih/color v1.18.0 // indirect
4858 github.com/fatih/structtag v1.2.0 // indirect
@@ -71,6 +81,7 @@ require (
7181 github.com/golangci/revgrep v0.8.0 // indirect
7282 github.com/golangci/unconvert v0.0.0-20240309020433-c5143eacb3ed // indirect
7383 github.com/google/go-cmp v0.7.0 // indirect
84+ github.com/google/uuid v1.6.0 // indirect
7485 github.com/gordonklaus/ineffassign v0.1.0 // indirect
7586 github.com/gostaticanalysis/analysisutil v0.7.1 // indirect
7687 github.com/gostaticanalysis/comment v1.5.0 // indirect
@@ -111,7 +122,9 @@ require (
111122 github.com/mitchellh/go-homedir v1.1.0 // indirect
112123 github.com/mitchellh/mapstructure v1.5.0 // indirect
113124 github.com/moricho/tparallel v0.3.2 // indirect
125+ github.com/mr-tron/base58 v1.2.0 // indirect
114126 github.com/nakabonne/nestif v0.3.1 // indirect
127+ github.com/ncruces/go-strftime v1.0.0 // indirect
115128 github.com/nishanths/exhaustive v0.12.0 // indirect
116129 github.com/nishanths/predeclared v0.2.2 // indirect
117130 github.com/nunnatsa/ginkgolinter v0.19.1 // indirect
@@ -130,6 +143,7 @@ require (
130143 github.com/quasilyte/regex/syntax v0.0.0-20210819130434-b3f0c404a727 // indirect
131144 github.com/quasilyte/stdinfo v0.0.0-20220114132959-f7386bf02567 // indirect
132145 github.com/raeperd/recvcheck v0.2.0 // indirect
146+ github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
133147 github.com/rivo/uniseg v0.4.7 // indirect
134148 github.com/rogpeppe/go-internal v1.14.1 // indirect
135149 github.com/ryancurrah/gomodguard v1.3.5 // indirect
@@ -170,22 +184,31 @@ require (
170184 github.com/yeya24/promlinter v0.3.0 // indirect
171185 github.com/ykadowak/zerologlint v0.1.5 // indirect
172186 gitlab.com/bosi/decorder v0.4.2 // indirect
187+ gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b // indirect
188+ gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02 // indirect
173189 go-simpler.org/musttag v0.13.0 // indirect
174190 go-simpler.org/sloglint v0.9.0 // indirect
175191 go.uber.org/automaxprocs v1.6.0 // indirect
176192 go.uber.org/multierr v1.11.0 // indirect
177193 go.uber.org/zap v1.26.0 // indirect
194+ golang.org/x/crypto v0.35.0 // indirect
178195 golang.org/x/exp/typeparams v0.0.0-20250210185358-939b2ce775ac // indirect
179- golang.org/x/mod v0.24.0 // indirect
180- golang.org/x/sync v0.12.0 // indirect
181- golang.org/x/sys v0.31.0 // indirect
196+ golang.org/x/mod v0.33.0 // indirect
197+ golang.org/x/sync v0.20.0 // indirect
198+ golang.org/x/sys v0.42.0 // indirect
182199 golang.org/x/text v0.22.0 // indirect
183- golang.org/x/tools v0.31.0 // indirect
200+ golang.org/x/time v0.10.0 // indirect
201+ golang.org/x/tools v0.42.0 // indirect
202+ golang.org/x/tools/go/expect v0.1.1-deprecated // indirect
203+ golang.org/x/tools/go/packages/packagestest v0.1.1-deprecated // indirect
184204 google.golang.org/protobuf v1.36.5 // indirect
185205 gopkg.in/ini.v1 v1.67.0 // indirect
186206 gopkg.in/yaml.v2 v2.4.0 // indirect
187207 gopkg.in/yaml.v3 v3.0.1 // indirect
188208 honnef.co/go/tools v0.6.1 // indirect
209+ modernc.org/libc v1.72.0 // indirect
210+ modernc.org/mathutil v1.7.1 // indirect
211+ modernc.org/memory v1.11.0 // indirect
189212 mvdan.cc/gofumpt v0.7.0 // indirect
190213 mvdan.cc/unparam v0.0.0-20240528143540-8a5130ca722f // indirect
191214 )
@@ -4,6 +4,14 @@ go 1.26.2
4 4
5 tool github.com/golangci/golangci-lint/cmd/golangci-lint5 tool github.com/golangci/golangci-lint/cmd/golangci-lint
6 6
7+require (
8+ github.com/bluesky-social/indigo v0.0.0-20260417172304-7da09df6081d
9+ github.com/go-chi/chi/v5 v5.2.5
10+ github.com/go-chi/cors v1.2.2
11+ github.com/gorilla/websocket v1.5.3
12+ modernc.org/sqlite v1.49.1
13+)
14+
7 require (15 require (
8 4d63.com/gocheckcompilerdirectives v1.3.0 // indirect16 4d63.com/gocheckcompilerdirectives v1.3.0 // indirect
9 4d63.com/gochecknoglobals v0.2.2 // indirect17 4d63.com/gochecknoglobals v0.2.2 // indirect
@@ -43,6 +51,8 @@ require (
43 github.com/daixiang0/gci v0.13.5 // indirect51 github.com/daixiang0/gci v0.13.5 // indirect
44 github.com/davecgh/go-spew v1.1.1 // indirect52 github.com/davecgh/go-spew v1.1.1 // indirect
45 github.com/denis-tingaikin/go-header v0.5.0 // indirect53 github.com/denis-tingaikin/go-header v0.5.0 // indirect
54+ github.com/dustin/go-humanize v1.0.1 // indirect
55+ github.com/earthboundkid/versioninfo/v2 v2.24.1 // indirect
46 github.com/ettle/strcase v0.2.0 // indirect56 github.com/ettle/strcase v0.2.0 // indirect
47 github.com/fatih/color v1.18.0 // indirect57 github.com/fatih/color v1.18.0 // indirect
48 github.com/fatih/structtag v1.2.0 // indirect58 github.com/fatih/structtag v1.2.0 // indirect
@@ -71,6 +81,7 @@ require (
71 github.com/golangci/revgrep v0.8.0 // indirect81 github.com/golangci/revgrep v0.8.0 // indirect
72 github.com/golangci/unconvert v0.0.0-20240309020433-c5143eacb3ed // indirect82 github.com/golangci/unconvert v0.0.0-20240309020433-c5143eacb3ed // indirect
73 github.com/google/go-cmp v0.7.0 // indirect83 github.com/google/go-cmp v0.7.0 // indirect
84+ github.com/google/uuid v1.6.0 // indirect
74 github.com/gordonklaus/ineffassign v0.1.0 // indirect85 github.com/gordonklaus/ineffassign v0.1.0 // indirect
75 github.com/gostaticanalysis/analysisutil v0.7.1 // indirect86 github.com/gostaticanalysis/analysisutil v0.7.1 // indirect
76 github.com/gostaticanalysis/comment v1.5.0 // indirect87 github.com/gostaticanalysis/comment v1.5.0 // indirect
@@ -111,7 +122,9 @@ require (
111 github.com/mitchellh/go-homedir v1.1.0 // indirect122 github.com/mitchellh/go-homedir v1.1.0 // indirect
112 github.com/mitchellh/mapstructure v1.5.0 // indirect123 github.com/mitchellh/mapstructure v1.5.0 // indirect
113 github.com/moricho/tparallel v0.3.2 // indirect124 github.com/moricho/tparallel v0.3.2 // indirect
125+ github.com/mr-tron/base58 v1.2.0 // indirect
114 github.com/nakabonne/nestif v0.3.1 // indirect126 github.com/nakabonne/nestif v0.3.1 // indirect
127+ github.com/ncruces/go-strftime v1.0.0 // indirect
115 github.com/nishanths/exhaustive v0.12.0 // indirect128 github.com/nishanths/exhaustive v0.12.0 // indirect
116 github.com/nishanths/predeclared v0.2.2 // indirect129 github.com/nishanths/predeclared v0.2.2 // indirect
117 github.com/nunnatsa/ginkgolinter v0.19.1 // indirect130 github.com/nunnatsa/ginkgolinter v0.19.1 // indirect
@@ -130,6 +143,7 @@ require (
130 github.com/quasilyte/regex/syntax v0.0.0-20210819130434-b3f0c404a727 // indirect143 github.com/quasilyte/regex/syntax v0.0.0-20210819130434-b3f0c404a727 // indirect
131 github.com/quasilyte/stdinfo v0.0.0-20220114132959-f7386bf02567 // indirect144 github.com/quasilyte/stdinfo v0.0.0-20220114132959-f7386bf02567 // indirect
132 github.com/raeperd/recvcheck v0.2.0 // indirect145 github.com/raeperd/recvcheck v0.2.0 // indirect
146+ github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
133 github.com/rivo/uniseg v0.4.7 // indirect147 github.com/rivo/uniseg v0.4.7 // indirect
134 github.com/rogpeppe/go-internal v1.14.1 // indirect148 github.com/rogpeppe/go-internal v1.14.1 // indirect
135 github.com/ryancurrah/gomodguard v1.3.5 // indirect149 github.com/ryancurrah/gomodguard v1.3.5 // indirect
@@ -170,22 +184,31 @@ require (
170 github.com/yeya24/promlinter v0.3.0 // indirect184 github.com/yeya24/promlinter v0.3.0 // indirect
171 github.com/ykadowak/zerologlint v0.1.5 // indirect185 github.com/ykadowak/zerologlint v0.1.5 // indirect
172 gitlab.com/bosi/decorder v0.4.2 // indirect186 gitlab.com/bosi/decorder v0.4.2 // indirect
187+ gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b // indirect
188+ gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02 // indirect
173 go-simpler.org/musttag v0.13.0 // indirect189 go-simpler.org/musttag v0.13.0 // indirect
174 go-simpler.org/sloglint v0.9.0 // indirect190 go-simpler.org/sloglint v0.9.0 // indirect
175 go.uber.org/automaxprocs v1.6.0 // indirect191 go.uber.org/automaxprocs v1.6.0 // indirect
176 go.uber.org/multierr v1.11.0 // indirect192 go.uber.org/multierr v1.11.0 // indirect
177 go.uber.org/zap v1.26.0 // indirect193 go.uber.org/zap v1.26.0 // indirect
194+ golang.org/x/crypto v0.35.0 // indirect
178 golang.org/x/exp/typeparams v0.0.0-20250210185358-939b2ce775ac // indirect195 golang.org/x/exp/typeparams v0.0.0-20250210185358-939b2ce775ac // indirect
179- golang.org/x/mod v0.24.0 // indirect196+ golang.org/x/mod v0.33.0 // indirect
180- golang.org/x/sync v0.12.0 // indirect197+ golang.org/x/sync v0.20.0 // indirect
181- golang.org/x/sys v0.31.0 // indirect198+ golang.org/x/sys v0.42.0 // indirect
182 golang.org/x/text v0.22.0 // indirect199 golang.org/x/text v0.22.0 // indirect
183- golang.org/x/tools v0.31.0 // indirect200+ golang.org/x/time v0.10.0 // indirect
201+ golang.org/x/tools v0.42.0 // indirect
202+ golang.org/x/tools/go/expect v0.1.1-deprecated // indirect
203+ golang.org/x/tools/go/packages/packagestest v0.1.1-deprecated // indirect
184 google.golang.org/protobuf v1.36.5 // indirect204 google.golang.org/protobuf v1.36.5 // indirect
185 gopkg.in/ini.v1 v1.67.0 // indirect205 gopkg.in/ini.v1 v1.67.0 // indirect
186 gopkg.in/yaml.v2 v2.4.0 // indirect206 gopkg.in/yaml.v2 v2.4.0 // indirect
187 gopkg.in/yaml.v3 v3.0.1 // indirect207 gopkg.in/yaml.v3 v3.0.1 // indirect
188 honnef.co/go/tools v0.6.1 // indirect208 honnef.co/go/tools v0.6.1 // indirect
209+ modernc.org/libc v1.72.0 // indirect
210+ modernc.org/mathutil v1.7.1 // indirect
211+ modernc.org/memory v1.11.0 // indirect
189 mvdan.cc/gofumpt v0.7.0 // indirect212 mvdan.cc/gofumpt v0.7.0 // indirect
190 mvdan.cc/unparam v0.0.0-20240528143540-8a5130ca722f // indirect213 mvdan.cc/unparam v0.0.0-20240528143540-8a5130ca722f // indirect
191 )214 )
modified go.sum +72 -12
@@ -48,6 +48,8 @@ github.com/bkielbasa/cyclop v1.2.3 h1:faIVMIGDIANuGPWH031CZJTi2ymOQBULs9H21HSMa5
4848 github.com/bkielbasa/cyclop v1.2.3/go.mod h1:kHTwA9Q0uZqOADdupvcFJQtp/ksSnytRMe8ztxG8Fuo=
4949 github.com/blizzy78/varnamelen v0.8.0 h1:oqSblyuQvFsW1hbBHh1zfwrKe3kcSj0rnXkKzsQ089M=
5050 github.com/blizzy78/varnamelen v0.8.0/go.mod h1:V9TzQZ4fLJ1DSrjVDfl89H7aMnTvKkApdHeyESmyR7k=
51+github.com/bluesky-social/indigo v0.0.0-20260417172304-7da09df6081d h1:ThKFUrkm2/IZwbvmIKLJYr0wPHibtCkIVmuZCWmdIHM=
52+github.com/bluesky-social/indigo v0.0.0-20260417172304-7da09df6081d/go.mod h1:JqQkz8lrOI6YZivP38GHmtVOTtzsNToITKj1gMpU5Jo=
5153 github.com/bombsimon/wsl/v4 v4.5.0 h1:iZRsEvDdyhd2La0FVi5k6tYehpOR/R7qIUjmKk7N74A=
5254 github.com/bombsimon/wsl/v4 v4.5.0/go.mod h1:NOQ3aLF4nD7N5YPXMruR6ZXDOAqLoM0GEpLwTdvmOSc=
5355 github.com/breml/bidichk v0.3.2 h1:xV4flJ9V5xWTqxL+/PMFF6dtJPvZLPsyixAoPe8BGJs=
@@ -82,6 +84,10 @@ github.com/denis-tingaikin/go-header v0.5.0 h1:SRdnP5ZKvcO9KKRP1KJrhFR3RrlGuD+42
8284 github.com/denis-tingaikin/go-header v0.5.0/go.mod h1:mMenU5bWrok6Wl2UsZjy+1okegmwQ3UgWl4V1D8gjlY=
8385 github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI=
8486 github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
87+github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
88+github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
89+github.com/earthboundkid/versioninfo/v2 v2.24.1 h1:SJTMHaoUx3GzjjnUO1QzP3ZXK6Ee/nbWyCm58eY3oUg=
90+github.com/earthboundkid/versioninfo/v2 v2.24.1/go.mod h1:VcWEooDEuyUJnMfbdTh0uFN4cfEIg+kHMuWB2CDCLjw=
8591 github.com/ettle/strcase v0.2.0 h1:fGNiVF21fHXpX1niBgk0aROov1LagYsOwV/xqKDKR/Q=
8692 github.com/ettle/strcase v0.2.0/go.mod h1:DajmHElDSaX76ITe3/VHVyMin4LWSJN5Z909Wp+ED1A=
8793 github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
@@ -98,6 +104,10 @@ github.com/fzipp/gocyclo v0.6.0 h1:lsblElZG7d3ALtGMx9fmxeTKZaLLpU8mET09yN4BBLo=
98104 github.com/fzipp/gocyclo v0.6.0/go.mod h1:rXPyn8fnlpa0R2csP/31uerbiVBugk5whMdlyaLkLoA=
99105 github.com/ghostiam/protogetter v0.3.9 h1:j+zlLLWzqLay22Cz/aYwTHKQ88GE2DQ6GkWSYFOI4lQ=
100106 github.com/ghostiam/protogetter v0.3.9/go.mod h1:WZ0nw9pfzsgxuRsPOFQomgDVSWtDLJRfQJEhsGbmQMA=
107+github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug=
108+github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0=
109+github.com/go-chi/cors v1.2.2 h1:Jmey33TE+b+rB7fT8MUy1u0I4L+NARQlK6LhzKPSyQE=
110+github.com/go-chi/cors v1.2.2/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58=
101111 github.com/go-critic/go-critic v0.12.0 h1:iLosHZuye812wnkEz1Xu3aBwn5ocCPfc9yqmFG9pa6w=
102112 github.com/go-critic/go-critic v0.12.0/go.mod h1:DpE0P6OVc6JzVYzmM5gq5jMU31zLr4am5mB/VfFK64w=
103113 github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
@@ -156,10 +166,14 @@ github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/
156166 github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
157167 github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
158168 github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
159-github.com/google/pprof v0.0.0-20241210010833-40e02aabc2ad h1:a6HEuzUHeKH6hwfN/ZoQgRgVIWFJljSWa/zetS2WTvg=
160-github.com/google/pprof v0.0.0-20241210010833-40e02aabc2ad/go.mod h1:vavhavw2zAxS5dIdcRluK6cSGGPlZynqzFM8NdvU144=
169+github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
170+github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
171+github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
172+github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
161173 github.com/gordonklaus/ineffassign v0.1.0 h1:y2Gd/9I7MdY1oEIt+n+rowjBNDcLQq3RsH5hwJd0f9s=
162174 github.com/gordonklaus/ineffassign v0.1.0/go.mod h1:Qcp2HIAYhR7mNUVSIxZww3Guk4it82ghYcEXIAk+QT0=
175+github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
176+github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
163177 github.com/gostaticanalysis/analysisutil v0.7.1 h1:ZMCjoue3DtDWQ5WyU16YbjbQEQ3VuzwxALrpYd+HeKk=
164178 github.com/gostaticanalysis/analysisutil v0.7.1/go.mod h1:v21E3hY37WKMGSnbsw2S/ojApNWb6C1//mXO48CXbVc=
165179 github.com/gostaticanalysis/comment v1.4.1/go.mod h1:ih6ZxzTHLdadaiSnF5WY3dxUoXfXAlTaRzuaNDlSado=
@@ -253,8 +267,12 @@ github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyua
253267 github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
254268 github.com/moricho/tparallel v0.3.2 h1:odr8aZVFA3NZrNybggMkYO3rgPRcqjeQUlBBFVxKHTI=
255269 github.com/moricho/tparallel v0.3.2/go.mod h1:OQ+K3b4Ln3l2TZveGCywybl68glfLEwFGqvnjok8b+U=
270+github.com/mr-tron/base58 v1.2.0 h1:T/HDJBh4ZCPbU39/+c3rRvE0uKBQlU27+QI8LJ4t64o=
271+github.com/mr-tron/base58 v1.2.0/go.mod h1:BinMc/sQntlIE1frQmRFPUoPA1Zkr8VRgBdjWI2mNwc=
256272 github.com/nakabonne/nestif v0.3.1 h1:wm28nZjhQY5HyYPx+weN3Q65k6ilSBxDb8v5S81B81U=
257273 github.com/nakabonne/nestif v0.3.1/go.mod h1:9EtoZochLn5iUprVDmDjqGKPofoUEBL8U4Ngq6aY7OE=
274+github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
275+github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
258276 github.com/nishanths/exhaustive v0.12.0 h1:vIY9sALmw6T/yxiASewa4TQcFsVYZQQRUQJhKRf3Swg=
259277 github.com/nishanths/exhaustive v0.12.0/go.mod h1:mEZ95wPIZW+x8kC4TgC+9YCUgiST7ecevsVDTgc2obs=
260278 github.com/nishanths/predeclared v0.2.2 h1:V2EPdZPliZymNAn79T8RkNApBjMmVKh5XRpLm/w98Vk=
@@ -304,6 +322,8 @@ github.com/quasilyte/stdinfo v0.0.0-20220114132959-f7386bf02567 h1:M8mH9eK4OUR4l
304322 github.com/quasilyte/stdinfo v0.0.0-20220114132959-f7386bf02567/go.mod h1:DWNGW8A4Y+GyBgPuaQJuWiy0XYftx4Xm/y5Jqk9I6VQ=
305323 github.com/raeperd/recvcheck v0.2.0 h1:GnU+NsbiCqdC2XX5+vMZzP+jAJC5fht7rcVTAhX74UI=
306324 github.com/raeperd/recvcheck v0.2.0/go.mod h1:n04eYkwIR0JbgD73wT8wL4JjPC3wm0nFtzBnWNocnYU=
325+github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
326+github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
307327 github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
308328 github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
309329 github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
@@ -410,6 +430,10 @@ github.com/yuin/goldmark v1.4.1/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1
410430 github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
411431 gitlab.com/bosi/decorder v0.4.2 h1:qbQaV3zgwnBZ4zPMhGLW4KZe7A7NwxEhJx39R3shffo=
412432 gitlab.com/bosi/decorder v0.4.2/go.mod h1:muuhHoaJkA9QLcYHq4Mj8FJUwDZ+EirSHRiaTcTf6T8=
433+gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b h1:CzigHMRySiX3drau9C6Q5CAbNIApmLdat5jPMqChvDA=
434+gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b/go.mod h1:/y/V339mxv2sZmYYR64O07VuCpdNZqCTwO8ZcouTMI8=
435+gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02 h1:qwDnMxjkyLmAFgcfgTnfJrmYKWhHnci3GjDqcZp1M3Q=
436+gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02/go.mod h1:JTnUj0mpYiAsuZLmKjTx/ex3AtMowcCgnE7YNyCEP0I=
413437 go-simpler.org/assert v0.9.0 h1:PfpmcSvL7yAnWyChSjOz6Sp6m9j5lyK8Ok9pEL31YkQ=
414438 go-simpler.org/assert v0.9.0/go.mod h1:74Eqh5eI6vCK6Y5l3PI8ZYFXG4Sa+tkr70OIPJAUr28=
415439 go-simpler.org/musttag v0.13.0 h1:Q/YAW0AHvaoaIbsPj3bvEI5/QFP7w696IMUpnKXQfCE=
@@ -430,6 +454,8 @@ golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPh
430454 golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
431455 golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
432456 golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4=
457+golang.org/x/crypto v0.35.0 h1:b15kiHdrGCHrP6LvwaQ3c03kgNhhiMgvlhxHQhmg2Xs=
458+golang.org/x/crypto v0.35.0/go.mod h1:dy7dXNW32cAb/6/PRuTNsix8T+vJAqvuIy5Bli/x0YQ=
433459 golang.org/x/exp v0.0.0-20240909161429-701f63a606c0 h1:e66Fs6Z+fZTbFBAxKfP3PALWBtpfqks2bwGcexMxgtk=
434460 golang.org/x/exp v0.0.0-20240909161429-701f63a606c0/go.mod h1:2TbTHSBQa924w8M6Xs1QcRcFwyucIwBGpK1p2f1YFFY=
435461 golang.org/x/exp/typeparams v0.0.0-20220428152302-39d4317da171/go.mod h1:AbB0pIl9nAr9wVwH+Z2ZpaocVmF5I4GyWCDIsVjR0bk=
@@ -447,8 +473,8 @@ golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
447473 golang.org/x/mod v0.9.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
448474 golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
449475 golang.org/x/mod v0.13.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
450-golang.org/x/mod v0.24.0 h1:ZfthKaKaT4NrhGVZHO1/WDTwGES4De8KtWO0SIbNJMU=
451-golang.org/x/mod v0.24.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
476+golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
477+golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
452478 golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
453479 golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
454480 golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
@@ -464,8 +490,8 @@ golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc=
464490 golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
465491 golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
466492 golang.org/x/net v0.16.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE=
467-golang.org/x/net v0.37.0 h1:1zLorHbz+LYj7MQlSf1+2tPIIgibq2eL5xkrGk6f+2c=
468-golang.org/x/net v0.37.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8=
493+golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
494+golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
469495 golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
470496 golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
471497 golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@@ -475,8 +501,8 @@ golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJ
475501 golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
476502 golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
477503 golang.org/x/sync v0.4.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
478-golang.org/x/sync v0.12.0 h1:MHc5BpPuC30uJk597Ri8TV3CNZcTLu6B6z4lJy+g6Jw=
479-golang.org/x/sync v0.12.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
504+golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
505+golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
480506 golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
481507 golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
482508 golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@@ -499,8 +525,8 @@ golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
499525 golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
500526 golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
501527 golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
502-golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik=
503-golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
528+golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
529+golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
504530 golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
505531 golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
506532 golang.org/x/term v0.2.0/go.mod h1:TVmDHMZPmdnySmBfhjOoOdhjzdE1h4u1VwSiw2l1Nuc=
@@ -521,6 +547,8 @@ golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
521547 golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
522548 golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM=
523549 golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
550+golang.org/x/time v0.10.0 h1:3usCWA8tQn0L8+hFJQNgzpWbd89begxN66o1Ojdn5L4=
551+golang.org/x/time v0.10.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
524552 golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
525553 golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
526554 golang.org/x/tools v0.0.0-20200324003944-a576cf524670/go.mod h1:Sl4aGygMT6LrqrWclx+PTx3U+LnKx/seiNR+3G19Ar8=
@@ -539,8 +567,12 @@ golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
539567 golang.org/x/tools v0.7.0/go.mod h1:4pg6aUX35JBAogB10C9AtvVL+qowtN4pT3CGSQex14s=
540568 golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
541569 golang.org/x/tools v0.14.0/go.mod h1:uYBEerGOWcJyEORxN+Ek8+TT266gXkNlHdJBwexUsBg=
542-golang.org/x/tools v0.31.0 h1:0EedkvKDbh+qistFTd0Bcwe/YLh4vHwWEkiI0toFIBU=
543-golang.org/x/tools v0.31.0/go.mod h1:naFTU+Cev749tSJRXJlna0T3WxKvb1kWEx15xA4SdmQ=
570+golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
571+golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
572+golang.org/x/tools/go/expect v0.1.1-deprecated h1:jpBZDwmgPhXsKZC6WhL20P4b/wmnpsEAGHaNy0n/rJM=
573+golang.org/x/tools/go/expect v0.1.1-deprecated/go.mod h1:eihoPOH+FgIqa3FpoTwguz/bVUSGBlGQU67vpBeOrBY=
574+golang.org/x/tools/go/packages/packagestest v0.1.1-deprecated h1:1h2MnaIAIXISqTFKdENegdpAgUXz6NrPEsbIeWaBRvM=
575+golang.org/x/tools/go/packages/packagestest v0.1.1-deprecated/go.mod h1:RVAQXBGNv1ib0J382/DPCRS/BPnsGebyM1Gj5VSDpG8=
544576 golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
545577 golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
546578 golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
@@ -560,6 +592,34 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
560592 gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
561593 honnef.co/go/tools v0.6.1 h1:R094WgE8K4JirYjBaOpz/AvTyUu/3wbmAoskKN/pxTI=
562594 honnef.co/go/tools v0.6.1/go.mod h1:3puzxxljPCe8RGJX7BIy1plGbxEOZni5mR2aXe3/uk4=
595+modernc.org/cc/v4 v4.27.3 h1:uNCgn37E5U09mTv1XgskEVUJ8ADKpmFMPxzGJ0TSo+U=
596+modernc.org/cc/v4 v4.27.3/go.mod h1:3YjcbCqhoTTHPycJDRl2WZKKFj0nwcOIPBfEZK0Hdk8=
597+modernc.org/ccgo/v4 v4.32.4 h1:L5OB8rpEX4ZsXEQwGozRfJyJSFHbbNVOoQ59DU9/KuU=
598+modernc.org/ccgo/v4 v4.32.4/go.mod h1:lY7f+fiTDHfcv6YlRgSkxYfhs+UvOEEzj49jAn2TOx0=
599+modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
600+modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
601+modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
602+modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
603+modernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo=
604+modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
605+modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
606+modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
607+modernc.org/libc v1.72.0 h1:IEu559v9a0XWjw0DPoVKtXpO2qt5NVLAnFaBbjq+n8c=
608+modernc.org/libc v1.72.0/go.mod h1:tTU8DL8A+XLVkEY3x5E/tO7s2Q/q42EtnNWda/L5QhQ=
609+modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
610+modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
611+modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
612+modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
613+modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
614+modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
615+modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
616+modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
617+modernc.org/sqlite v1.49.1 h1:dYGHTKcX1sJ+EQDnUzvz4TJ5GbuvhNJa8Fg6ElGx73U=
618+modernc.org/sqlite v1.49.1/go.mod h1:m0w8xhwYUVY3H6pSDwc3gkJ/irZT/0YEXwBlhaxQEew=
619+modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
620+modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
621+modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
622+modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
563623 mvdan.cc/gofumpt v0.7.0 h1:bg91ttqXmi9y2xawvkuMXyvAA/1ZGJqYAEGjXuP0JXU=
564624 mvdan.cc/gofumpt v0.7.0/go.mod h1:txVFJy/Sc/mvaycET54pV8SW8gWxTlUuGHVEcncmNUo=
565625 mvdan.cc/unparam v0.0.0-20240528143540-8a5130ca722f h1:lMpcwN6GxNbWtbpI1+xzFLSW8XzX0u72NttUGVFjO3U=
@@ -48,6 +48,8 @@ github.com/bkielbasa/cyclop v1.2.3 h1:faIVMIGDIANuGPWH031CZJTi2ymOQBULs9H21HSMa5
48 github.com/bkielbasa/cyclop v1.2.3/go.mod h1:kHTwA9Q0uZqOADdupvcFJQtp/ksSnytRMe8ztxG8Fuo=48 github.com/bkielbasa/cyclop v1.2.3/go.mod h1:kHTwA9Q0uZqOADdupvcFJQtp/ksSnytRMe8ztxG8Fuo=
49 github.com/blizzy78/varnamelen v0.8.0 h1:oqSblyuQvFsW1hbBHh1zfwrKe3kcSj0rnXkKzsQ089M=49 github.com/blizzy78/varnamelen v0.8.0 h1:oqSblyuQvFsW1hbBHh1zfwrKe3kcSj0rnXkKzsQ089M=
50 github.com/blizzy78/varnamelen v0.8.0/go.mod h1:V9TzQZ4fLJ1DSrjVDfl89H7aMnTvKkApdHeyESmyR7k=50 github.com/blizzy78/varnamelen v0.8.0/go.mod h1:V9TzQZ4fLJ1DSrjVDfl89H7aMnTvKkApdHeyESmyR7k=
51+github.com/bluesky-social/indigo v0.0.0-20260417172304-7da09df6081d h1:ThKFUrkm2/IZwbvmIKLJYr0wPHibtCkIVmuZCWmdIHM=
52+github.com/bluesky-social/indigo v0.0.0-20260417172304-7da09df6081d/go.mod h1:JqQkz8lrOI6YZivP38GHmtVOTtzsNToITKj1gMpU5Jo=
51 github.com/bombsimon/wsl/v4 v4.5.0 h1:iZRsEvDdyhd2La0FVi5k6tYehpOR/R7qIUjmKk7N74A=53 github.com/bombsimon/wsl/v4 v4.5.0 h1:iZRsEvDdyhd2La0FVi5k6tYehpOR/R7qIUjmKk7N74A=
52 github.com/bombsimon/wsl/v4 v4.5.0/go.mod h1:NOQ3aLF4nD7N5YPXMruR6ZXDOAqLoM0GEpLwTdvmOSc=54 github.com/bombsimon/wsl/v4 v4.5.0/go.mod h1:NOQ3aLF4nD7N5YPXMruR6ZXDOAqLoM0GEpLwTdvmOSc=
53 github.com/breml/bidichk v0.3.2 h1:xV4flJ9V5xWTqxL+/PMFF6dtJPvZLPsyixAoPe8BGJs=55 github.com/breml/bidichk v0.3.2 h1:xV4flJ9V5xWTqxL+/PMFF6dtJPvZLPsyixAoPe8BGJs=
@@ -82,6 +84,10 @@ github.com/denis-tingaikin/go-header v0.5.0 h1:SRdnP5ZKvcO9KKRP1KJrhFR3RrlGuD+42
82 github.com/denis-tingaikin/go-header v0.5.0/go.mod h1:mMenU5bWrok6Wl2UsZjy+1okegmwQ3UgWl4V1D8gjlY=84 github.com/denis-tingaikin/go-header v0.5.0/go.mod h1:mMenU5bWrok6Wl2UsZjy+1okegmwQ3UgWl4V1D8gjlY=
83 github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI=85 github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI=
84 github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=86 github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
87+github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
88+github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
89+github.com/earthboundkid/versioninfo/v2 v2.24.1 h1:SJTMHaoUx3GzjjnUO1QzP3ZXK6Ee/nbWyCm58eY3oUg=
90+github.com/earthboundkid/versioninfo/v2 v2.24.1/go.mod h1:VcWEooDEuyUJnMfbdTh0uFN4cfEIg+kHMuWB2CDCLjw=
85 github.com/ettle/strcase v0.2.0 h1:fGNiVF21fHXpX1niBgk0aROov1LagYsOwV/xqKDKR/Q=91 github.com/ettle/strcase v0.2.0 h1:fGNiVF21fHXpX1niBgk0aROov1LagYsOwV/xqKDKR/Q=
86 github.com/ettle/strcase v0.2.0/go.mod h1:DajmHElDSaX76ITe3/VHVyMin4LWSJN5Z909Wp+ED1A=92 github.com/ettle/strcase v0.2.0/go.mod h1:DajmHElDSaX76ITe3/VHVyMin4LWSJN5Z909Wp+ED1A=
87 github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=93 github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
@@ -98,6 +104,10 @@ github.com/fzipp/gocyclo v0.6.0 h1:lsblElZG7d3ALtGMx9fmxeTKZaLLpU8mET09yN4BBLo=
98 github.com/fzipp/gocyclo v0.6.0/go.mod h1:rXPyn8fnlpa0R2csP/31uerbiVBugk5whMdlyaLkLoA=104 github.com/fzipp/gocyclo v0.6.0/go.mod h1:rXPyn8fnlpa0R2csP/31uerbiVBugk5whMdlyaLkLoA=
99 github.com/ghostiam/protogetter v0.3.9 h1:j+zlLLWzqLay22Cz/aYwTHKQ88GE2DQ6GkWSYFOI4lQ=105 github.com/ghostiam/protogetter v0.3.9 h1:j+zlLLWzqLay22Cz/aYwTHKQ88GE2DQ6GkWSYFOI4lQ=
100 github.com/ghostiam/protogetter v0.3.9/go.mod h1:WZ0nw9pfzsgxuRsPOFQomgDVSWtDLJRfQJEhsGbmQMA=106 github.com/ghostiam/protogetter v0.3.9/go.mod h1:WZ0nw9pfzsgxuRsPOFQomgDVSWtDLJRfQJEhsGbmQMA=
107+github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug=
108+github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0=
109+github.com/go-chi/cors v1.2.2 h1:Jmey33TE+b+rB7fT8MUy1u0I4L+NARQlK6LhzKPSyQE=
110+github.com/go-chi/cors v1.2.2/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58=
101 github.com/go-critic/go-critic v0.12.0 h1:iLosHZuye812wnkEz1Xu3aBwn5ocCPfc9yqmFG9pa6w=111 github.com/go-critic/go-critic v0.12.0 h1:iLosHZuye812wnkEz1Xu3aBwn5ocCPfc9yqmFG9pa6w=
102 github.com/go-critic/go-critic v0.12.0/go.mod h1:DpE0P6OVc6JzVYzmM5gq5jMU31zLr4am5mB/VfFK64w=112 github.com/go-critic/go-critic v0.12.0/go.mod h1:DpE0P6OVc6JzVYzmM5gq5jMU31zLr4am5mB/VfFK64w=
103 github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=113 github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
@@ -156,10 +166,14 @@ github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/
156 github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=166 github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
157 github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=167 github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
158 github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=168 github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
159-github.com/google/pprof v0.0.0-20241210010833-40e02aabc2ad h1:a6HEuzUHeKH6hwfN/ZoQgRgVIWFJljSWa/zetS2WTvg=169+github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
160-github.com/google/pprof v0.0.0-20241210010833-40e02aabc2ad/go.mod h1:vavhavw2zAxS5dIdcRluK6cSGGPlZynqzFM8NdvU144=170+github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
171+github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
172+github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
161 github.com/gordonklaus/ineffassign v0.1.0 h1:y2Gd/9I7MdY1oEIt+n+rowjBNDcLQq3RsH5hwJd0f9s=173 github.com/gordonklaus/ineffassign v0.1.0 h1:y2Gd/9I7MdY1oEIt+n+rowjBNDcLQq3RsH5hwJd0f9s=
162 github.com/gordonklaus/ineffassign v0.1.0/go.mod h1:Qcp2HIAYhR7mNUVSIxZww3Guk4it82ghYcEXIAk+QT0=174 github.com/gordonklaus/ineffassign v0.1.0/go.mod h1:Qcp2HIAYhR7mNUVSIxZww3Guk4it82ghYcEXIAk+QT0=
175+github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
176+github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
163 github.com/gostaticanalysis/analysisutil v0.7.1 h1:ZMCjoue3DtDWQ5WyU16YbjbQEQ3VuzwxALrpYd+HeKk=177 github.com/gostaticanalysis/analysisutil v0.7.1 h1:ZMCjoue3DtDWQ5WyU16YbjbQEQ3VuzwxALrpYd+HeKk=
164 github.com/gostaticanalysis/analysisutil v0.7.1/go.mod h1:v21E3hY37WKMGSnbsw2S/ojApNWb6C1//mXO48CXbVc=178 github.com/gostaticanalysis/analysisutil v0.7.1/go.mod h1:v21E3hY37WKMGSnbsw2S/ojApNWb6C1//mXO48CXbVc=
165 github.com/gostaticanalysis/comment v1.4.1/go.mod h1:ih6ZxzTHLdadaiSnF5WY3dxUoXfXAlTaRzuaNDlSado=179 github.com/gostaticanalysis/comment v1.4.1/go.mod h1:ih6ZxzTHLdadaiSnF5WY3dxUoXfXAlTaRzuaNDlSado=
@@ -253,8 +267,12 @@ github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyua
253 github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=267 github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
254 github.com/moricho/tparallel v0.3.2 h1:odr8aZVFA3NZrNybggMkYO3rgPRcqjeQUlBBFVxKHTI=268 github.com/moricho/tparallel v0.3.2 h1:odr8aZVFA3NZrNybggMkYO3rgPRcqjeQUlBBFVxKHTI=
255 github.com/moricho/tparallel v0.3.2/go.mod h1:OQ+K3b4Ln3l2TZveGCywybl68glfLEwFGqvnjok8b+U=269 github.com/moricho/tparallel v0.3.2/go.mod h1:OQ+K3b4Ln3l2TZveGCywybl68glfLEwFGqvnjok8b+U=
270+github.com/mr-tron/base58 v1.2.0 h1:T/HDJBh4ZCPbU39/+c3rRvE0uKBQlU27+QI8LJ4t64o=
271+github.com/mr-tron/base58 v1.2.0/go.mod h1:BinMc/sQntlIE1frQmRFPUoPA1Zkr8VRgBdjWI2mNwc=
256 github.com/nakabonne/nestif v0.3.1 h1:wm28nZjhQY5HyYPx+weN3Q65k6ilSBxDb8v5S81B81U=272 github.com/nakabonne/nestif v0.3.1 h1:wm28nZjhQY5HyYPx+weN3Q65k6ilSBxDb8v5S81B81U=
257 github.com/nakabonne/nestif v0.3.1/go.mod h1:9EtoZochLn5iUprVDmDjqGKPofoUEBL8U4Ngq6aY7OE=273 github.com/nakabonne/nestif v0.3.1/go.mod h1:9EtoZochLn5iUprVDmDjqGKPofoUEBL8U4Ngq6aY7OE=
274+github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
275+github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
258 github.com/nishanths/exhaustive v0.12.0 h1:vIY9sALmw6T/yxiASewa4TQcFsVYZQQRUQJhKRf3Swg=276 github.com/nishanths/exhaustive v0.12.0 h1:vIY9sALmw6T/yxiASewa4TQcFsVYZQQRUQJhKRf3Swg=
259 github.com/nishanths/exhaustive v0.12.0/go.mod h1:mEZ95wPIZW+x8kC4TgC+9YCUgiST7ecevsVDTgc2obs=277 github.com/nishanths/exhaustive v0.12.0/go.mod h1:mEZ95wPIZW+x8kC4TgC+9YCUgiST7ecevsVDTgc2obs=
260 github.com/nishanths/predeclared v0.2.2 h1:V2EPdZPliZymNAn79T8RkNApBjMmVKh5XRpLm/w98Vk=278 github.com/nishanths/predeclared v0.2.2 h1:V2EPdZPliZymNAn79T8RkNApBjMmVKh5XRpLm/w98Vk=
@@ -304,6 +322,8 @@ github.com/quasilyte/stdinfo v0.0.0-20220114132959-f7386bf02567 h1:M8mH9eK4OUR4l
304 github.com/quasilyte/stdinfo v0.0.0-20220114132959-f7386bf02567/go.mod h1:DWNGW8A4Y+GyBgPuaQJuWiy0XYftx4Xm/y5Jqk9I6VQ=322 github.com/quasilyte/stdinfo v0.0.0-20220114132959-f7386bf02567/go.mod h1:DWNGW8A4Y+GyBgPuaQJuWiy0XYftx4Xm/y5Jqk9I6VQ=
305 github.com/raeperd/recvcheck v0.2.0 h1:GnU+NsbiCqdC2XX5+vMZzP+jAJC5fht7rcVTAhX74UI=323 github.com/raeperd/recvcheck v0.2.0 h1:GnU+NsbiCqdC2XX5+vMZzP+jAJC5fht7rcVTAhX74UI=
306 github.com/raeperd/recvcheck v0.2.0/go.mod h1:n04eYkwIR0JbgD73wT8wL4JjPC3wm0nFtzBnWNocnYU=324 github.com/raeperd/recvcheck v0.2.0/go.mod h1:n04eYkwIR0JbgD73wT8wL4JjPC3wm0nFtzBnWNocnYU=
325+github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
326+github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
307 github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=327 github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
308 github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=328 github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
309 github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=329 github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
@@ -410,6 +430,10 @@ github.com/yuin/goldmark v1.4.1/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1
410 github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=430 github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
411 gitlab.com/bosi/decorder v0.4.2 h1:qbQaV3zgwnBZ4zPMhGLW4KZe7A7NwxEhJx39R3shffo=431 gitlab.com/bosi/decorder v0.4.2 h1:qbQaV3zgwnBZ4zPMhGLW4KZe7A7NwxEhJx39R3shffo=
412 gitlab.com/bosi/decorder v0.4.2/go.mod h1:muuhHoaJkA9QLcYHq4Mj8FJUwDZ+EirSHRiaTcTf6T8=432 gitlab.com/bosi/decorder v0.4.2/go.mod h1:muuhHoaJkA9QLcYHq4Mj8FJUwDZ+EirSHRiaTcTf6T8=
433+gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b h1:CzigHMRySiX3drau9C6Q5CAbNIApmLdat5jPMqChvDA=
434+gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b/go.mod h1:/y/V339mxv2sZmYYR64O07VuCpdNZqCTwO8ZcouTMI8=
435+gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02 h1:qwDnMxjkyLmAFgcfgTnfJrmYKWhHnci3GjDqcZp1M3Q=
436+gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02/go.mod h1:JTnUj0mpYiAsuZLmKjTx/ex3AtMowcCgnE7YNyCEP0I=
413 go-simpler.org/assert v0.9.0 h1:PfpmcSvL7yAnWyChSjOz6Sp6m9j5lyK8Ok9pEL31YkQ=437 go-simpler.org/assert v0.9.0 h1:PfpmcSvL7yAnWyChSjOz6Sp6m9j5lyK8Ok9pEL31YkQ=
414 go-simpler.org/assert v0.9.0/go.mod h1:74Eqh5eI6vCK6Y5l3PI8ZYFXG4Sa+tkr70OIPJAUr28=438 go-simpler.org/assert v0.9.0/go.mod h1:74Eqh5eI6vCK6Y5l3PI8ZYFXG4Sa+tkr70OIPJAUr28=
415 go-simpler.org/musttag v0.13.0 h1:Q/YAW0AHvaoaIbsPj3bvEI5/QFP7w696IMUpnKXQfCE=439 go-simpler.org/musttag v0.13.0 h1:Q/YAW0AHvaoaIbsPj3bvEI5/QFP7w696IMUpnKXQfCE=
@@ -430,6 +454,8 @@ golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPh
430 golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=454 golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
431 golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=455 golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
432 golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4=456 golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4=
457+golang.org/x/crypto v0.35.0 h1:b15kiHdrGCHrP6LvwaQ3c03kgNhhiMgvlhxHQhmg2Xs=
458+golang.org/x/crypto v0.35.0/go.mod h1:dy7dXNW32cAb/6/PRuTNsix8T+vJAqvuIy5Bli/x0YQ=
433 golang.org/x/exp v0.0.0-20240909161429-701f63a606c0 h1:e66Fs6Z+fZTbFBAxKfP3PALWBtpfqks2bwGcexMxgtk=459 golang.org/x/exp v0.0.0-20240909161429-701f63a606c0 h1:e66Fs6Z+fZTbFBAxKfP3PALWBtpfqks2bwGcexMxgtk=
434 golang.org/x/exp v0.0.0-20240909161429-701f63a606c0/go.mod h1:2TbTHSBQa924w8M6Xs1QcRcFwyucIwBGpK1p2f1YFFY=460 golang.org/x/exp v0.0.0-20240909161429-701f63a606c0/go.mod h1:2TbTHSBQa924w8M6Xs1QcRcFwyucIwBGpK1p2f1YFFY=
435 golang.org/x/exp/typeparams v0.0.0-20220428152302-39d4317da171/go.mod h1:AbB0pIl9nAr9wVwH+Z2ZpaocVmF5I4GyWCDIsVjR0bk=461 golang.org/x/exp/typeparams v0.0.0-20220428152302-39d4317da171/go.mod h1:AbB0pIl9nAr9wVwH+Z2ZpaocVmF5I4GyWCDIsVjR0bk=
@@ -447,8 +473,8 @@ golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
447 golang.org/x/mod v0.9.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=473 golang.org/x/mod v0.9.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
448 golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=474 golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
449 golang.org/x/mod v0.13.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=475 golang.org/x/mod v0.13.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
450-golang.org/x/mod v0.24.0 h1:ZfthKaKaT4NrhGVZHO1/WDTwGES4De8KtWO0SIbNJMU=476+golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
451-golang.org/x/mod v0.24.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=477+golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
452 golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=478 golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
453 golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=479 golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
454 golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=480 golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
@@ -464,8 +490,8 @@ golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc=
464 golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=490 golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
465 golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=491 golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
466 golang.org/x/net v0.16.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE=492 golang.org/x/net v0.16.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE=
467-golang.org/x/net v0.37.0 h1:1zLorHbz+LYj7MQlSf1+2tPIIgibq2eL5xkrGk6f+2c=493+golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
468-golang.org/x/net v0.37.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8=494+golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
469 golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=495 golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
470 golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=496 golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
471 golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=497 golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@@ -475,8 +501,8 @@ golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJ
475 golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=501 golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
476 golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=502 golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
477 golang.org/x/sync v0.4.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=503 golang.org/x/sync v0.4.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
478-golang.org/x/sync v0.12.0 h1:MHc5BpPuC30uJk597Ri8TV3CNZcTLu6B6z4lJy+g6Jw=504+golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
479-golang.org/x/sync v0.12.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=505+golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
480 golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=506 golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
481 golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=507 golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
482 golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=508 golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@@ -499,8 +525,8 @@ golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
499 golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=525 golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
500 golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=526 golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
501 golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=527 golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
502-golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik=528+golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
503-golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=529+golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
504 golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=530 golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
505 golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=531 golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
506 golang.org/x/term v0.2.0/go.mod h1:TVmDHMZPmdnySmBfhjOoOdhjzdE1h4u1VwSiw2l1Nuc=532 golang.org/x/term v0.2.0/go.mod h1:TVmDHMZPmdnySmBfhjOoOdhjzdE1h4u1VwSiw2l1Nuc=
@@ -521,6 +547,8 @@ golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
521 golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=547 golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
522 golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM=548 golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM=
523 golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=549 golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
550+golang.org/x/time v0.10.0 h1:3usCWA8tQn0L8+hFJQNgzpWbd89begxN66o1Ojdn5L4=
551+golang.org/x/time v0.10.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
524 golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=552 golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
525 golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=553 golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
526 golang.org/x/tools v0.0.0-20200324003944-a576cf524670/go.mod h1:Sl4aGygMT6LrqrWclx+PTx3U+LnKx/seiNR+3G19Ar8=554 golang.org/x/tools v0.0.0-20200324003944-a576cf524670/go.mod h1:Sl4aGygMT6LrqrWclx+PTx3U+LnKx/seiNR+3G19Ar8=
@@ -539,8 +567,12 @@ golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
539 golang.org/x/tools v0.7.0/go.mod h1:4pg6aUX35JBAogB10C9AtvVL+qowtN4pT3CGSQex14s=567 golang.org/x/tools v0.7.0/go.mod h1:4pg6aUX35JBAogB10C9AtvVL+qowtN4pT3CGSQex14s=
540 golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=568 golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
541 golang.org/x/tools v0.14.0/go.mod h1:uYBEerGOWcJyEORxN+Ek8+TT266gXkNlHdJBwexUsBg=569 golang.org/x/tools v0.14.0/go.mod h1:uYBEerGOWcJyEORxN+Ek8+TT266gXkNlHdJBwexUsBg=
542-golang.org/x/tools v0.31.0 h1:0EedkvKDbh+qistFTd0Bcwe/YLh4vHwWEkiI0toFIBU=570+golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
543-golang.org/x/tools v0.31.0/go.mod h1:naFTU+Cev749tSJRXJlna0T3WxKvb1kWEx15xA4SdmQ=571+golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
572+golang.org/x/tools/go/expect v0.1.1-deprecated h1:jpBZDwmgPhXsKZC6WhL20P4b/wmnpsEAGHaNy0n/rJM=
573+golang.org/x/tools/go/expect v0.1.1-deprecated/go.mod h1:eihoPOH+FgIqa3FpoTwguz/bVUSGBlGQU67vpBeOrBY=
574+golang.org/x/tools/go/packages/packagestest v0.1.1-deprecated h1:1h2MnaIAIXISqTFKdENegdpAgUXz6NrPEsbIeWaBRvM=
575+golang.org/x/tools/go/packages/packagestest v0.1.1-deprecated/go.mod h1:RVAQXBGNv1ib0J382/DPCRS/BPnsGebyM1Gj5VSDpG8=
544 golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=576 golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
545 golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=577 golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
546 golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=578 golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
@@ -560,6 +592,34 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
560 gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=592 gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
561 honnef.co/go/tools v0.6.1 h1:R094WgE8K4JirYjBaOpz/AvTyUu/3wbmAoskKN/pxTI=593 honnef.co/go/tools v0.6.1 h1:R094WgE8K4JirYjBaOpz/AvTyUu/3wbmAoskKN/pxTI=
562 honnef.co/go/tools v0.6.1/go.mod h1:3puzxxljPCe8RGJX7BIy1plGbxEOZni5mR2aXe3/uk4=594 honnef.co/go/tools v0.6.1/go.mod h1:3puzxxljPCe8RGJX7BIy1plGbxEOZni5mR2aXe3/uk4=
595+modernc.org/cc/v4 v4.27.3 h1:uNCgn37E5U09mTv1XgskEVUJ8ADKpmFMPxzGJ0TSo+U=
596+modernc.org/cc/v4 v4.27.3/go.mod h1:3YjcbCqhoTTHPycJDRl2WZKKFj0nwcOIPBfEZK0Hdk8=
597+modernc.org/ccgo/v4 v4.32.4 h1:L5OB8rpEX4ZsXEQwGozRfJyJSFHbbNVOoQ59DU9/KuU=
598+modernc.org/ccgo/v4 v4.32.4/go.mod h1:lY7f+fiTDHfcv6YlRgSkxYfhs+UvOEEzj49jAn2TOx0=
599+modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
600+modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
601+modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
602+modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
603+modernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo=
604+modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
605+modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
606+modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
607+modernc.org/libc v1.72.0 h1:IEu559v9a0XWjw0DPoVKtXpO2qt5NVLAnFaBbjq+n8c=
608+modernc.org/libc v1.72.0/go.mod h1:tTU8DL8A+XLVkEY3x5E/tO7s2Q/q42EtnNWda/L5QhQ=
609+modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
610+modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
611+modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
612+modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
613+modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
614+modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
615+modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
616+modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
617+modernc.org/sqlite v1.49.1 h1:dYGHTKcX1sJ+EQDnUzvz4TJ5GbuvhNJa8Fg6ElGx73U=
618+modernc.org/sqlite v1.49.1/go.mod h1:m0w8xhwYUVY3H6pSDwc3gkJ/irZT/0YEXwBlhaxQEew=
619+modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
620+modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
621+modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
622+modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
563 mvdan.cc/gofumpt v0.7.0 h1:bg91ttqXmi9y2xawvkuMXyvAA/1ZGJqYAEGjXuP0JXU=623 mvdan.cc/gofumpt v0.7.0 h1:bg91ttqXmi9y2xawvkuMXyvAA/1ZGJqYAEGjXuP0JXU=
564 mvdan.cc/gofumpt v0.7.0/go.mod h1:txVFJy/Sc/mvaycET54pV8SW8gWxTlUuGHVEcncmNUo=624 mvdan.cc/gofumpt v0.7.0/go.mod h1:txVFJy/Sc/mvaycET54pV8SW8gWxTlUuGHVEcncmNUo=
565 mvdan.cc/unparam v0.0.0-20240528143540-8a5130ca722f h1:lMpcwN6GxNbWtbpI1+xzFLSW8XzX0u72NttUGVFjO3U=625 mvdan.cc/unparam v0.0.0-20240528143540-8a5130ca722f h1:lMpcwN6GxNbWtbpI1+xzFLSW8XzX0u72NttUGVFjO3U=
deleted internal/.gitkeep +0 -0
deleted file mode 100644
deleted file mode 100644
added internal/atproto/auth.go +85 -0
new file mode 100644
@@ -0,0 +1,85 @@
1+package atproto
2+
3+import (
4+ "context"
5+ "fmt"
6+
7+ "github.com/bluesky-social/indigo/atproto/identity"
8+ "github.com/bluesky-social/indigo/atproto/syntax"
9+)
10+
11+type DIDDocument = identity.DIDDocument
12+type Identity = identity.Identity
13+
14+func ResolveHandle(ctx context.Context, handle string) (string, error) {
15+ h, err := syntax.ParseHandle(handle)
16+ if err != nil {
17+ return "", fmt.Errorf("parsing handle: %w", err)
18+ }
19+
20+ dir := identity.DefaultDirectory()
21+ ident, err := dir.LookupHandle(ctx, h)
22+ if err != nil {
23+ return "", fmt.Errorf("resolving handle: %w", err)
24+ }
25+
26+ return ident.DID.String(), nil
27+}
28+
29+func ResolveDID(ctx context.Context, did string) (*DIDDocument, error) {
30+ d, err := syntax.ParseDID(did)
31+ if err != nil {
32+ return nil, fmt.Errorf("parsing DID: %w", err)
33+ }
34+
35+ dir := identity.DefaultDirectory()
36+ ident, err := dir.LookupDID(ctx, d)
37+ if err != nil {
38+ return nil, fmt.Errorf("resolving DID: %w", err)
39+ }
40+
41+ doc := ident.DIDDocument()
42+ return &doc, nil
43+}
44+
45+func ResolveIdentity(ctx context.Context, identifier string) (*Identity, error) {
46+ atid, err := syntax.ParseAtIdentifier(identifier)
47+ if err != nil {
48+ return nil, fmt.Errorf("parsing identifier: %w", err)
49+ }
50+
51+ dir := identity.DefaultDirectory()
52+ ident, err := dir.Lookup(ctx, atid)
53+ if err != nil {
54+ return nil, fmt.Errorf("resolving identity: %w", err)
55+ }
56+
57+ return ident, nil
58+}
59+
60+func ResolvePDSEndpoint(ctx context.Context, did string) (string, error) {
61+ ident, err := ResolveIdentity(ctx, did)
62+ if err != nil {
63+ return "", err
64+ }
65+
66+ pds := ident.PDSEndpoint()
67+ if pds == "" {
68+ return "", fmt.Errorf("no PDS endpoint found for %s", did)
69+ }
70+
71+ return pds, nil
72+}
73+
74+type OAuthConfig struct {
75+ ClientID string
76+ RedirectURL string
77+ Scopes []string
78+}
79+
80+type OAuthTokens struct {
81+ AccessToken string
82+ RefreshToken string
83+ DID string
84+ Handle string
85+}
new file mode 100644
@@ -0,0 +1,85 @@
1+package atproto
2+
3+import (
4+ "context"
5+ "fmt"
6+
7+ "github.com/bluesky-social/indigo/atproto/identity"
8+ "github.com/bluesky-social/indigo/atproto/syntax"
9+)
10+
11+type DIDDocument = identity.DIDDocument
12+type Identity = identity.Identity
13+
14+func ResolveHandle(ctx context.Context, handle string) (string, error) {
15+ h, err := syntax.ParseHandle(handle)
16+ if err != nil {
17+ return "", fmt.Errorf("parsing handle: %w", err)
18+ }
19+
20+ dir := identity.DefaultDirectory()
21+ ident, err := dir.LookupHandle(ctx, h)
22+ if err != nil {
23+ return "", fmt.Errorf("resolving handle: %w", err)
24+ }
25+
26+ return ident.DID.String(), nil
27+}
28+
29+func ResolveDID(ctx context.Context, did string) (*DIDDocument, error) {
30+ d, err := syntax.ParseDID(did)
31+ if err != nil {
32+ return nil, fmt.Errorf("parsing DID: %w", err)
33+ }
34+
35+ dir := identity.DefaultDirectory()
36+ ident, err := dir.LookupDID(ctx, d)
37+ if err != nil {
38+ return nil, fmt.Errorf("resolving DID: %w", err)
39+ }
40+
41+ doc := ident.DIDDocument()
42+ return &doc, nil
43+}
44+
45+func ResolveIdentity(ctx context.Context, identifier string) (*Identity, error) {
46+ atid, err := syntax.ParseAtIdentifier(identifier)
47+ if err != nil {
48+ return nil, fmt.Errorf("parsing identifier: %w", err)
49+ }
50+
51+ dir := identity.DefaultDirectory()
52+ ident, err := dir.Lookup(ctx, atid)
53+ if err != nil {
54+ return nil, fmt.Errorf("resolving identity: %w", err)
55+ }
56+
57+ return ident, nil
58+}
59+
60+func ResolvePDSEndpoint(ctx context.Context, did string) (string, error) {
61+ ident, err := ResolveIdentity(ctx, did)
62+ if err != nil {
63+ return "", err
64+ }
65+
66+ pds := ident.PDSEndpoint()
67+ if pds == "" {
68+ return "", fmt.Errorf("no PDS endpoint found for %s", did)
69+ }
70+
71+ return pds, nil
72+}
73+
74+type OAuthConfig struct {
75+ ClientID string
76+ RedirectURL string
77+ Scopes []string
78+}
79+
80+type OAuthTokens struct {
81+ AccessToken string
82+ RefreshToken string
83+ DID string
84+ Handle string
85+}
added internal/atproto/client.go +153 -0
new file mode 100644
@@ -0,0 +1,153 @@
1+package atproto
2+
3+import (
4+ "bytes"
5+ "context"
6+ "encoding/json"
7+ "fmt"
8+ "net/http"
9+
10+ "github.com/bluesky-social/indigo/atproto/syntax"
11+)
12+
13+type Client struct {
14+ httpClient *http.Client
15+ pdsURL string
16+ accessToken string
17+}
18+
19+func NewClient(pdsURL, accessToken string) *Client {
20+ return &Client{
21+ httpClient: &http.Client{},
22+ pdsURL: pdsURL,
23+ accessToken: accessToken,
24+ }
25+}
26+
27+func (c *Client) CreateRecord(ctx context.Context, did, collection string, record any) (string, string, error) {
28+ nsid, err := syntax.ParseNSID(collection)
29+ if err != nil {
30+ return "", "", fmt.Errorf("parsing collection NSID: %w", err)
31+ }
32+
33+ body := map[string]any{
34+ "repo": did,
35+ "collection": nsid.String(),
36+ "record": record,
37+ }
38+
39+ data, err := json.Marshal(body)
40+ if err != nil {
41+ return "", "", err
42+ }
43+
44+ url := c.pdsURL + "/xrpc/com.atproto.repo.createRecord"
45+ req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(data))
46+ if err != nil {
47+ return "", "", err
48+ }
49+ req.Header.Set("Content-Type", "application/json")
50+ req.Header.Set("Authorization", "Bearer "+c.accessToken)
51+
52+ resp, err := c.httpClient.Do(req)
53+ if err != nil {
54+ return "", "", err
55+ }
56+ defer resp.Body.Close()
57+
58+ if resp.StatusCode != http.StatusOK {
59+ return "", "", fmt.Errorf("create record returned %d", resp.StatusCode)
60+ }
61+
62+ var result struct {
63+ URI string `json:"uri"`
64+ CID string `json:"cid"`
65+ }
66+ if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
67+ return "", "", err
68+ }
69+
70+ return result.URI, result.CID, nil
71+}
72+
73+func (c *Client) DeleteRecord(ctx context.Context, did, collection, rkey string) error {
74+ body := map[string]any{
75+ "repo": did,
76+ "collection": collection,
77+ "rkey": rkey,
78+ }
79+
80+ data, err := json.Marshal(body)
81+ if err != nil {
82+ return err
83+ }
84+
85+ url := c.pdsURL + "/xrpc/com.atproto.repo.deleteRecord"
86+ req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(data))
87+ if err != nil {
88+ return err
89+ }
90+ req.Header.Set("Content-Type", "application/json")
91+ req.Header.Set("Authorization", "Bearer "+c.accessToken)
92+
93+ resp, err := c.httpClient.Do(req)
94+ if err != nil {
95+ return err
96+ }
97+ defer resp.Body.Close()
98+
99+ if resp.StatusCode != http.StatusOK {
100+ return fmt.Errorf("delete record returned %d", resp.StatusCode)
101+ }
102+
103+ return nil
104+}
105+
106+func (c *Client) ListRecords(ctx context.Context, did, collection string, limit int, cursor string) ([]Record, string, error) {
107+ url := fmt.Sprintf("%s/xrpc/com.atproto.repo.listRecords?repo=%s&collection=%s", c.pdsURL, did, collection)
108+ if limit > 0 {
109+ url += fmt.Sprintf("&limit=%d", limit)
110+ }
111+ if cursor != "" {
112+ url += "&cursor=" + cursor
113+ }
114+
115+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
116+ if err != nil {
117+ return nil, "", err
118+ }
119+ req.Header.Set("Authorization", "Bearer "+c.accessToken)
120+
121+ resp, err := c.httpClient.Do(req)
122+ if err != nil {
123+ return nil, "", err
124+ }
125+ defer resp.Body.Close()
126+
127+ if resp.StatusCode != http.StatusOK {
128+ return nil, "", fmt.Errorf("list records returned %d", resp.StatusCode)
129+ }
130+
131+ var result struct {
132+ Records []struct {
133+ URI string `json:"uri"`
134+ CID string `json:"cid"`
135+ Value json.RawMessage `json:"value"`
136+ } `json:"records"`
137+ Cursor string `json:"cursor"`
138+ }
139+ if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
140+ return nil, "", err
141+ }
142+
143+ records := make([]Record, len(result.Records))
144+ for i, r := range result.Records {
145+ records[i] = Record{
146+ URI: r.URI,
147+ CID: r.CID,
148+ Value: r.Value,
149+ }
150+ }
151+
152+ return records, result.Cursor, nil
153+}
new file mode 100644
@@ -0,0 +1,153 @@
1+package atproto
2+
3+import (
4+ "bytes"
5+ "context"
6+ "encoding/json"
7+ "fmt"
8+ "net/http"
9+
10+ "github.com/bluesky-social/indigo/atproto/syntax"
11+)
12+
13+type Client struct {
14+ httpClient *http.Client
15+ pdsURL string
16+ accessToken string
17+}
18+
19+func NewClient(pdsURL, accessToken string) *Client {
20+ return &Client{
21+ httpClient: &http.Client{},
22+ pdsURL: pdsURL,
23+ accessToken: accessToken,
24+ }
25+}
26+
27+func (c *Client) CreateRecord(ctx context.Context, did, collection string, record any) (string, string, error) {
28+ nsid, err := syntax.ParseNSID(collection)
29+ if err != nil {
30+ return "", "", fmt.Errorf("parsing collection NSID: %w", err)
31+ }
32+
33+ body := map[string]any{
34+ "repo": did,
35+ "collection": nsid.String(),
36+ "record": record,
37+ }
38+
39+ data, err := json.Marshal(body)
40+ if err != nil {
41+ return "", "", err
42+ }
43+
44+ url := c.pdsURL + "/xrpc/com.atproto.repo.createRecord"
45+ req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(data))
46+ if err != nil {
47+ return "", "", err
48+ }
49+ req.Header.Set("Content-Type", "application/json")
50+ req.Header.Set("Authorization", "Bearer "+c.accessToken)
51+
52+ resp, err := c.httpClient.Do(req)
53+ if err != nil {
54+ return "", "", err
55+ }
56+ defer resp.Body.Close()
57+
58+ if resp.StatusCode != http.StatusOK {
59+ return "", "", fmt.Errorf("create record returned %d", resp.StatusCode)
60+ }
61+
62+ var result struct {
63+ URI string `json:"uri"`
64+ CID string `json:"cid"`
65+ }
66+ if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
67+ return "", "", err
68+ }
69+
70+ return result.URI, result.CID, nil
71+}
72+
73+func (c *Client) DeleteRecord(ctx context.Context, did, collection, rkey string) error {
74+ body := map[string]any{
75+ "repo": did,
76+ "collection": collection,
77+ "rkey": rkey,
78+ }
79+
80+ data, err := json.Marshal(body)
81+ if err != nil {
82+ return err
83+ }
84+
85+ url := c.pdsURL + "/xrpc/com.atproto.repo.deleteRecord"
86+ req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(data))
87+ if err != nil {
88+ return err
89+ }
90+ req.Header.Set("Content-Type", "application/json")
91+ req.Header.Set("Authorization", "Bearer "+c.accessToken)
92+
93+ resp, err := c.httpClient.Do(req)
94+ if err != nil {
95+ return err
96+ }
97+ defer resp.Body.Close()
98+
99+ if resp.StatusCode != http.StatusOK {
100+ return fmt.Errorf("delete record returned %d", resp.StatusCode)
101+ }
102+
103+ return nil
104+}
105+
106+func (c *Client) ListRecords(ctx context.Context, did, collection string, limit int, cursor string) ([]Record, string, error) {
107+ url := fmt.Sprintf("%s/xrpc/com.atproto.repo.listRecords?repo=%s&collection=%s", c.pdsURL, did, collection)
108+ if limit > 0 {
109+ url += fmt.Sprintf("&limit=%d", limit)
110+ }
111+ if cursor != "" {
112+ url += "&cursor=" + cursor
113+ }
114+
115+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
116+ if err != nil {
117+ return nil, "", err
118+ }
119+ req.Header.Set("Authorization", "Bearer "+c.accessToken)
120+
121+ resp, err := c.httpClient.Do(req)
122+ if err != nil {
123+ return nil, "", err
124+ }
125+ defer resp.Body.Close()
126+
127+ if resp.StatusCode != http.StatusOK {
128+ return nil, "", fmt.Errorf("list records returned %d", resp.StatusCode)
129+ }
130+
131+ var result struct {
132+ Records []struct {
133+ URI string `json:"uri"`
134+ CID string `json:"cid"`
135+ Value json.RawMessage `json:"value"`
136+ } `json:"records"`
137+ Cursor string `json:"cursor"`
138+ }
139+ if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
140+ return nil, "", err
141+ }
142+
143+ records := make([]Record, len(result.Records))
144+ for i, r := range result.Records {
145+ records[i] = Record{
146+ URI: r.URI,
147+ CID: r.CID,
148+ Value: r.Value,
149+ }
150+ }
151+
152+ return records, result.Cursor, nil
153+}
added internal/atproto/firehose.go +201 -0
new file mode 100644
@@ -0,0 +1,201 @@
1+package atproto
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "fmt"
7+ "log/slog"
8+ "net/http"
9+ "net/url"
10+ "time"
11+
12+ "github.com/gorilla/websocket"
13+)
14+
15+type FirehoseEvent struct {
16+ Type string
17+ DID string
18+ Collection string
19+ RKey string
20+ URI string
21+ CID string
22+ Value json.RawMessage
23+}
24+
25+type FirehoseHandler func(ctx context.Context, event *FirehoseEvent) error
26+
27+type FirehoseConsumer struct {
28+ relayURL string
29+ handler FirehoseHandler
30+ cursor int64
31+ logger *slog.Logger
32+ collections map[string]bool
33+}
34+
35+func NewFirehoseConsumer(relayURL string, handler FirehoseHandler, logger *slog.Logger) *FirehoseConsumer {
36+ return &FirehoseConsumer{
37+ relayURL: relayURL,
38+ handler: handler,
39+ logger: logger,
40+ collections: map[string]bool{
41+ "at.glean.subscription": true,
42+ "at.glean.annotation": true,
43+ "at.glean.like": true,
44+ },
45+ }
46+}
47+
48+func (fc *FirehoseConsumer) Start(ctx context.Context) error {
49+ for {
50+ err := fc.connect(ctx)
51+ if ctx.Err() != nil {
52+ return ctx.Err()
53+ }
54+ if err != nil {
55+ fc.logger.Error("firehose connection error", "error", err)
56+ }
57+
58+ select {
59+ case <-ctx.Done():
60+ return ctx.Err()
61+ case <-time.After(5 * time.Second):
62+ }
63+ }
64+}
65+
66+func (fc *FirehoseConsumer) connect(ctx context.Context) error {
67+ u, err := url.Parse(fc.relayURL)
68+ if err != nil {
69+ return fmt.Errorf("parsing relay URL: %w", err)
70+ }
71+
72+ scheme := "wss"
73+ if u.Scheme == "http" || u.Scheme == "ws" {
74+ scheme = "ws"
75+ }
76+
77+ wsURL := fmt.Sprintf("%s://%s/xrpc/com.atproto.sync.subscribeRepos", scheme, u.Host)
78+ if fc.cursor > 0 {
79+ wsURL += fmt.Sprintf("?cursor=%d", fc.cursor)
80+ }
81+
82+ fc.logger.Info("connecting to firehose", "url", wsURL)
83+
84+ conn, _, err := websocket.DefaultDialer.DialContext(ctx, wsURL, http.Header{})
85+ if err != nil {
86+ return fmt.Errorf("dialing firehose: %w", err)
87+ }
88+ defer conn.Close()
89+
90+ fc.logger.Info("firehose connected")
91+
92+ for {
93+ select {
94+ case <-ctx.Done():
95+ return ctx.Err()
96+ default:
97+ }
98+
99+ _, msg, err := conn.ReadMessage()
100+ if err != nil {
101+ return fmt.Errorf("reading firehose: %w", err)
102+ }
103+
104+ fc.handleMessage(ctx, msg)
105+ }
106+}
107+
108+func (fc *FirehoseConsumer) handleMessage(ctx context.Context, msg []byte) {
109+ var frame struct {
110+ Type string `json:"type"`
111+ Commit json.RawMessage `json:"commit"`
112+ Seq int64 `json:"seq"`
113+ }
114+
115+ if err := json.Unmarshal(msg, &frame); err != nil {
116+ return
117+ }
118+
119+ if frame.Seq > 0 {
120+ fc.cursor = frame.Seq
121+ }
122+
123+ if frame.Type == "#commit" {
124+ fc.parseCommit(ctx, frame.Commit)
125+ }
126+}
127+
128+func (fc *FirehoseConsumer) parseCommit(ctx context.Context, raw json.RawMessage) {
129+ var commit struct {
130+ Did string `json:"did"`
131+ Ops []struct {
132+ Action string `json:"action"`
133+ Path string `json:"path"`
134+ CID json.RawMessage `json:"cid"`
135+ Record json.RawMessage `json:"record"`
136+ } `json:"ops"`
137+ }
138+
139+ if err := json.Unmarshal(raw, &commit); err != nil {
140+ return
141+ }
142+
143+ for _, op := range commit.Ops {
144+ parts := splitPath(op.Path)
145+ if len(parts) != 2 {
146+ continue
147+ }
148+
149+ collection := parts[0]
150+ rkey := parts[1]
151+
152+ if !fc.collections[collection] {
153+ continue
154+ }
155+
156+ var action string
157+ switch op.Action {
158+ case "create":
159+ action = "create"
160+ case "update":
161+ action = "update"
162+ case "delete":
163+ action = "delete"
164+ default:
165+ continue
166+ }
167+
168+ evt := &FirehoseEvent{
169+ Type: action,
170+ DID: commit.Did,
171+ Collection: collection,
172+ RKey: rkey,
173+ URI: fmt.Sprintf("at://%s/%s/%s", commit.Did, collection, rkey),
174+ Value: op.Record,
175+ }
176+
177+ if op.CID != nil {
178+ evt.CID = string(op.CID)
179+ }
180+
181+ if err := fc.handler(ctx, evt); err != nil {
182+ fc.logger.Error("firehose handler error", "error", err)
183+ }
184+ }
185+}
186+
187+func splitPath(p string) []string {
188+ if p == "" {
189+ return nil
190+ }
191+ var parts []string
192+ start := 0
193+ for i := 0; i < len(p); i++ {
194+ if p[i] == '/' {
195+ parts = append(parts, p[start:i])
196+ start = i + 1
197+ }
198+ }
199+ parts = append(parts, p[start:])
200+ return parts
201+}
new file mode 100644
@@ -0,0 +1,201 @@
1+package atproto
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "fmt"
7+ "log/slog"
8+ "net/http"
9+ "net/url"
10+ "time"
11+
12+ "github.com/gorilla/websocket"
13+)
14+
15+type FirehoseEvent struct {
16+ Type string
17+ DID string
18+ Collection string
19+ RKey string
20+ URI string
21+ CID string
22+ Value json.RawMessage
23+}
24+
25+type FirehoseHandler func(ctx context.Context, event *FirehoseEvent) error
26+
27+type FirehoseConsumer struct {
28+ relayURL string
29+ handler FirehoseHandler
30+ cursor int64
31+ logger *slog.Logger
32+ collections map[string]bool
33+}
34+
35+func NewFirehoseConsumer(relayURL string, handler FirehoseHandler, logger *slog.Logger) *FirehoseConsumer {
36+ return &FirehoseConsumer{
37+ relayURL: relayURL,
38+ handler: handler,
39+ logger: logger,
40+ collections: map[string]bool{
41+ "at.glean.subscription": true,
42+ "at.glean.annotation": true,
43+ "at.glean.like": true,
44+ },
45+ }
46+}
47+
48+func (fc *FirehoseConsumer) Start(ctx context.Context) error {
49+ for {
50+ err := fc.connect(ctx)
51+ if ctx.Err() != nil {
52+ return ctx.Err()
53+ }
54+ if err != nil {
55+ fc.logger.Error("firehose connection error", "error", err)
56+ }
57+
58+ select {
59+ case <-ctx.Done():
60+ return ctx.Err()
61+ case <-time.After(5 * time.Second):
62+ }
63+ }
64+}
65+
66+func (fc *FirehoseConsumer) connect(ctx context.Context) error {
67+ u, err := url.Parse(fc.relayURL)
68+ if err != nil {
69+ return fmt.Errorf("parsing relay URL: %w", err)
70+ }
71+
72+ scheme := "wss"
73+ if u.Scheme == "http" || u.Scheme == "ws" {
74+ scheme = "ws"
75+ }
76+
77+ wsURL := fmt.Sprintf("%s://%s/xrpc/com.atproto.sync.subscribeRepos", scheme, u.Host)
78+ if fc.cursor > 0 {
79+ wsURL += fmt.Sprintf("?cursor=%d", fc.cursor)
80+ }
81+
82+ fc.logger.Info("connecting to firehose", "url", wsURL)
83+
84+ conn, _, err := websocket.DefaultDialer.DialContext(ctx, wsURL, http.Header{})
85+ if err != nil {
86+ return fmt.Errorf("dialing firehose: %w", err)
87+ }
88+ defer conn.Close()
89+
90+ fc.logger.Info("firehose connected")
91+
92+ for {
93+ select {
94+ case <-ctx.Done():
95+ return ctx.Err()
96+ default:
97+ }
98+
99+ _, msg, err := conn.ReadMessage()
100+ if err != nil {
101+ return fmt.Errorf("reading firehose: %w", err)
102+ }
103+
104+ fc.handleMessage(ctx, msg)
105+ }
106+}
107+
108+func (fc *FirehoseConsumer) handleMessage(ctx context.Context, msg []byte) {
109+ var frame struct {
110+ Type string `json:"type"`
111+ Commit json.RawMessage `json:"commit"`
112+ Seq int64 `json:"seq"`
113+ }
114+
115+ if err := json.Unmarshal(msg, &frame); err != nil {
116+ return
117+ }
118+
119+ if frame.Seq > 0 {
120+ fc.cursor = frame.Seq
121+ }
122+
123+ if frame.Type == "#commit" {
124+ fc.parseCommit(ctx, frame.Commit)
125+ }
126+}
127+
128+func (fc *FirehoseConsumer) parseCommit(ctx context.Context, raw json.RawMessage) {
129+ var commit struct {
130+ Did string `json:"did"`
131+ Ops []struct {
132+ Action string `json:"action"`
133+ Path string `json:"path"`
134+ CID json.RawMessage `json:"cid"`
135+ Record json.RawMessage `json:"record"`
136+ } `json:"ops"`
137+ }
138+
139+ if err := json.Unmarshal(raw, &commit); err != nil {
140+ return
141+ }
142+
143+ for _, op := range commit.Ops {
144+ parts := splitPath(op.Path)
145+ if len(parts) != 2 {
146+ continue
147+ }
148+
149+ collection := parts[0]
150+ rkey := parts[1]
151+
152+ if !fc.collections[collection] {
153+ continue
154+ }
155+
156+ var action string
157+ switch op.Action {
158+ case "create":
159+ action = "create"
160+ case "update":
161+ action = "update"
162+ case "delete":
163+ action = "delete"
164+ default:
165+ continue
166+ }
167+
168+ evt := &FirehoseEvent{
169+ Type: action,
170+ DID: commit.Did,
171+ Collection: collection,
172+ RKey: rkey,
173+ URI: fmt.Sprintf("at://%s/%s/%s", commit.Did, collection, rkey),
174+ Value: op.Record,
175+ }
176+
177+ if op.CID != nil {
178+ evt.CID = string(op.CID)
179+ }
180+
181+ if err := fc.handler(ctx, evt); err != nil {
182+ fc.logger.Error("firehose handler error", "error", err)
183+ }
184+ }
185+}
186+
187+func splitPath(p string) []string {
188+ if p == "" {
189+ return nil
190+ }
191+ var parts []string
192+ start := 0
193+ for i := 0; i < len(p); i++ {
194+ if p[i] == '/' {
195+ parts = append(parts, p[start:i])
196+ start = i + 1
197+ }
198+ }
199+ parts = append(parts, p[start:])
200+ return parts
201+}
added internal/atproto/lexicon.go +126 -0
new file mode 100644
@@ -0,0 +1,126 @@
1+package atproto
2+
3+import "time"
4+
5+type SubscriptionRecord struct {
6+ CreatedAt string `json:"createdAt"`
7+ FeedURL string `json:"feedUrl"`
8+ Title string `json:"title,omitempty"`
9+ Category string `json:"category,omitempty"`
10+}
11+
12+type AnnotationRecord struct {
13+ CreatedAt string `json:"createdAt"`
14+ FeedURL string `json:"feedUrl"`
15+ ArticleURL string `json:"articleUrl"`
16+ Quote string `json:"quote,omitempty"`
17+ Note string `json:"note,omitempty"`
18+ Tags []string `json:"tags,omitempty"`
19+ Rating int `json:"rating,omitempty"`
20+}
21+
22+type LikeRecord struct {
23+ CreatedAt string `json:"createdAt"`
24+ FeedURL string `json:"feedUrl"`
25+ ArticleURL string `json:"articleUrl"`
26+}
27+
28+type Record struct {
29+ URI string
30+ CID string
31+ DID string
32+ Collection string
33+ RKey string
34+ Value []byte
35+ IndexedAt time.Time
36+}
37+
38+type SubscriptionView struct {
39+ URI string `json:"uri"`
40+ CID string `json:"cid"`
41+ Value SubscriptionRecord `json:"value"`
42+ IndexedAt string `json:"indexedAt"`
43+}
44+
45+type ListSubscriptionsResponse struct {
46+ Cursor string `json:"cursor,omitempty"`
47+ Subscriptions []SubscriptionView `json:"subscriptions"`
48+}
49+
50+type FeedListEntry struct {
51+ DID string `json:"did"`
52+ SubscriptionCount int `json:"subscriptionCount"`
53+ Subscriptions []SubscriptionRecord `json:"subscriptions"`
54+}
55+
56+type ListFeedListsResponse struct {
57+ Cursor string `json:"cursor,omitempty"`
58+ Feeds []FeedListEntry `json:"feeds"`
59+}
60+
61+type ActorView struct {
62+ DID string `json:"did"`
63+ Handle string `json:"handle"`
64+}
65+
66+type AnnotationView struct {
67+ URI string `json:"uri"`
68+ CID string `json:"cid"`
69+ Author ActorView `json:"author"`
70+ Value AnnotationRecord `json:"value"`
71+ IndexedAt string `json:"indexedAt"`
72+}
73+
74+type ListAnnotationsResponse struct {
75+ Cursor string `json:"cursor,omitempty"`
76+ Annotations []AnnotationView `json:"annotations"`
77+}
78+
79+type LikeView struct {
80+ URI string `json:"uri"`
81+ CID string `json:"cid"`
82+ Author ActorView `json:"author"`
83+ Value LikeRecord `json:"value"`
84+ IndexedAt string `json:"indexedAt"`
85+}
86+
87+type ListLikesResponse struct {
88+ Cursor string `json:"cursor,omitempty"`
89+ Likes []LikeView `json:"likes"`
90+}
91+
92+type TrendingArticle struct {
93+ FeedURL string `json:"feedUrl"`
94+ ArticleURL string `json:"articleUrl"`
95+ Title string `json:"title"`
96+ LikeCount int `json:"likeCount"`
97+ Annotations []AnnotationView `json:"annotations"`
98+}
99+
100+type GetTrendingResponse struct {
101+ Cursor string `json:"cursor,omitempty"`
102+ Articles []TrendingArticle `json:"articles"`
103+}
104+
105+type RecommendedFeed struct {
106+ FeedURL string `json:"feedUrl"`
107+ Title string `json:"title"`
108+ SiteURL string `json:"siteUrl"`
109+ Description string `json:"description"`
110+ SubscriberCount int `json:"subscriberCount"`
111+ Score float64 `json:"score"`
112+}
113+
114+type RecommendedPerson struct {
115+ DID string `json:"did"`
116+ Handle string `json:"handle"`
117+ DisplayName string `json:"displayName"`
118+ Avatar string `json:"avatar"`
119+ Jaccard float64 `json:"jaccard"`
120+ CommonFeeds int `json:"commonFeeds"`
121+}
122+
123+type GetRecommendationsResponse struct {
124+ Feeds []RecommendedFeed `json:"feeds"`
125+ People []RecommendedPerson `json:"people"`
126+}
new file mode 100644
@@ -0,0 +1,126 @@
1+package atproto
2+
3+import "time"
4+
5+type SubscriptionRecord struct {
6+ CreatedAt string `json:"createdAt"`
7+ FeedURL string `json:"feedUrl"`
8+ Title string `json:"title,omitempty"`
9+ Category string `json:"category,omitempty"`
10+}
11+
12+type AnnotationRecord struct {
13+ CreatedAt string `json:"createdAt"`
14+ FeedURL string `json:"feedUrl"`
15+ ArticleURL string `json:"articleUrl"`
16+ Quote string `json:"quote,omitempty"`
17+ Note string `json:"note,omitempty"`
18+ Tags []string `json:"tags,omitempty"`
19+ Rating int `json:"rating,omitempty"`
20+}
21+
22+type LikeRecord struct {
23+ CreatedAt string `json:"createdAt"`
24+ FeedURL string `json:"feedUrl"`
25+ ArticleURL string `json:"articleUrl"`
26+}
27+
28+type Record struct {
29+ URI string
30+ CID string
31+ DID string
32+ Collection string
33+ RKey string
34+ Value []byte
35+ IndexedAt time.Time
36+}
37+
38+type SubscriptionView struct {
39+ URI string `json:"uri"`
40+ CID string `json:"cid"`
41+ Value SubscriptionRecord `json:"value"`
42+ IndexedAt string `json:"indexedAt"`
43+}
44+
45+type ListSubscriptionsResponse struct {
46+ Cursor string `json:"cursor,omitempty"`
47+ Subscriptions []SubscriptionView `json:"subscriptions"`
48+}
49+
50+type FeedListEntry struct {
51+ DID string `json:"did"`
52+ SubscriptionCount int `json:"subscriptionCount"`
53+ Subscriptions []SubscriptionRecord `json:"subscriptions"`
54+}
55+
56+type ListFeedListsResponse struct {
57+ Cursor string `json:"cursor,omitempty"`
58+ Feeds []FeedListEntry `json:"feeds"`
59+}
60+
61+type ActorView struct {
62+ DID string `json:"did"`
63+ Handle string `json:"handle"`
64+}
65+
66+type AnnotationView struct {
67+ URI string `json:"uri"`
68+ CID string `json:"cid"`
69+ Author ActorView `json:"author"`
70+ Value AnnotationRecord `json:"value"`
71+ IndexedAt string `json:"indexedAt"`
72+}
73+
74+type ListAnnotationsResponse struct {
75+ Cursor string `json:"cursor,omitempty"`
76+ Annotations []AnnotationView `json:"annotations"`
77+}
78+
79+type LikeView struct {
80+ URI string `json:"uri"`
81+ CID string `json:"cid"`
82+ Author ActorView `json:"author"`
83+ Value LikeRecord `json:"value"`
84+ IndexedAt string `json:"indexedAt"`
85+}
86+
87+type ListLikesResponse struct {
88+ Cursor string `json:"cursor,omitempty"`
89+ Likes []LikeView `json:"likes"`
90+}
91+
92+type TrendingArticle struct {
93+ FeedURL string `json:"feedUrl"`
94+ ArticleURL string `json:"articleUrl"`
95+ Title string `json:"title"`
96+ LikeCount int `json:"likeCount"`
97+ Annotations []AnnotationView `json:"annotations"`
98+}
99+
100+type GetTrendingResponse struct {
101+ Cursor string `json:"cursor,omitempty"`
102+ Articles []TrendingArticle `json:"articles"`
103+}
104+
105+type RecommendedFeed struct {
106+ FeedURL string `json:"feedUrl"`
107+ Title string `json:"title"`
108+ SiteURL string `json:"siteUrl"`
109+ Description string `json:"description"`
110+ SubscriberCount int `json:"subscriberCount"`
111+ Score float64 `json:"score"`
112+}
113+
114+type RecommendedPerson struct {
115+ DID string `json:"did"`
116+ Handle string `json:"handle"`
117+ DisplayName string `json:"displayName"`
118+ Avatar string `json:"avatar"`
119+ Jaccard float64 `json:"jaccard"`
120+ CommonFeeds int `json:"commonFeeds"`
121+}
122+
123+type GetRecommendationsResponse struct {
124+ Feeds []RecommendedFeed `json:"feeds"`
125+ People []RecommendedPerson `json:"people"`
126+}
added internal/atproto/xrpc.go +496 -0
new file mode 100644
@@ -0,0 +1,496 @@
1+package atproto
2+
3+import (
4+ "database/sql"
5+ "encoding/json"
6+ "net/http"
7+ "strconv"
8+ "strings"
9+
10+ "github.com/go-chi/chi/v5"
11+)
12+
13+type XRPCHandler struct {
14+ db *sql.DB
15+}
16+
17+func NewXRPCHandler(db *sql.DB) *XRPCHandler {
18+ return &XRPCHandler{db: db}
19+}
20+
21+func (h *XRPCHandler) ListSubscriptions(w http.ResponseWriter, r *http.Request) {
22+ repo := chi.URLParam(r, "repo")
23+ category := r.URL.Query().Get("category")
24+ limit := parseIntParam(r, "limit", 50)
25+ cursor := r.URL.Query().Get("cursor")
26+
27+ query := `
28+ SELECT s.id, f.feed_url, f.title, s.category, s.added_at
29+ FROM subscriptions s
30+ JOIN feeds f ON s.feed_url = f.feed_url
31+ WHERE s.user_did = ?`
32+ args := []any{repo}
33+
34+ if category != "" {
35+ query += " AND s.category = ?"
36+ args = append(args, category)
37+ }
38+ if cursor != "" {
39+ query += " AND s.id > ?"
40+ args = append(args, cursor)
41+ }
42+
43+ query += " ORDER BY s.id ASC LIMIT ?"
44+ args = append(args, limit+1)
45+
46+ rows, err := h.db.QueryContext(r.Context(), query, args...)
47+ if err != nil {
48+ http.Error(w, err.Error(), http.StatusInternalServerError)
49+ return
50+ }
51+ defer rows.Close()
52+
53+ subs := make([]SubscriptionView, 0)
54+ for rows.Next() {
55+ var id int
56+ var feedURL, title string
57+ var cat, addedAt sql.NullString
58+ if err := rows.Scan(&id, &feedURL, &title, &cat, &addedAt); err != nil {
59+ http.Error(w, err.Error(), http.StatusInternalServerError)
60+ return
61+ }
62+
63+ sv := SubscriptionView{
64+ URI: fmtATURI(repo, "at.glean.subscription", strconv.Itoa(id)),
65+ Value: SubscriptionRecord{
66+ CreatedAt: addedAt.String,
67+ FeedURL: feedURL,
68+ Title: title,
69+ Category: cat.String,
70+ },
71+ IndexedAt: addedAt.String,
72+ }
73+ subs = append(subs, sv)
74+ }
75+
76+ resp := ListSubscriptionsResponse{Subscriptions: subs}
77+ if len(subs) > limit {
78+ resp.Cursor = strconv.Itoa(limit)
79+ resp.Subscriptions = subs[:limit]
80+ }
81+
82+ writeJSON(w, resp)
83+}
84+
85+func (h *XRPCHandler) ListAnnotations(w http.ResponseWriter, r *http.Request) {
86+ feedURL := r.URL.Query().Get("feedUrl")
87+ articleURL := r.URL.Query().Get("articleUrl")
88+ author := r.URL.Query().Get("author")
89+ limit := parseIntParam(r, "limit", 50)
90+ cursor := r.URL.Query().Get("cursor")
91+
92+ query := `
93+ SELECT a.uri, a.cid, u.did, u.handle, a.feed_url, a.article_url,
94+ a.quote, a.note, a.tags, a.rating, a.created_at
95+ FROM annotations a
96+ JOIN users u ON a.author_did = u.did
97+ WHERE 1=1`
98+ args := []any{}
99+
100+ if feedURL != "" {
101+ query += " AND a.feed_url = ?"
102+ args = append(args, feedURL)
103+ }
104+ if articleURL != "" {
105+ query += " AND a.article_url = ?"
106+ args = append(args, articleURL)
107+ }
108+ if author != "" {
109+ query += " AND a.author_did = ?"
110+ args = append(args, author)
111+ }
112+ if cursor != "" {
113+ query += " AND a.id > ?"
114+ args = append(args, cursor)
115+ }
116+
117+ query += " ORDER BY a.id ASC LIMIT ?"
118+ args = append(args, limit+1)
119+
120+ rows, err := h.db.QueryContext(r.Context(), query, args...)
121+ if err != nil {
122+ http.Error(w, err.Error(), http.StatusInternalServerError)
123+ return
124+ }
125+ defer rows.Close()
126+
127+ annotations := make([]AnnotationView, 0)
128+ for rows.Next() {
129+ var uri, did, handle, fURL, artURL, createdAt string
130+ var cid, quote, note, tags sql.NullString
131+ var rating sql.NullInt64
132+ if err := rows.Scan(&uri, &cid, &did, &handle, &fURL, &artURL, &quote, &note, &tags, &rating, &createdAt); err != nil {
133+ http.Error(w, err.Error(), http.StatusInternalServerError)
134+ return
135+ }
136+
137+ var tagSlice []string
138+ if tags.Valid && tags.String != "" {
139+ tagSlice = strings.Split(tags.String, ",")
140+ }
141+
142+ av := AnnotationView{
143+ URI: uri,
144+ CID: cid.String,
145+ Author: ActorView{
146+ DID: did,
147+ Handle: handle,
148+ },
149+ Value: AnnotationRecord{
150+ CreatedAt: createdAt,
151+ FeedURL: fURL,
152+ ArticleURL: artURL,
153+ Quote: quote.String,
154+ Note: note.String,
155+ Tags: tagSlice,
156+ Rating: int(rating.Int64),
157+ },
158+ IndexedAt: createdAt,
159+ }
160+ annotations = append(annotations, av)
161+ }
162+
163+ resp := ListAnnotationsResponse{Annotations: annotations}
164+ if len(annotations) > limit {
165+ resp.Cursor = strconv.Itoa(limit)
166+ resp.Annotations = annotations[:limit]
167+ }
168+
169+ writeJSON(w, resp)
170+}
171+
172+func (h *XRPCHandler) ListLikes(w http.ResponseWriter, r *http.Request) {
173+ author := r.URL.Query().Get("author")
174+ feedURL := r.URL.Query().Get("feedUrl")
175+ limit := parseIntParam(r, "limit", 50)
176+ cursor := r.URL.Query().Get("cursor")
177+
178+ query := `
179+ SELECT l.uri, l.cid, u.did, u.handle, l.feed_url, l.article_url, l.created_at
180+ FROM likes l
181+ JOIN users u ON l.author_did = u.did
182+ WHERE 1=1`
183+ args := []any{}
184+
185+ if author != "" {
186+ query += " AND l.author_did = ?"
187+ args = append(args, author)
188+ }
189+ if feedURL != "" {
190+ query += " AND l.feed_url = ?"
191+ args = append(args, feedURL)
192+ }
193+ if cursor != "" {
194+ query += " AND l.id > ?"
195+ args = append(args, cursor)
196+ }
197+
198+ query += " ORDER BY l.id ASC LIMIT ?"
199+ args = append(args, limit+1)
200+
201+ rows, err := h.db.QueryContext(r.Context(), query, args...)
202+ if err != nil {
203+ http.Error(w, err.Error(), http.StatusInternalServerError)
204+ return
205+ }
206+ defer rows.Close()
207+
208+ likes := make([]LikeView, 0)
209+ for rows.Next() {
210+ var uri, did, handle, fURL, artURL, createdAt string
211+ var cid sql.NullString
212+ if err := rows.Scan(&uri, &cid, &did, &handle, &fURL, &artURL, &createdAt); err != nil {
213+ http.Error(w, err.Error(), http.StatusInternalServerError)
214+ return
215+ }
216+
217+ lv := LikeView{
218+ URI: uri,
219+ CID: cid.String,
220+ Author: ActorView{
221+ DID: did,
222+ Handle: handle,
223+ },
224+ Value: LikeRecord{
225+ CreatedAt: createdAt,
226+ FeedURL: fURL,
227+ ArticleURL: artURL,
228+ },
229+ IndexedAt: createdAt,
230+ }
231+ likes = append(likes, lv)
232+ }
233+
234+ resp := ListLikesResponse{Likes: likes}
235+ if len(likes) > limit {
236+ resp.Cursor = strconv.Itoa(limit)
237+ resp.Likes = likes[:limit]
238+ }
239+
240+ writeJSON(w, resp)
241+}
242+
243+func (h *XRPCHandler) GetTrending(w http.ResponseWriter, r *http.Request) {
244+ limit := parseIntParam(r, "limit", 25)
245+ cursor := r.URL.Query().Get("cursor")
246+ since := r.URL.Query().Get("since")
247+
248+ query := `
249+ SELECT l.feed_url, l.article_url, a.title, COUNT(*) as like_count
250+ FROM likes l
251+ LEFT JOIN articles a ON l.article_url = a.url
252+ WHERE 1=1`
253+ args := []any{}
254+
255+ if since != "" {
256+ query += " AND l.created_at >= ?"
257+ args = append(args, since)
258+ }
259+ if cursor != "" {
260+ query += " AND l.article_url > ?"
261+ args = append(args, cursor)
262+ }
263+
264+ query += " GROUP BY l.feed_url, l.article_url ORDER BY like_count DESC LIMIT ?"
265+ args = append(args, limit+1)
266+
267+ rows, err := h.db.QueryContext(r.Context(), query, args...)
268+ if err != nil {
269+ http.Error(w, err.Error(), http.StatusInternalServerError)
270+ return
271+ }
272+ defer rows.Close()
273+
274+ articles := make([]TrendingArticle, 0)
275+ for rows.Next() {
276+ var feedURL, articleURL string
277+ var title sql.NullString
278+ var likeCount int
279+ if err := rows.Scan(&feedURL, &articleURL, &title, &likeCount); err != nil {
280+ http.Error(w, err.Error(), http.StatusInternalServerError)
281+ return
282+ }
283+
284+ ta := TrendingArticle{
285+ FeedURL: feedURL,
286+ ArticleURL: articleURL,
287+ Title: title.String,
288+ LikeCount: likeCount,
289+ }
290+ articles = append(articles, ta)
291+ }
292+
293+ resp := GetTrendingResponse{Articles: articles}
294+ if len(articles) > limit {
295+ resp.Cursor = strconv.Itoa(limit)
296+ resp.Articles = articles[:limit]
297+ }
298+
299+ writeJSON(w, resp)
300+}
301+
302+func (h *XRPCHandler) GetRecommendations(w http.ResponseWriter, r *http.Request) {
303+ repo := chi.URLParam(r, "repo")
304+ limit := parseIntParam(r, "limit", 10)
305+
306+ feedRows, err := h.db.QueryContext(r.Context(), `
307+ SELECT r.feed_url, f.title, f.site_url, f.description, f.subscriber_count, r.score
308+ FROM user_feed_recommendations r
309+ JOIN feeds f ON r.feed_url = f.feed_url
310+ WHERE r.user_did = ?
311+ ORDER BY r.score DESC
312+ LIMIT ?
313+ `, repo, limit)
314+ if err != nil {
315+ http.Error(w, err.Error(), http.StatusInternalServerError)
316+ return
317+ }
318+ defer feedRows.Close()
319+
320+ feeds := make([]RecommendedFeed, 0)
321+ for feedRows.Next() {
322+ var feedURL, title, siteURL, description string
323+ var subscriberCount int
324+ var score float64
325+ if err := feedRows.Scan(&feedURL, &title, &siteURL, &description, &subscriberCount, &score); err != nil {
326+ http.Error(w, err.Error(), http.StatusInternalServerError)
327+ return
328+ }
329+ feeds = append(feeds, RecommendedFeed{
330+ FeedURL: feedURL,
331+ Title: title,
332+ SiteURL: siteURL,
333+ Description: description,
334+ SubscriberCount: subscriberCount,
335+ Score: score,
336+ })
337+ }
338+
339+ peopleRows, err := h.db.QueryContext(r.Context(), `
340+ SELECT u.did, u.handle, u.display_name, u.avatar_url, s.jaccard, s.common_feeds
341+ FROM user_similarity s
342+ JOIN users u ON (
343+ CASE WHEN s.user_a = ? THEN s.user_b ELSE s.user_a END
344+ ) = u.did
345+ WHERE s.user_a = ? OR s.user_b = ?
346+ ORDER BY s.jaccard DESC
347+ LIMIT ?
348+ `, repo, repo, repo, limit)
349+ if err != nil {
350+ http.Error(w, err.Error(), http.StatusInternalServerError)
351+ return
352+ }
353+ defer peopleRows.Close()
354+
355+ people := make([]RecommendedPerson, 0)
356+ for peopleRows.Next() {
357+ var did, handle string
358+ var displayName, avatar sql.NullString
359+ var jaccard float64
360+ var commonFeeds int
361+ if err := peopleRows.Scan(&did, &handle, &displayName, &avatar, &jaccard, &commonFeeds); err != nil {
362+ http.Error(w, err.Error(), http.StatusInternalServerError)
363+ return
364+ }
365+ people = append(people, RecommendedPerson{
366+ DID: did,
367+ Handle: handle,
368+ DisplayName: displayName.String,
369+ Avatar: avatar.String,
370+ Jaccard: jaccard,
371+ CommonFeeds: commonFeeds,
372+ })
373+ }
374+
375+ writeJSON(w, GetRecommendationsResponse{Feeds: feeds, People: people})
376+}
377+
378+func (h *XRPCHandler) ListFeedLists(w http.ResponseWriter, r *http.Request) {
379+ actorsParam := r.URL.Query().Get("actors")
380+ limit := parseIntParam(r, "limit", 50)
381+ cursor := r.URL.Query().Get("cursor")
382+
383+ var dids []string
384+ if actorsParam != "" {
385+ dids = strings.Split(actorsParam, ",")
386+ }
387+
388+ if len(dids) == 0 {
389+ writeJSON(w, ListFeedListsResponse{})
390+ return
391+ }
392+
393+ placeholders := make([]string, len(dids))
394+ args := make([]any, len(dids))
395+ for i, d := range dids {
396+ placeholders[i] = "?"
397+ args[i] = d
398+ }
399+
400+ query := `
401+ SELECT u.did, u.handle, COUNT(s.id) as subscription_count
402+ FROM users u
403+ LEFT JOIN subscriptions s ON u.did = s.user_did
404+ WHERE u.did IN (` + strings.Join(placeholders, ",") + `)`
405+
406+ if cursor != "" {
407+ query += " AND u.did > ?"
408+ args = append(args, cursor)
409+ }
410+
411+ query += " GROUP BY u.did ORDER BY u.did ASC LIMIT ?"
412+ args = append(args, limit+1)
413+
414+ rows, err := h.db.QueryContext(r.Context(), query, args...)
415+ if err != nil {
416+ http.Error(w, err.Error(), http.StatusInternalServerError)
417+ return
418+ }
419+ defer rows.Close()
420+
421+ feedLists := make([]FeedListEntry, 0)
422+ for rows.Next() {
423+ var did, handle string
424+ var subCount int
425+ if err := rows.Scan(&did, &handle, &subCount); err != nil {
426+ http.Error(w, err.Error(), http.StatusInternalServerError)
427+ return
428+ }
429+
430+ subRows, err := h.db.QueryContext(r.Context(), `
431+ SELECT f.feed_url, f.title, s.category
432+ FROM subscriptions s
433+ JOIN feeds f ON s.feed_url = f.feed_url
434+ WHERE s.user_did = ?
435+ ORDER BY s.added_at DESC
436+ `, did)
437+ if err != nil {
438+ http.Error(w, err.Error(), http.StatusInternalServerError)
439+ return
440+ }
441+
442+ subs := make([]SubscriptionRecord, 0)
443+ for subRows.Next() {
444+ var feedURL, title string
445+ var cat sql.NullString
446+ if err := subRows.Scan(&feedURL, &title, &cat); err != nil {
447+ subRows.Close()
448+ http.Error(w, err.Error(), http.StatusInternalServerError)
449+ return
450+ }
451+ subs = append(subs, SubscriptionRecord{
452+ FeedURL: feedURL,
453+ Title: title,
454+ Category: cat.String,
455+ })
456+ }
457+ subRows.Close()
458+
459+ feedLists = append(feedLists, FeedListEntry{
460+ DID: did,
461+ SubscriptionCount: subCount,
462+ Subscriptions: subs,
463+ })
464+ }
465+
466+ resp := ListFeedListsResponse{Feeds: feedLists}
467+ if len(feedLists) > limit {
468+ resp.Cursor = strconv.Itoa(limit)
469+ resp.Feeds = feedLists[:limit]
470+ }
471+
472+ writeJSON(w, resp)
473+}
474+
475+func parseIntParam(r *http.Request, key string, defaultVal int) int {
476+ v := r.URL.Query().Get(key)
477+ if v == "" {
478+ return defaultVal
479+ }
480+ n, err := strconv.Atoi(v)
481+ if err != nil {
482+ return defaultVal
483+ }
484+ return n
485+}
486+
487+func fmtATURI(did, collection, rkey string) string {
488+ return "at://" + did + "/" + collection + "/" + rkey
489+}
490+
491+func writeJSON(w http.ResponseWriter, v any) {
492+ w.Header().Set("Content-Type", "application/json")
493+ if err := json.NewEncoder(w).Encode(v); err != nil {
494+ http.Error(w, err.Error(), http.StatusInternalServerError)
495+ }
496+}
new file mode 100644
@@ -0,0 +1,496 @@
1+package atproto
2+
3+import (
4+ "database/sql"
5+ "encoding/json"
6+ "net/http"
7+ "strconv"
8+ "strings"
9+
10+ "github.com/go-chi/chi/v5"
11+)
12+
13+type XRPCHandler struct {
14+ db *sql.DB
15+}
16+
17+func NewXRPCHandler(db *sql.DB) *XRPCHandler {
18+ return &XRPCHandler{db: db}
19+}
20+
21+func (h *XRPCHandler) ListSubscriptions(w http.ResponseWriter, r *http.Request) {
22+ repo := chi.URLParam(r, "repo")
23+ category := r.URL.Query().Get("category")
24+ limit := parseIntParam(r, "limit", 50)
25+ cursor := r.URL.Query().Get("cursor")
26+
27+ query := `
28+ SELECT s.id, f.feed_url, f.title, s.category, s.added_at
29+ FROM subscriptions s
30+ JOIN feeds f ON s.feed_url = f.feed_url
31+ WHERE s.user_did = ?`
32+ args := []any{repo}
33+
34+ if category != "" {
35+ query += " AND s.category = ?"
36+ args = append(args, category)
37+ }
38+ if cursor != "" {
39+ query += " AND s.id > ?"
40+ args = append(args, cursor)
41+ }
42+
43+ query += " ORDER BY s.id ASC LIMIT ?"
44+ args = append(args, limit+1)
45+
46+ rows, err := h.db.QueryContext(r.Context(), query, args...)
47+ if err != nil {
48+ http.Error(w, err.Error(), http.StatusInternalServerError)
49+ return
50+ }
51+ defer rows.Close()
52+
53+ subs := make([]SubscriptionView, 0)
54+ for rows.Next() {
55+ var id int
56+ var feedURL, title string
57+ var cat, addedAt sql.NullString
58+ if err := rows.Scan(&id, &feedURL, &title, &cat, &addedAt); err != nil {
59+ http.Error(w, err.Error(), http.StatusInternalServerError)
60+ return
61+ }
62+
63+ sv := SubscriptionView{
64+ URI: fmtATURI(repo, "at.glean.subscription", strconv.Itoa(id)),
65+ Value: SubscriptionRecord{
66+ CreatedAt: addedAt.String,
67+ FeedURL: feedURL,
68+ Title: title,
69+ Category: cat.String,
70+ },
71+ IndexedAt: addedAt.String,
72+ }
73+ subs = append(subs, sv)
74+ }
75+
76+ resp := ListSubscriptionsResponse{Subscriptions: subs}
77+ if len(subs) > limit {
78+ resp.Cursor = strconv.Itoa(limit)
79+ resp.Subscriptions = subs[:limit]
80+ }
81+
82+ writeJSON(w, resp)
83+}
84+
85+func (h *XRPCHandler) ListAnnotations(w http.ResponseWriter, r *http.Request) {
86+ feedURL := r.URL.Query().Get("feedUrl")
87+ articleURL := r.URL.Query().Get("articleUrl")
88+ author := r.URL.Query().Get("author")
89+ limit := parseIntParam(r, "limit", 50)
90+ cursor := r.URL.Query().Get("cursor")
91+
92+ query := `
93+ SELECT a.uri, a.cid, u.did, u.handle, a.feed_url, a.article_url,
94+ a.quote, a.note, a.tags, a.rating, a.created_at
95+ FROM annotations a
96+ JOIN users u ON a.author_did = u.did
97+ WHERE 1=1`
98+ args := []any{}
99+
100+ if feedURL != "" {
101+ query += " AND a.feed_url = ?"
102+ args = append(args, feedURL)
103+ }
104+ if articleURL != "" {
105+ query += " AND a.article_url = ?"
106+ args = append(args, articleURL)
107+ }
108+ if author != "" {
109+ query += " AND a.author_did = ?"
110+ args = append(args, author)
111+ }
112+ if cursor != "" {
113+ query += " AND a.id > ?"
114+ args = append(args, cursor)
115+ }
116+
117+ query += " ORDER BY a.id ASC LIMIT ?"
118+ args = append(args, limit+1)
119+
120+ rows, err := h.db.QueryContext(r.Context(), query, args...)
121+ if err != nil {
122+ http.Error(w, err.Error(), http.StatusInternalServerError)
123+ return
124+ }
125+ defer rows.Close()
126+
127+ annotations := make([]AnnotationView, 0)
128+ for rows.Next() {
129+ var uri, did, handle, fURL, artURL, createdAt string
130+ var cid, quote, note, tags sql.NullString
131+ var rating sql.NullInt64
132+ if err := rows.Scan(&uri, &cid, &did, &handle, &fURL, &artURL, &quote, &note, &tags, &rating, &createdAt); err != nil {
133+ http.Error(w, err.Error(), http.StatusInternalServerError)
134+ return
135+ }
136+
137+ var tagSlice []string
138+ if tags.Valid && tags.String != "" {
139+ tagSlice = strings.Split(tags.String, ",")
140+ }
141+
142+ av := AnnotationView{
143+ URI: uri,
144+ CID: cid.String,
145+ Author: ActorView{
146+ DID: did,
147+ Handle: handle,
148+ },
149+ Value: AnnotationRecord{
150+ CreatedAt: createdAt,
151+ FeedURL: fURL,
152+ ArticleURL: artURL,
153+ Quote: quote.String,
154+ Note: note.String,
155+ Tags: tagSlice,
156+ Rating: int(rating.Int64),
157+ },
158+ IndexedAt: createdAt,
159+ }
160+ annotations = append(annotations, av)
161+ }
162+
163+ resp := ListAnnotationsResponse{Annotations: annotations}
164+ if len(annotations) > limit {
165+ resp.Cursor = strconv.Itoa(limit)
166+ resp.Annotations = annotations[:limit]
167+ }
168+
169+ writeJSON(w, resp)
170+}
171+
172+func (h *XRPCHandler) ListLikes(w http.ResponseWriter, r *http.Request) {
173+ author := r.URL.Query().Get("author")
174+ feedURL := r.URL.Query().Get("feedUrl")
175+ limit := parseIntParam(r, "limit", 50)
176+ cursor := r.URL.Query().Get("cursor")
177+
178+ query := `
179+ SELECT l.uri, l.cid, u.did, u.handle, l.feed_url, l.article_url, l.created_at
180+ FROM likes l
181+ JOIN users u ON l.author_did = u.did
182+ WHERE 1=1`
183+ args := []any{}
184+
185+ if author != "" {
186+ query += " AND l.author_did = ?"
187+ args = append(args, author)
188+ }
189+ if feedURL != "" {
190+ query += " AND l.feed_url = ?"
191+ args = append(args, feedURL)
192+ }
193+ if cursor != "" {
194+ query += " AND l.id > ?"
195+ args = append(args, cursor)
196+ }
197+
198+ query += " ORDER BY l.id ASC LIMIT ?"
199+ args = append(args, limit+1)
200+
201+ rows, err := h.db.QueryContext(r.Context(), query, args...)
202+ if err != nil {
203+ http.Error(w, err.Error(), http.StatusInternalServerError)
204+ return
205+ }
206+ defer rows.Close()
207+
208+ likes := make([]LikeView, 0)
209+ for rows.Next() {
210+ var uri, did, handle, fURL, artURL, createdAt string
211+ var cid sql.NullString
212+ if err := rows.Scan(&uri, &cid, &did, &handle, &fURL, &artURL, &createdAt); err != nil {
213+ http.Error(w, err.Error(), http.StatusInternalServerError)
214+ return
215+ }
216+
217+ lv := LikeView{
218+ URI: uri,
219+ CID: cid.String,
220+ Author: ActorView{
221+ DID: did,
222+ Handle: handle,
223+ },
224+ Value: LikeRecord{
225+ CreatedAt: createdAt,
226+ FeedURL: fURL,
227+ ArticleURL: artURL,
228+ },
229+ IndexedAt: createdAt,
230+ }
231+ likes = append(likes, lv)
232+ }
233+
234+ resp := ListLikesResponse{Likes: likes}
235+ if len(likes) > limit {
236+ resp.Cursor = strconv.Itoa(limit)
237+ resp.Likes = likes[:limit]
238+ }
239+
240+ writeJSON(w, resp)
241+}
242+
243+func (h *XRPCHandler) GetTrending(w http.ResponseWriter, r *http.Request) {
244+ limit := parseIntParam(r, "limit", 25)
245+ cursor := r.URL.Query().Get("cursor")
246+ since := r.URL.Query().Get("since")
247+
248+ query := `
249+ SELECT l.feed_url, l.article_url, a.title, COUNT(*) as like_count
250+ FROM likes l
251+ LEFT JOIN articles a ON l.article_url = a.url
252+ WHERE 1=1`
253+ args := []any{}
254+
255+ if since != "" {
256+ query += " AND l.created_at >= ?"
257+ args = append(args, since)
258+ }
259+ if cursor != "" {
260+ query += " AND l.article_url > ?"
261+ args = append(args, cursor)
262+ }
263+
264+ query += " GROUP BY l.feed_url, l.article_url ORDER BY like_count DESC LIMIT ?"
265+ args = append(args, limit+1)
266+
267+ rows, err := h.db.QueryContext(r.Context(), query, args...)
268+ if err != nil {
269+ http.Error(w, err.Error(), http.StatusInternalServerError)
270+ return
271+ }
272+ defer rows.Close()
273+
274+ articles := make([]TrendingArticle, 0)
275+ for rows.Next() {
276+ var feedURL, articleURL string
277+ var title sql.NullString
278+ var likeCount int
279+ if err := rows.Scan(&feedURL, &articleURL, &title, &likeCount); err != nil {
280+ http.Error(w, err.Error(), http.StatusInternalServerError)
281+ return
282+ }
283+
284+ ta := TrendingArticle{
285+ FeedURL: feedURL,
286+ ArticleURL: articleURL,
287+ Title: title.String,
288+ LikeCount: likeCount,
289+ }
290+ articles = append(articles, ta)
291+ }
292+
293+ resp := GetTrendingResponse{Articles: articles}
294+ if len(articles) > limit {
295+ resp.Cursor = strconv.Itoa(limit)
296+ resp.Articles = articles[:limit]
297+ }
298+
299+ writeJSON(w, resp)
300+}
301+
302+func (h *XRPCHandler) GetRecommendations(w http.ResponseWriter, r *http.Request) {
303+ repo := chi.URLParam(r, "repo")
304+ limit := parseIntParam(r, "limit", 10)
305+
306+ feedRows, err := h.db.QueryContext(r.Context(), `
307+ SELECT r.feed_url, f.title, f.site_url, f.description, f.subscriber_count, r.score
308+ FROM user_feed_recommendations r
309+ JOIN feeds f ON r.feed_url = f.feed_url
310+ WHERE r.user_did = ?
311+ ORDER BY r.score DESC
312+ LIMIT ?
313+ `, repo, limit)
314+ if err != nil {
315+ http.Error(w, err.Error(), http.StatusInternalServerError)
316+ return
317+ }
318+ defer feedRows.Close()
319+
320+ feeds := make([]RecommendedFeed, 0)
321+ for feedRows.Next() {
322+ var feedURL, title, siteURL, description string
323+ var subscriberCount int
324+ var score float64
325+ if err := feedRows.Scan(&feedURL, &title, &siteURL, &description, &subscriberCount, &score); err != nil {
326+ http.Error(w, err.Error(), http.StatusInternalServerError)
327+ return
328+ }
329+ feeds = append(feeds, RecommendedFeed{
330+ FeedURL: feedURL,
331+ Title: title,
332+ SiteURL: siteURL,
333+ Description: description,
334+ SubscriberCount: subscriberCount,
335+ Score: score,
336+ })
337+ }
338+
339+ peopleRows, err := h.db.QueryContext(r.Context(), `
340+ SELECT u.did, u.handle, u.display_name, u.avatar_url, s.jaccard, s.common_feeds
341+ FROM user_similarity s
342+ JOIN users u ON (
343+ CASE WHEN s.user_a = ? THEN s.user_b ELSE s.user_a END
344+ ) = u.did
345+ WHERE s.user_a = ? OR s.user_b = ?
346+ ORDER BY s.jaccard DESC
347+ LIMIT ?
348+ `, repo, repo, repo, limit)
349+ if err != nil {
350+ http.Error(w, err.Error(), http.StatusInternalServerError)
351+ return
352+ }
353+ defer peopleRows.Close()
354+
355+ people := make([]RecommendedPerson, 0)
356+ for peopleRows.Next() {
357+ var did, handle string
358+ var displayName, avatar sql.NullString
359+ var jaccard float64
360+ var commonFeeds int
361+ if err := peopleRows.Scan(&did, &handle, &displayName, &avatar, &jaccard, &commonFeeds); err != nil {
362+ http.Error(w, err.Error(), http.StatusInternalServerError)
363+ return
364+ }
365+ people = append(people, RecommendedPerson{
366+ DID: did,
367+ Handle: handle,
368+ DisplayName: displayName.String,
369+ Avatar: avatar.String,
370+ Jaccard: jaccard,
371+ CommonFeeds: commonFeeds,
372+ })
373+ }
374+
375+ writeJSON(w, GetRecommendationsResponse{Feeds: feeds, People: people})
376+}
377+
378+func (h *XRPCHandler) ListFeedLists(w http.ResponseWriter, r *http.Request) {
379+ actorsParam := r.URL.Query().Get("actors")
380+ limit := parseIntParam(r, "limit", 50)
381+ cursor := r.URL.Query().Get("cursor")
382+
383+ var dids []string
384+ if actorsParam != "" {
385+ dids = strings.Split(actorsParam, ",")
386+ }
387+
388+ if len(dids) == 0 {
389+ writeJSON(w, ListFeedListsResponse{})
390+ return
391+ }
392+
393+ placeholders := make([]string, len(dids))
394+ args := make([]any, len(dids))
395+ for i, d := range dids {
396+ placeholders[i] = "?"
397+ args[i] = d
398+ }
399+
400+ query := `
401+ SELECT u.did, u.handle, COUNT(s.id) as subscription_count
402+ FROM users u
403+ LEFT JOIN subscriptions s ON u.did = s.user_did
404+ WHERE u.did IN (` + strings.Join(placeholders, ",") + `)`
405+
406+ if cursor != "" {
407+ query += " AND u.did > ?"
408+ args = append(args, cursor)
409+ }
410+
411+ query += " GROUP BY u.did ORDER BY u.did ASC LIMIT ?"
412+ args = append(args, limit+1)
413+
414+ rows, err := h.db.QueryContext(r.Context(), query, args...)
415+ if err != nil {
416+ http.Error(w, err.Error(), http.StatusInternalServerError)
417+ return
418+ }
419+ defer rows.Close()
420+
421+ feedLists := make([]FeedListEntry, 0)
422+ for rows.Next() {
423+ var did, handle string
424+ var subCount int
425+ if err := rows.Scan(&did, &handle, &subCount); err != nil {
426+ http.Error(w, err.Error(), http.StatusInternalServerError)
427+ return
428+ }
429+
430+ subRows, err := h.db.QueryContext(r.Context(), `
431+ SELECT f.feed_url, f.title, s.category
432+ FROM subscriptions s
433+ JOIN feeds f ON s.feed_url = f.feed_url
434+ WHERE s.user_did = ?
435+ ORDER BY s.added_at DESC
436+ `, did)
437+ if err != nil {
438+ http.Error(w, err.Error(), http.StatusInternalServerError)
439+ return
440+ }
441+
442+ subs := make([]SubscriptionRecord, 0)
443+ for subRows.Next() {
444+ var feedURL, title string
445+ var cat sql.NullString
446+ if err := subRows.Scan(&feedURL, &title, &cat); err != nil {
447+ subRows.Close()
448+ http.Error(w, err.Error(), http.StatusInternalServerError)
449+ return
450+ }
451+ subs = append(subs, SubscriptionRecord{
452+ FeedURL: feedURL,
453+ Title: title,
454+ Category: cat.String,
455+ })
456+ }
457+ subRows.Close()
458+
459+ feedLists = append(feedLists, FeedListEntry{
460+ DID: did,
461+ SubscriptionCount: subCount,
462+ Subscriptions: subs,
463+ })
464+ }
465+
466+ resp := ListFeedListsResponse{Feeds: feedLists}
467+ if len(feedLists) > limit {
468+ resp.Cursor = strconv.Itoa(limit)
469+ resp.Feeds = feedLists[:limit]
470+ }
471+
472+ writeJSON(w, resp)
473+}
474+
475+func parseIntParam(r *http.Request, key string, defaultVal int) int {
476+ v := r.URL.Query().Get(key)
477+ if v == "" {
478+ return defaultVal
479+ }
480+ n, err := strconv.Atoi(v)
481+ if err != nil {
482+ return defaultVal
483+ }
484+ return n
485+}
486+
487+func fmtATURI(did, collection, rkey string) string {
488+ return "at://" + did + "/" + collection + "/" + rkey
489+}
490+
491+func writeJSON(w http.ResponseWriter, v any) {
492+ w.Header().Set("Content-Type", "application/json")
493+ if err := json.NewEncoder(w).Encode(v); err != nil {
494+ http.Error(w, err.Error(), http.StatusInternalServerError)
495+ }
496+}
added internal/cluster/cron.go +43 -0
new file mode 100644
@@ -0,0 +1,43 @@
1+package cluster
2+
3+import (
4+ "context"
5+ "log/slog"
6+ "time"
7+)
8+
9+type Cron struct {
10+ engine *Engine
11+ interval time.Duration
12+ logger *slog.Logger
13+}
14+
15+func NewCron(engine *Engine, interval time.Duration, logger *slog.Logger) *Cron {
16+ return &Cron{engine: engine, interval: interval, logger: logger}
17+}
18+
19+func (c *Cron) Run(ctx context.Context) error {
20+ for {
21+ c.logger.Info("starting similarity computation")
22+
23+ if err := c.engine.ComputeFeedSimilarity(ctx); err != nil {
24+ c.logger.Error("feed similarity failed", "error", err)
25+ }
26+
27+ if err := c.engine.ComputeUserSimilarity(ctx); err != nil {
28+ c.logger.Error("user similarity failed", "error", err)
29+ }
30+
31+ if err := c.engine.ComputeRecommendations(ctx); err != nil {
32+ c.logger.Error("recommendations failed", "error", err)
33+ }
34+
35+ c.logger.Info("similarity computation complete", "next_run", c.interval)
36+
37+ select {
38+ case <-ctx.Done():
39+ return ctx.Err()
40+ case <-time.After(c.interval):
41+ }
42+ }
43+}
new file mode 100644
@@ -0,0 +1,43 @@
1+package cluster
2+
3+import (
4+ "context"
5+ "log/slog"
6+ "time"
7+)
8+
9+type Cron struct {
10+ engine *Engine
11+ interval time.Duration
12+ logger *slog.Logger
13+}
14+
15+func NewCron(engine *Engine, interval time.Duration, logger *slog.Logger) *Cron {
16+ return &Cron{engine: engine, interval: interval, logger: logger}
17+}
18+
19+func (c *Cron) Run(ctx context.Context) error {
20+ for {
21+ c.logger.Info("starting similarity computation")
22+
23+ if err := c.engine.ComputeFeedSimilarity(ctx); err != nil {
24+ c.logger.Error("feed similarity failed", "error", err)
25+ }
26+
27+ if err := c.engine.ComputeUserSimilarity(ctx); err != nil {
28+ c.logger.Error("user similarity failed", "error", err)
29+ }
30+
31+ if err := c.engine.ComputeRecommendations(ctx); err != nil {
32+ c.logger.Error("recommendations failed", "error", err)
33+ }
34+
35+ c.logger.Info("similarity computation complete", "next_run", c.interval)
36+
37+ select {
38+ case <-ctx.Done():
39+ return ctx.Err()
40+ case <-time.After(c.interval):
41+ }
42+ }
43+}
added internal/cluster/jaccard.go +123 -0
new file mode 100644
@@ -0,0 +1,123 @@
1+package cluster
2+
3+import (
4+ "context"
5+ "database/sql"
6+ "log/slog"
7+)
8+
9+type Engine struct {
10+ db *sql.DB
11+ logger *slog.Logger
12+}
13+
14+func NewEngine(db *sql.DB, logger *slog.Logger) *Engine {
15+ return &Engine{db: db, logger: logger}
16+}
17+
18+func (e *Engine) ComputeFeedSimilarity(ctx context.Context) error {
19+ tx, err := e.db.BeginTx(ctx, nil)
20+ if err != nil {
21+ return err
22+ }
23+ defer func() { _ = tx.Rollback() }()
24+
25+ if _, err := tx.ExecContext(ctx, `DELETE FROM feed_similarity`); err != nil {
26+ return err
27+ }
28+
29+ _, err = tx.ExecContext(ctx, `
30+ INSERT INTO feed_similarity (feed_a, feed_b, jaccard)
31+ SELECT
32+ s1.feed_url,
33+ s2.feed_url,
34+ CAST(COUNT(*) AS REAL) / (f1.subscriber_count + f2.subscriber_count - CAST(COUNT(*) AS REAL))
35+ FROM subscriptions s1
36+ JOIN subscriptions s2 ON s1.user_did = s2.user_did AND s1.feed_url < s2.feed_url
37+ JOIN feeds f1 ON f1.feed_url = s1.feed_url
38+ JOIN feeds f2 ON f2.feed_url = s2.feed_url
39+ GROUP BY s1.feed_url, s2.feed_url
40+ HAVING COUNT(*) > 0
41+ `)
42+ if err != nil {
43+ return err
44+ }
45+
46+ e.logger.Info("feed similarity computed")
47+ return tx.Commit()
48+}
49+
50+func (e *Engine) ComputeUserSimilarity(ctx context.Context) error {
51+ tx, err := e.db.BeginTx(ctx, nil)
52+ if err != nil {
53+ return err
54+ }
55+ defer func() { _ = tx.Rollback() }()
56+
57+ if _, err := tx.ExecContext(ctx, `DELETE FROM user_similarity`); err != nil {
58+ return err
59+ }
60+
61+ _, err = tx.ExecContext(ctx, `
62+ INSERT INTO user_similarity (user_a, user_b, jaccard, common_feeds)
63+ SELECT
64+ s1.user_did,
65+ s2.user_did,
66+ CAST(COUNT(*) AS REAL) / (
67+ (SELECT COUNT(*) FROM subscriptions WHERE user_did = s1.user_did) +
68+ (SELECT COUNT(*) FROM subscriptions WHERE user_did = s2.user_did) -
69+ CAST(COUNT(*) AS REAL)
70+ ),
71+ COUNT(*)
72+ FROM subscriptions s1
73+ JOIN subscriptions s2 ON s1.feed_url = s2.feed_url AND s1.user_did < s2.user_did
74+ GROUP BY s1.user_did, s2.user_did
75+ HAVING COUNT(*) > 0
76+ `)
77+ if err != nil {
78+ return err
79+ }
80+
81+ e.logger.Info("user similarity computed")
82+ return tx.Commit()
83+}
84+
85+func (e *Engine) ComputeRecommendations(ctx context.Context) error {
86+ tx, err := e.db.BeginTx(ctx, nil)
87+ if err != nil {
88+ return err
89+ }
90+ defer func() { _ = tx.Rollback() }()
91+
92+ if _, err := tx.ExecContext(ctx, `DELETE FROM user_feed_recommendations`); err != nil {
93+ return err
94+ }
95+
96+ _, err = tx.ExecContext(ctx, `
97+ INSERT INTO user_feed_recommendations (user_did, feed_url, score)
98+ SELECT target, feed_url, SUM(jaccard) AS score
99+ FROM (
100+ SELECT us.user_a AS target, s.feed_url, us.jaccard
101+ FROM user_similarity us
102+ JOIN subscriptions s ON s.user_did = us.user_b
103+ WHERE us.jaccard > 0.2
104+ AND s.feed_url NOT IN (SELECT feed_url FROM subscriptions WHERE user_did = us.user_a)
105+
106+ UNION ALL
107+
108+ SELECT us.user_b AS target, s.feed_url, us.jaccard
109+ FROM user_similarity us
110+ JOIN subscriptions s ON s.user_did = us.user_a
111+ WHERE us.jaccard > 0.2
112+ AND s.feed_url NOT IN (SELECT feed_url FROM subscriptions WHERE user_did = us.user_b)
113+ )
114+ GROUP BY target, feed_url
115+ ORDER BY score DESC
116+ `)
117+ if err != nil {
118+ return err
119+ }
120+
121+ e.logger.Info("feed recommendations computed")
122+ return tx.Commit()
123+}
new file mode 100644
@@ -0,0 +1,123 @@
1+package cluster
2+
3+import (
4+ "context"
5+ "database/sql"
6+ "log/slog"
7+)
8+
9+type Engine struct {
10+ db *sql.DB
11+ logger *slog.Logger
12+}
13+
14+func NewEngine(db *sql.DB, logger *slog.Logger) *Engine {
15+ return &Engine{db: db, logger: logger}
16+}
17+
18+func (e *Engine) ComputeFeedSimilarity(ctx context.Context) error {
19+ tx, err := e.db.BeginTx(ctx, nil)
20+ if err != nil {
21+ return err
22+ }
23+ defer func() { _ = tx.Rollback() }()
24+
25+ if _, err := tx.ExecContext(ctx, `DELETE FROM feed_similarity`); err != nil {
26+ return err
27+ }
28+
29+ _, err = tx.ExecContext(ctx, `
30+ INSERT INTO feed_similarity (feed_a, feed_b, jaccard)
31+ SELECT
32+ s1.feed_url,
33+ s2.feed_url,
34+ CAST(COUNT(*) AS REAL) / (f1.subscriber_count + f2.subscriber_count - CAST(COUNT(*) AS REAL))
35+ FROM subscriptions s1
36+ JOIN subscriptions s2 ON s1.user_did = s2.user_did AND s1.feed_url < s2.feed_url
37+ JOIN feeds f1 ON f1.feed_url = s1.feed_url
38+ JOIN feeds f2 ON f2.feed_url = s2.feed_url
39+ GROUP BY s1.feed_url, s2.feed_url
40+ HAVING COUNT(*) > 0
41+ `)
42+ if err != nil {
43+ return err
44+ }
45+
46+ e.logger.Info("feed similarity computed")
47+ return tx.Commit()
48+}
49+
50+func (e *Engine) ComputeUserSimilarity(ctx context.Context) error {
51+ tx, err := e.db.BeginTx(ctx, nil)
52+ if err != nil {
53+ return err
54+ }
55+ defer func() { _ = tx.Rollback() }()
56+
57+ if _, err := tx.ExecContext(ctx, `DELETE FROM user_similarity`); err != nil {
58+ return err
59+ }
60+
61+ _, err = tx.ExecContext(ctx, `
62+ INSERT INTO user_similarity (user_a, user_b, jaccard, common_feeds)
63+ SELECT
64+ s1.user_did,
65+ s2.user_did,
66+ CAST(COUNT(*) AS REAL) / (
67+ (SELECT COUNT(*) FROM subscriptions WHERE user_did = s1.user_did) +
68+ (SELECT COUNT(*) FROM subscriptions WHERE user_did = s2.user_did) -
69+ CAST(COUNT(*) AS REAL)
70+ ),
71+ COUNT(*)
72+ FROM subscriptions s1
73+ JOIN subscriptions s2 ON s1.feed_url = s2.feed_url AND s1.user_did < s2.user_did
74+ GROUP BY s1.user_did, s2.user_did
75+ HAVING COUNT(*) > 0
76+ `)
77+ if err != nil {
78+ return err
79+ }
80+
81+ e.logger.Info("user similarity computed")
82+ return tx.Commit()
83+}
84+
85+func (e *Engine) ComputeRecommendations(ctx context.Context) error {
86+ tx, err := e.db.BeginTx(ctx, nil)
87+ if err != nil {
88+ return err
89+ }
90+ defer func() { _ = tx.Rollback() }()
91+
92+ if _, err := tx.ExecContext(ctx, `DELETE FROM user_feed_recommendations`); err != nil {
93+ return err
94+ }
95+
96+ _, err = tx.ExecContext(ctx, `
97+ INSERT INTO user_feed_recommendations (user_did, feed_url, score)
98+ SELECT target, feed_url, SUM(jaccard) AS score
99+ FROM (
100+ SELECT us.user_a AS target, s.feed_url, us.jaccard
101+ FROM user_similarity us
102+ JOIN subscriptions s ON s.user_did = us.user_b
103+ WHERE us.jaccard > 0.2
104+ AND s.feed_url NOT IN (SELECT feed_url FROM subscriptions WHERE user_did = us.user_a)
105+
106+ UNION ALL
107+
108+ SELECT us.user_b AS target, s.feed_url, us.jaccard
109+ FROM user_similarity us
110+ JOIN subscriptions s ON s.user_did = us.user_a
111+ WHERE us.jaccard > 0.2
112+ AND s.feed_url NOT IN (SELECT feed_url FROM subscriptions WHERE user_did = us.user_b)
113+ )
114+ GROUP BY target, feed_url
115+ ORDER BY score DESC
116+ `)
117+ if err != nil {
118+ return err
119+ }
120+
121+ e.logger.Info("feed recommendations computed")
122+ return tx.Commit()
123+}
added internal/cluster/recommender.go +111 -0
new file mode 100644
@@ -0,0 +1,111 @@
1+package cluster
2+
3+import (
4+ "context"
5+)
6+
7+func (e *Engine) GetFeedRecommendations(ctx context.Context, userDID string, limit int) ([]map[string]any, error) {
8+ rows, err := e.db.QueryContext(ctx, `
9+ SELECT r.feed_url, f.title, f.site_url, f.description, f.feed_type, r.score
10+ FROM user_feed_recommendations r
11+ JOIN feeds f ON f.feed_url = r.feed_url
12+ WHERE r.user_did = ?
13+ ORDER BY r.score DESC
14+ LIMIT ?
15+ `, userDID, limit)
16+ if err != nil {
17+ return nil, err
18+ }
19+ defer rows.Close()
20+
21+ var results []map[string]any
22+ for rows.Next() {
23+ var feedURL, title, siteURL, description, feedType string
24+ var score float64
25+ if err := rows.Scan(&feedURL, &title, &siteURL, &description, &feedType, &score); err != nil {
26+ return nil, err
27+ }
28+ results = append(results, map[string]any{
29+ "feed_url": feedURL,
30+ "title": title,
31+ "site_url": siteURL,
32+ "description": description,
33+ "feed_type": feedType,
34+ "score": score,
35+ })
36+ }
37+ return results, rows.Err()
38+}
39+
40+func (e *Engine) GetPeopleRecommendations(ctx context.Context, userDID string, limit int) ([]map[string]any, error) {
41+ rows, err := e.db.QueryContext(ctx, `
42+ SELECT u.did, u.handle, u.display_name, u.avatar_url, sim.jaccard, sim.common_feeds
43+ FROM (
44+ SELECT user_b AS peer_did, jaccard, common_feeds FROM user_similarity WHERE user_a = ?
45+ UNION ALL
46+ SELECT user_a AS peer_did, jaccard, common_feeds FROM user_similarity WHERE user_b = ?
47+ ) sim
48+ JOIN users u ON u.did = sim.peer_did
49+ ORDER BY sim.jaccard DESC
50+ LIMIT ?
51+ `, userDID, userDID, limit)
52+ if err != nil {
53+ return nil, err
54+ }
55+ defer rows.Close()
56+
57+ var results []map[string]any
58+ for rows.Next() {
59+ var did, handle, displayName, avatarURL string
60+ var jaccard float64
61+ var commonFeeds int
62+ if err := rows.Scan(&did, &handle, &displayName, &avatarURL, &jaccard, &commonFeeds); err != nil {
63+ return nil, err
64+ }
65+ results = append(results, map[string]any{
66+ "did": did,
67+ "handle": handle,
68+ "display_name": displayName,
69+ "avatar_url": avatarURL,
70+ "jaccard": jaccard,
71+ "common_feeds": commonFeeds,
72+ })
73+ }
74+ return results, rows.Err()
75+}
76+
77+func (e *Engine) GetSimilarFeeds(ctx context.Context, feedURL string, limit int) ([]map[string]any, error) {
78+ rows, err := e.db.QueryContext(ctx, `
79+ SELECT f.feed_url, f.title, f.site_url, f.description, f.feed_type, sim.jaccard
80+ FROM (
81+ SELECT feed_b AS peer_url, jaccard FROM feed_similarity WHERE feed_a = ?
82+ UNION ALL
83+ SELECT feed_a AS peer_url, jaccard FROM feed_similarity WHERE feed_b = ?
84+ ) sim
85+ JOIN feeds f ON f.feed_url = sim.peer_url
86+ ORDER BY sim.jaccard DESC
87+ LIMIT ?
88+ `, feedURL, feedURL, limit)
89+ if err != nil {
90+ return nil, err
91+ }
92+ defer rows.Close()
93+
94+ var results []map[string]any
95+ for rows.Next() {
96+ var peerURL, title, siteURL, description, feedType string
97+ var jaccard float64
98+ if err := rows.Scan(&peerURL, &title, &siteURL, &description, &feedType, &jaccard); err != nil {
99+ return nil, err
100+ }
101+ results = append(results, map[string]any{
102+ "feed_url": peerURL,
103+ "title": title,
104+ "site_url": siteURL,
105+ "description": description,
106+ "feed_type": feedType,
107+ "jaccard": jaccard,
108+ })
109+ }
110+ return results, rows.Err()
111+}
new file mode 100644
@@ -0,0 +1,111 @@
1+package cluster
2+
3+import (
4+ "context"
5+)
6+
7+func (e *Engine) GetFeedRecommendations(ctx context.Context, userDID string, limit int) ([]map[string]any, error) {
8+ rows, err := e.db.QueryContext(ctx, `
9+ SELECT r.feed_url, f.title, f.site_url, f.description, f.feed_type, r.score
10+ FROM user_feed_recommendations r
11+ JOIN feeds f ON f.feed_url = r.feed_url
12+ WHERE r.user_did = ?
13+ ORDER BY r.score DESC
14+ LIMIT ?
15+ `, userDID, limit)
16+ if err != nil {
17+ return nil, err
18+ }
19+ defer rows.Close()
20+
21+ var results []map[string]any
22+ for rows.Next() {
23+ var feedURL, title, siteURL, description, feedType string
24+ var score float64
25+ if err := rows.Scan(&feedURL, &title, &siteURL, &description, &feedType, &score); err != nil {
26+ return nil, err
27+ }
28+ results = append(results, map[string]any{
29+ "feed_url": feedURL,
30+ "title": title,
31+ "site_url": siteURL,
32+ "description": description,
33+ "feed_type": feedType,
34+ "score": score,
35+ })
36+ }
37+ return results, rows.Err()
38+}
39+
40+func (e *Engine) GetPeopleRecommendations(ctx context.Context, userDID string, limit int) ([]map[string]any, error) {
41+ rows, err := e.db.QueryContext(ctx, `
42+ SELECT u.did, u.handle, u.display_name, u.avatar_url, sim.jaccard, sim.common_feeds
43+ FROM (
44+ SELECT user_b AS peer_did, jaccard, common_feeds FROM user_similarity WHERE user_a = ?
45+ UNION ALL
46+ SELECT user_a AS peer_did, jaccard, common_feeds FROM user_similarity WHERE user_b = ?
47+ ) sim
48+ JOIN users u ON u.did = sim.peer_did
49+ ORDER BY sim.jaccard DESC
50+ LIMIT ?
51+ `, userDID, userDID, limit)
52+ if err != nil {
53+ return nil, err
54+ }
55+ defer rows.Close()
56+
57+ var results []map[string]any
58+ for rows.Next() {
59+ var did, handle, displayName, avatarURL string
60+ var jaccard float64
61+ var commonFeeds int
62+ if err := rows.Scan(&did, &handle, &displayName, &avatarURL, &jaccard, &commonFeeds); err != nil {
63+ return nil, err
64+ }
65+ results = append(results, map[string]any{
66+ "did": did,
67+ "handle": handle,
68+ "display_name": displayName,
69+ "avatar_url": avatarURL,
70+ "jaccard": jaccard,
71+ "common_feeds": commonFeeds,
72+ })
73+ }
74+ return results, rows.Err()
75+}
76+
77+func (e *Engine) GetSimilarFeeds(ctx context.Context, feedURL string, limit int) ([]map[string]any, error) {
78+ rows, err := e.db.QueryContext(ctx, `
79+ SELECT f.feed_url, f.title, f.site_url, f.description, f.feed_type, sim.jaccard
80+ FROM (
81+ SELECT feed_b AS peer_url, jaccard FROM feed_similarity WHERE feed_a = ?
82+ UNION ALL
83+ SELECT feed_a AS peer_url, jaccard FROM feed_similarity WHERE feed_b = ?
84+ ) sim
85+ JOIN feeds f ON f.feed_url = sim.peer_url
86+ ORDER BY sim.jaccard DESC
87+ LIMIT ?
88+ `, feedURL, feedURL, limit)
89+ if err != nil {
90+ return nil, err
91+ }
92+ defer rows.Close()
93+
94+ var results []map[string]any
95+ for rows.Next() {
96+ var peerURL, title, siteURL, description, feedType string
97+ var jaccard float64
98+ if err := rows.Scan(&peerURL, &title, &siteURL, &description, &feedType, &jaccard); err != nil {
99+ return nil, err
100+ }
101+ results = append(results, map[string]any{
102+ "feed_url": peerURL,
103+ "title": title,
104+ "site_url": siteURL,
105+ "description": description,
106+ "feed_type": feedType,
107+ "jaccard": jaccard,
108+ })
109+ }
110+ return results, rows.Err()
111+}
added internal/db/article.go +266 -0
new file mode 100644
@@ -0,0 +1,266 @@
1+package db
2+
3+import (
4+ "context"
5+ "database/sql"
6+ "fmt"
7+)
8+
9+type Article struct {
10+ ID int64
11+ FeedURL string
12+ FeedTitle string
13+ GUID string
14+ Title string
15+ URL sql.NullString
16+ Author sql.NullString
17+ Summary sql.NullString
18+ Content sql.NullString
19+ Published sql.NullTime
20+ Updated sql.NullTime
21+ FetchedAt sql.NullTime
22+ IsRead sql.NullBool
23+ IsStarred sql.NullBool
24+}
25+
26+type ReadState struct {
27+ UserDID string
28+ ArticleID int64
29+ IsRead bool
30+ ReadAt sql.NullTime
31+ IsStarred bool
32+ StarredAt sql.NullTime
33+}
34+
35+func (db *DB) UpsertArticle(ctx context.Context, article *Article) (int64, error) {
36+ var id int64
37+ err := db.QueryRowContext(ctx, `
38+ INSERT INTO articles (feed_url, guid, title, url, author, summary, content, published, updated)
39+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
40+ ON CONFLICT(feed_url, guid) DO NOTHING
41+ RETURNING id
42+ `, article.FeedURL, article.GUID, article.Title, article.URL, article.Author,
43+ article.Summary, article.Content, article.Published, article.Updated).Scan(&id)
44+ if err == sql.ErrNoRows {
45+ err = db.QueryRowContext(ctx, `
46+ SELECT id FROM articles WHERE feed_url = ? AND guid = ?
47+ `, article.FeedURL, article.GUID).Scan(&id)
48+ }
49+ return id, err
50+}
51+
52+func (db *DB) GetArticle(ctx context.Context, id int64) (*Article, error) {
53+ a := &Article{}
54+ err := db.QueryRowContext(ctx, `
55+ SELECT id, feed_url, guid, title, url, author, summary, content, published, updated, fetched_at
56+ FROM articles WHERE id = ?
57+ `, id).Scan(&a.ID, &a.FeedURL, &a.GUID, &a.Title, &a.URL, &a.Author,
58+ &a.Summary, &a.Content, &a.Published, &a.Updated, &a.FetchedAt)
59+ if err != nil {
60+ return nil, err
61+ }
62+ return a, nil
63+}
64+
65+func (db *DB) ListArticles(ctx context.Context, userDID, feedURL string, limit, offset int) ([]*Article, error) {
66+ query := `
67+ SELECT a.id, a.feed_url, COALESCE(f.title, ''), a.guid, a.title, a.url, a.author, a.summary, a.content,
68+ a.published, a.updated, a.fetched_at,
69+ COALESCE(r.is_read, 0), COALESCE(r.is_starred, 0)
70+ FROM articles a
71+ JOIN subscriptions s ON a.feed_url = s.feed_url AND s.user_did = ?
72+ LEFT JOIN feeds f ON a.feed_url = f.feed_url
73+ LEFT JOIN read_state r ON r.user_did = ? AND r.article_id = a.id
74+ `
75+ args := []any{userDID, userDID}
76+
77+ if feedURL != "" {
78+ query += ` AND s.feed_url = ?`
79+ args = append(args, feedURL)
80+ }
81+
82+ query += fmt.Sprintf(` ORDER BY a.published DESC LIMIT %d OFFSET %d`, limit, offset)
83+
84+ rows, err := db.QueryContext(ctx, query, args...)
85+ if err != nil {
86+ return nil, err
87+ }
88+ defer rows.Close()
89+
90+ var articles []*Article
91+ for rows.Next() {
92+ a := &Article{}
93+ if err := rows.Scan(&a.ID, &a.FeedURL, &a.FeedTitle, &a.GUID, &a.Title, &a.URL, &a.Author,
94+ &a.Summary, &a.Content, &a.Published, &a.Updated, &a.FetchedAt,
95+ &a.IsRead, &a.IsStarred); err != nil {
96+ return nil, err
97+ }
98+ articles = append(articles, a)
99+ }
100+ return articles, rows.Err()
101+}
102+
103+func (db *DB) ListUnreadArticles(ctx context.Context, userDID, feedURL string, limit, offset int) ([]*Article, error) {
104+ query := `
105+ SELECT a.id, a.feed_url, COALESCE(f.title, ''), a.guid, a.title, a.url, a.author, a.summary, a.content,
106+ a.published, a.updated, a.fetched_at
107+ FROM articles a
108+ JOIN subscriptions s ON a.feed_url = s.feed_url AND s.user_did = ?
109+ LEFT JOIN feeds f ON a.feed_url = f.feed_url
110+ LEFT JOIN read_state r ON r.user_did = ? AND r.article_id = a.id
111+ WHERE r.is_read = 0 OR r.is_read IS NULL
112+ `
113+ args := []any{userDID, userDID}
114+
115+ if feedURL != "" {
116+ query += ` AND a.feed_url = ?`
117+ args = append(args, feedURL)
118+ }
119+
120+ query += fmt.Sprintf(` ORDER BY a.published DESC LIMIT %d OFFSET %d`, limit, offset)
121+
122+ rows, err := db.QueryContext(ctx, query, args...)
123+ if err != nil {
124+ return nil, err
125+ }
126+ defer rows.Close()
127+
128+ var articles []*Article
129+ for rows.Next() {
130+ a := &Article{}
131+ if err := rows.Scan(&a.ID, &a.FeedURL, &a.FeedTitle, &a.GUID, &a.Title, &a.URL, &a.Author,
132+ &a.Summary, &a.Content, &a.Published, &a.Updated, &a.FetchedAt); err != nil {
133+ return nil, err
134+ }
135+ articles = append(articles, a)
136+ }
137+ return articles, rows.Err()
138+}
139+
140+func (db *DB) ListStarredArticles(ctx context.Context, userDID string, limit, offset int) ([]*Article, error) {
141+ rows, err := db.QueryContext(ctx, fmt.Sprintf(`
142+ SELECT a.id, a.feed_url, COALESCE(f.title, ''), a.guid, a.title, a.url, a.author, a.summary, a.content,
143+ a.published, a.updated, a.fetched_at
144+ FROM articles a
145+ JOIN read_state r ON r.article_id = a.id AND r.user_did = ?
146+ LEFT JOIN feeds f ON a.feed_url = f.feed_url
147+ WHERE r.is_starred = 1
148+ ORDER BY r.starred_at DESC
149+ LIMIT %d OFFSET %d
150+ `, limit, offset), userDID)
151+ if err != nil {
152+ return nil, err
153+ }
154+ defer rows.Close()
155+
156+ var articles []*Article
157+ for rows.Next() {
158+ a := &Article{}
159+ if err := rows.Scan(&a.ID, &a.FeedURL, &a.FeedTitle, &a.GUID, &a.Title, &a.URL, &a.Author,
160+ &a.Summary, &a.Content, &a.Published, &a.Updated, &a.FetchedAt); err != nil {
161+ return nil, err
162+ }
163+ articles = append(articles, a)
164+ }
165+ return articles, rows.Err()
166+}
167+
168+func (db *DB) MarkArticleRead(ctx context.Context, userDID string, articleID int64) error {
169+ _, err := db.ExecContext(ctx, `
170+ INSERT INTO read_state (user_did, article_id, is_read, read_at)
171+ VALUES (?, ?, 1, CURRENT_TIMESTAMP)
172+ ON CONFLICT(user_did, article_id) DO UPDATE SET
173+ is_read = 1, read_at = CURRENT_TIMESTAMP
174+ `, userDID, articleID)
175+ return err
176+}
177+
178+func (db *DB) MarkArticleUnread(ctx context.Context, userDID string, articleID int64) error {
179+ _, err := db.ExecContext(ctx, `
180+ INSERT INTO read_state (user_did, article_id, is_read)
181+ VALUES (?, ?, 0)
182+ ON CONFLICT(user_did, article_id) DO UPDATE SET
183+ is_read = 0, read_at = NULL
184+ `, userDID, articleID)
185+ return err
186+}
187+
188+func (db *DB) MarkAllRead(ctx context.Context, userDID, feedURL string) error {
189+ _, err := db.ExecContext(ctx, `
190+ INSERT INTO read_state (user_did, article_id, is_read, read_at)
191+ SELECT ?, a.id, 1, CURRENT_TIMESTAMP
192+ FROM articles a
193+ JOIN subscriptions s ON a.feed_url = s.feed_url AND s.user_did = ?
194+ WHERE a.feed_url = ?
195+ ON CONFLICT(user_did, article_id) DO UPDATE SET
196+ is_read = 1, read_at = CURRENT_TIMESTAMP
197+ `, userDID, userDID, feedURL)
198+ return err
199+}
200+
201+func (db *DB) MarkAllSubscribedRead(ctx context.Context, userDID string) error {
202+ _, err := db.ExecContext(ctx, `
203+ INSERT INTO read_state (user_did, article_id, is_read, read_at)
204+ SELECT ?, a.id, 1, CURRENT_TIMESTAMP
205+ FROM articles a
206+ JOIN subscriptions s ON a.feed_url = s.feed_url AND s.user_did = ?
207+ ON CONFLICT(user_did, article_id) DO UPDATE SET
208+ is_read = 1, read_at = CURRENT_TIMESTAMP
209+ `, userDID, userDID)
210+ return err
211+}
212+
213+func (db *DB) StarArticle(ctx context.Context, userDID string, articleID int64) error {
214+ _, err := db.ExecContext(ctx, `
215+ INSERT INTO read_state (user_did, article_id, is_starred, starred_at)
216+ VALUES (?, ?, 1, CURRENT_TIMESTAMP)
217+ ON CONFLICT(user_did, article_id) DO UPDATE SET
218+ is_starred = 1, starred_at = CURRENT_TIMESTAMP
219+ `, userDID, articleID)
220+ return err
221+}
222+
223+func (db *DB) UnstarArticle(ctx context.Context, userDID string, articleID int64) error {
224+ _, err := db.ExecContext(ctx, `
225+ UPDATE read_state SET is_starred = 0, starred_at = NULL
226+ WHERE user_did = ? AND article_id = ?
227+ `, userDID, articleID)
228+ return err
229+}
230+
231+func (db *DB) GetReadState(ctx context.Context, userDID string, articleID int64) (*ReadState, error) {
232+ rs := &ReadState{}
233+ err := db.QueryRowContext(ctx, `
234+ SELECT user_did, article_id, is_read, read_at, is_starred, starred_at
235+ FROM read_state WHERE user_did = ? AND article_id = ?
236+ `, userDID, articleID).Scan(&rs.UserDID, &rs.ArticleID, &rs.IsRead, &rs.ReadAt, &rs.IsStarred, &rs.StarredAt)
237+ if err == sql.ErrNoRows {
238+ return &ReadState{UserDID: userDID, ArticleID: articleID}, nil
239+ }
240+ if err != nil {
241+ return nil, err
242+ }
243+ return rs, nil
244+}
245+
246+func (db *DB) GetUnreadCount(ctx context.Context, userDID, feedURL string) (int, error) {
247+ var count int
248+ if feedURL != "" {
249+ err := db.QueryRowContext(ctx, `
250+ SELECT COUNT(*)
251+ FROM articles a
252+ JOIN subscriptions s ON a.feed_url = s.feed_url AND s.user_did = ?
253+ LEFT JOIN read_state r ON r.user_did = ? AND r.article_id = a.id
254+ WHERE a.feed_url = ? AND (r.is_read = 0 OR r.is_read IS NULL)
255+ `, userDID, userDID, feedURL).Scan(&count)
256+ return count, err
257+ }
258+ err := db.QueryRowContext(ctx, `
259+ SELECT COUNT(*)
260+ FROM articles a
261+ JOIN subscriptions s ON a.feed_url = s.feed_url AND s.user_did = ?
262+ LEFT JOIN read_state r ON r.user_did = ? AND r.article_id = a.id
263+ WHERE r.is_read = 0 OR r.is_read IS NULL
264+ `, userDID, userDID).Scan(&count)
265+ return count, err
266+}
new file mode 100644
@@ -0,0 +1,266 @@
1+package db
2+
3+import (
4+ "context"
5+ "database/sql"
6+ "fmt"
7+)
8+
9+type Article struct {
10+ ID int64
11+ FeedURL string
12+ FeedTitle string
13+ GUID string
14+ Title string
15+ URL sql.NullString
16+ Author sql.NullString
17+ Summary sql.NullString
18+ Content sql.NullString
19+ Published sql.NullTime
20+ Updated sql.NullTime
21+ FetchedAt sql.NullTime
22+ IsRead sql.NullBool
23+ IsStarred sql.NullBool
24+}
25+
26+type ReadState struct {
27+ UserDID string
28+ ArticleID int64
29+ IsRead bool
30+ ReadAt sql.NullTime
31+ IsStarred bool
32+ StarredAt sql.NullTime
33+}
34+
35+func (db *DB) UpsertArticle(ctx context.Context, article *Article) (int64, error) {
36+ var id int64
37+ err := db.QueryRowContext(ctx, `
38+ INSERT INTO articles (feed_url, guid, title, url, author, summary, content, published, updated)
39+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
40+ ON CONFLICT(feed_url, guid) DO NOTHING
41+ RETURNING id
42+ `, article.FeedURL, article.GUID, article.Title, article.URL, article.Author,
43+ article.Summary, article.Content, article.Published, article.Updated).Scan(&id)
44+ if err == sql.ErrNoRows {
45+ err = db.QueryRowContext(ctx, `
46+ SELECT id FROM articles WHERE feed_url = ? AND guid = ?
47+ `, article.FeedURL, article.GUID).Scan(&id)
48+ }
49+ return id, err
50+}
51+
52+func (db *DB) GetArticle(ctx context.Context, id int64) (*Article, error) {
53+ a := &Article{}
54+ err := db.QueryRowContext(ctx, `
55+ SELECT id, feed_url, guid, title, url, author, summary, content, published, updated, fetched_at
56+ FROM articles WHERE id = ?
57+ `, id).Scan(&a.ID, &a.FeedURL, &a.GUID, &a.Title, &a.URL, &a.Author,
58+ &a.Summary, &a.Content, &a.Published, &a.Updated, &a.FetchedAt)
59+ if err != nil {
60+ return nil, err
61+ }
62+ return a, nil
63+}
64+
65+func (db *DB) ListArticles(ctx context.Context, userDID, feedURL string, limit, offset int) ([]*Article, error) {
66+ query := `
67+ SELECT a.id, a.feed_url, COALESCE(f.title, ''), a.guid, a.title, a.url, a.author, a.summary, a.content,
68+ a.published, a.updated, a.fetched_at,
69+ COALESCE(r.is_read, 0), COALESCE(r.is_starred, 0)
70+ FROM articles a
71+ JOIN subscriptions s ON a.feed_url = s.feed_url AND s.user_did = ?
72+ LEFT JOIN feeds f ON a.feed_url = f.feed_url
73+ LEFT JOIN read_state r ON r.user_did = ? AND r.article_id = a.id
74+ `
75+ args := []any{userDID, userDID}
76+
77+ if feedURL != "" {
78+ query += ` AND s.feed_url = ?`
79+ args = append(args, feedURL)
80+ }
81+
82+ query += fmt.Sprintf(` ORDER BY a.published DESC LIMIT %d OFFSET %d`, limit, offset)
83+
84+ rows, err := db.QueryContext(ctx, query, args...)
85+ if err != nil {
86+ return nil, err
87+ }
88+ defer rows.Close()
89+
90+ var articles []*Article
91+ for rows.Next() {
92+ a := &Article{}
93+ if err := rows.Scan(&a.ID, &a.FeedURL, &a.FeedTitle, &a.GUID, &a.Title, &a.URL, &a.Author,
94+ &a.Summary, &a.Content, &a.Published, &a.Updated, &a.FetchedAt,
95+ &a.IsRead, &a.IsStarred); err != nil {
96+ return nil, err
97+ }
98+ articles = append(articles, a)
99+ }
100+ return articles, rows.Err()
101+}
102+
103+func (db *DB) ListUnreadArticles(ctx context.Context, userDID, feedURL string, limit, offset int) ([]*Article, error) {
104+ query := `
105+ SELECT a.id, a.feed_url, COALESCE(f.title, ''), a.guid, a.title, a.url, a.author, a.summary, a.content,
106+ a.published, a.updated, a.fetched_at
107+ FROM articles a
108+ JOIN subscriptions s ON a.feed_url = s.feed_url AND s.user_did = ?
109+ LEFT JOIN feeds f ON a.feed_url = f.feed_url
110+ LEFT JOIN read_state r ON r.user_did = ? AND r.article_id = a.id
111+ WHERE r.is_read = 0 OR r.is_read IS NULL
112+ `
113+ args := []any{userDID, userDID}
114+
115+ if feedURL != "" {
116+ query += ` AND a.feed_url = ?`
117+ args = append(args, feedURL)
118+ }
119+
120+ query += fmt.Sprintf(` ORDER BY a.published DESC LIMIT %d OFFSET %d`, limit, offset)
121+
122+ rows, err := db.QueryContext(ctx, query, args...)
123+ if err != nil {
124+ return nil, err
125+ }
126+ defer rows.Close()
127+
128+ var articles []*Article
129+ for rows.Next() {
130+ a := &Article{}
131+ if err := rows.Scan(&a.ID, &a.FeedURL, &a.FeedTitle, &a.GUID, &a.Title, &a.URL, &a.Author,
132+ &a.Summary, &a.Content, &a.Published, &a.Updated, &a.FetchedAt); err != nil {
133+ return nil, err
134+ }
135+ articles = append(articles, a)
136+ }
137+ return articles, rows.Err()
138+}
139+
140+func (db *DB) ListStarredArticles(ctx context.Context, userDID string, limit, offset int) ([]*Article, error) {
141+ rows, err := db.QueryContext(ctx, fmt.Sprintf(`
142+ SELECT a.id, a.feed_url, COALESCE(f.title, ''), a.guid, a.title, a.url, a.author, a.summary, a.content,
143+ a.published, a.updated, a.fetched_at
144+ FROM articles a
145+ JOIN read_state r ON r.article_id = a.id AND r.user_did = ?
146+ LEFT JOIN feeds f ON a.feed_url = f.feed_url
147+ WHERE r.is_starred = 1
148+ ORDER BY r.starred_at DESC
149+ LIMIT %d OFFSET %d
150+ `, limit, offset), userDID)
151+ if err != nil {
152+ return nil, err
153+ }
154+ defer rows.Close()
155+
156+ var articles []*Article
157+ for rows.Next() {
158+ a := &Article{}
159+ if err := rows.Scan(&a.ID, &a.FeedURL, &a.FeedTitle, &a.GUID, &a.Title, &a.URL, &a.Author,
160+ &a.Summary, &a.Content, &a.Published, &a.Updated, &a.FetchedAt); err != nil {
161+ return nil, err
162+ }
163+ articles = append(articles, a)
164+ }
165+ return articles, rows.Err()
166+}
167+
168+func (db *DB) MarkArticleRead(ctx context.Context, userDID string, articleID int64) error {
169+ _, err := db.ExecContext(ctx, `
170+ INSERT INTO read_state (user_did, article_id, is_read, read_at)
171+ VALUES (?, ?, 1, CURRENT_TIMESTAMP)
172+ ON CONFLICT(user_did, article_id) DO UPDATE SET
173+ is_read = 1, read_at = CURRENT_TIMESTAMP
174+ `, userDID, articleID)
175+ return err
176+}
177+
178+func (db *DB) MarkArticleUnread(ctx context.Context, userDID string, articleID int64) error {
179+ _, err := db.ExecContext(ctx, `
180+ INSERT INTO read_state (user_did, article_id, is_read)
181+ VALUES (?, ?, 0)
182+ ON CONFLICT(user_did, article_id) DO UPDATE SET
183+ is_read = 0, read_at = NULL
184+ `, userDID, articleID)
185+ return err
186+}
187+
188+func (db *DB) MarkAllRead(ctx context.Context, userDID, feedURL string) error {
189+ _, err := db.ExecContext(ctx, `
190+ INSERT INTO read_state (user_did, article_id, is_read, read_at)
191+ SELECT ?, a.id, 1, CURRENT_TIMESTAMP
192+ FROM articles a
193+ JOIN subscriptions s ON a.feed_url = s.feed_url AND s.user_did = ?
194+ WHERE a.feed_url = ?
195+ ON CONFLICT(user_did, article_id) DO UPDATE SET
196+ is_read = 1, read_at = CURRENT_TIMESTAMP
197+ `, userDID, userDID, feedURL)
198+ return err
199+}
200+
201+func (db *DB) MarkAllSubscribedRead(ctx context.Context, userDID string) error {
202+ _, err := db.ExecContext(ctx, `
203+ INSERT INTO read_state (user_did, article_id, is_read, read_at)
204+ SELECT ?, a.id, 1, CURRENT_TIMESTAMP
205+ FROM articles a
206+ JOIN subscriptions s ON a.feed_url = s.feed_url AND s.user_did = ?
207+ ON CONFLICT(user_did, article_id) DO UPDATE SET
208+ is_read = 1, read_at = CURRENT_TIMESTAMP
209+ `, userDID, userDID)
210+ return err
211+}
212+
213+func (db *DB) StarArticle(ctx context.Context, userDID string, articleID int64) error {
214+ _, err := db.ExecContext(ctx, `
215+ INSERT INTO read_state (user_did, article_id, is_starred, starred_at)
216+ VALUES (?, ?, 1, CURRENT_TIMESTAMP)
217+ ON CONFLICT(user_did, article_id) DO UPDATE SET
218+ is_starred = 1, starred_at = CURRENT_TIMESTAMP
219+ `, userDID, articleID)
220+ return err
221+}
222+
223+func (db *DB) UnstarArticle(ctx context.Context, userDID string, articleID int64) error {
224+ _, err := db.ExecContext(ctx, `
225+ UPDATE read_state SET is_starred = 0, starred_at = NULL
226+ WHERE user_did = ? AND article_id = ?
227+ `, userDID, articleID)
228+ return err
229+}
230+
231+func (db *DB) GetReadState(ctx context.Context, userDID string, articleID int64) (*ReadState, error) {
232+ rs := &ReadState{}
233+ err := db.QueryRowContext(ctx, `
234+ SELECT user_did, article_id, is_read, read_at, is_starred, starred_at
235+ FROM read_state WHERE user_did = ? AND article_id = ?
236+ `, userDID, articleID).Scan(&rs.UserDID, &rs.ArticleID, &rs.IsRead, &rs.ReadAt, &rs.IsStarred, &rs.StarredAt)
237+ if err == sql.ErrNoRows {
238+ return &ReadState{UserDID: userDID, ArticleID: articleID}, nil
239+ }
240+ if err != nil {
241+ return nil, err
242+ }
243+ return rs, nil
244+}
245+
246+func (db *DB) GetUnreadCount(ctx context.Context, userDID, feedURL string) (int, error) {
247+ var count int
248+ if feedURL != "" {
249+ err := db.QueryRowContext(ctx, `
250+ SELECT COUNT(*)
251+ FROM articles a
252+ JOIN subscriptions s ON a.feed_url = s.feed_url AND s.user_did = ?
253+ LEFT JOIN read_state r ON r.user_did = ? AND r.article_id = a.id
254+ WHERE a.feed_url = ? AND (r.is_read = 0 OR r.is_read IS NULL)
255+ `, userDID, userDID, feedURL).Scan(&count)
256+ return count, err
257+ }
258+ err := db.QueryRowContext(ctx, `
259+ SELECT COUNT(*)
260+ FROM articles a
261+ JOIN subscriptions s ON a.feed_url = s.feed_url AND s.user_did = ?
262+ LEFT JOIN read_state r ON r.user_did = ? AND r.article_id = a.id
263+ WHERE r.is_read = 0 OR r.is_read IS NULL
264+ `, userDID, userDID).Scan(&count)
265+ return count, err
266+}
added internal/db/cluster.go +306 -0
new file mode 100644
@@ -0,0 +1,306 @@
1+package db
2+
3+import (
4+ "context"
5+ "database/sql"
6+ "fmt"
7+)
8+
9+func (db *DB) ComputeFeedSimilarity(ctx context.Context) error {
10+ _, err := db.ExecContext(ctx, `DELETE FROM feed_similarity`)
11+ if err != nil {
12+ return err
13+ }
14+
15+ rows, err := db.QueryContext(ctx, `
16+ SELECT s1.feed_url, s2.feed_url, COUNT(*) AS overlap
17+ FROM subscriptions s1
18+ JOIN subscriptions s2 ON s1.user_did = s2.user_did AND s1.feed_url < s2.feed_url
19+ GROUP BY s1.feed_url, s2.feed_url
20+ HAVING overlap > 0
21+ `)
22+ if err != nil {
23+ return err
24+ }
25+ defer rows.Close()
26+
27+ type pair struct {
28+ feedA string
29+ feedB string
30+ overlap int
31+ }
32+ var pairs []pair
33+ for rows.Next() {
34+ var p pair
35+ if err := rows.Scan(&p.feedA, &p.feedB, &p.overlap); err != nil {
36+ return err
37+ }
38+ pairs = append(pairs, p)
39+ }
40+ if err := rows.Err(); err != nil {
41+ return err
42+ }
43+
44+ subCounts := make(map[string]int)
45+ for _, p := range pairs {
46+ subCounts[p.feedA] = 0
47+ subCounts[p.feedB] = 0
48+ }
49+
50+ if len(subCounts) > 0 {
51+ countRows, err := db.QueryContext(ctx, `
52+ SELECT feed_url, COUNT(*) FROM subscriptions GROUP BY feed_url
53+ `)
54+ if err != nil {
55+ return err
56+ }
57+ for countRows.Next() {
58+ var feedURL string
59+ var count int
60+ if err := countRows.Scan(&feedURL, &count); err != nil {
61+ countRows.Close()
62+ return err
63+ }
64+ subCounts[feedURL] = count
65+ }
66+ countRows.Close()
67+ }
68+
69+ for _, p := range pairs {
70+ total := subCounts[p.feedA] + subCounts[p.feedB] - p.overlap
71+ if total == 0 {
72+ continue
73+ }
74+ jaccard := float64(p.overlap) / float64(total)
75+ _, err := db.ExecContext(ctx, `
76+ INSERT INTO feed_similarity (feed_a, feed_b, jaccard, computed_at)
77+ VALUES (?, ?, ?, CURRENT_TIMESTAMP)
78+ `, p.feedA, p.feedB, jaccard)
79+ if err != nil {
80+ return err
81+ }
82+ }
83+
84+ return nil
85+}
86+
87+func (db *DB) ComputeUserSimilarity(ctx context.Context) error {
88+ _, err := db.ExecContext(ctx, `DELETE FROM user_similarity`)
89+ if err != nil {
90+ return err
91+ }
92+
93+ rows, err := db.QueryContext(ctx, `
94+ SELECT s1.user_did, s2.user_did, COUNT(*) AS common
95+ FROM subscriptions s1
96+ JOIN subscriptions s2 ON s1.user_did < s2.user_did AND s1.feed_url = s2.feed_url
97+ GROUP BY s1.user_did, s2.user_did
98+ HAVING common > 0
99+ `)
100+ if err != nil {
101+ return err
102+ }
103+ defer rows.Close()
104+
105+ type pair struct {
106+ userA string
107+ userB string
108+ common int
109+ }
110+ var pairs []pair
111+ for rows.Next() {
112+ var p pair
113+ if err := rows.Scan(&p.userA, &p.userB, &p.common); err != nil {
114+ return err
115+ }
116+ pairs = append(pairs, p)
117+ }
118+ if err := rows.Err(); err != nil {
119+ return err
120+ }
121+
122+ subCounts := make(map[string]int)
123+ for _, p := range pairs {
124+ subCounts[p.userA] = 0
125+ subCounts[p.userB] = 0
126+ }
127+
128+ if len(subCounts) > 0 {
129+ countRows, err := db.QueryContext(ctx, `
130+ SELECT user_did, COUNT(*) FROM subscriptions GROUP BY user_did
131+ `)
132+ if err != nil {
133+ return err
134+ }
135+ for countRows.Next() {
136+ var userDID string
137+ var count int
138+ if err := countRows.Scan(&userDID, &count); err != nil {
139+ countRows.Close()
140+ return err
141+ }
142+ subCounts[userDID] = count
143+ }
144+ countRows.Close()
145+ }
146+
147+ for _, p := range pairs {
148+ total := subCounts[p.userA] + subCounts[p.userB] - p.common
149+ if total == 0 {
150+ continue
151+ }
152+ jaccard := float64(p.common) / float64(total)
153+ _, err := db.ExecContext(ctx, `
154+ INSERT INTO user_similarity (user_a, user_b, jaccard, common_feeds, computed_at)
155+ VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP)
156+ `, p.userA, p.userB, jaccard, p.common)
157+ if err != nil {
158+ return err
159+ }
160+ }
161+
162+ return nil
163+}
164+
165+func (db *DB) ComputeFeedRecommendations(ctx context.Context, userDID string) error {
166+ _, err := db.ExecContext(ctx, `
167+ DELETE FROM user_feed_recommendations WHERE user_did = ?
168+ `, userDID)
169+ if err != nil {
170+ return err
171+ }
172+
173+ rows, err := db.QueryContext(ctx, `
174+ SELECT
175+ CASE WHEN fs.feed_a IN (SELECT feed_url FROM subscriptions WHERE user_did = ?) THEN fs.feed_b ELSE fs.feed_a END AS recommended_feed,
176+ SUM(fs.jaccard) AS score
177+ FROM feed_similarity fs
178+ WHERE fs.feed_a IN (SELECT feed_url FROM subscriptions WHERE user_did = ?)
179+ OR fs.feed_b IN (SELECT feed_url FROM subscriptions WHERE user_did = ?)
180+ GROUP BY recommended_feed
181+ HAVING recommended_feed NOT IN (SELECT feed_url FROM subscriptions WHERE user_did = ?)
182+ ORDER BY score DESC
183+ `, userDID, userDID, userDID, userDID)
184+ if err != nil {
185+ return err
186+ }
187+ defer rows.Close()
188+
189+ for rows.Next() {
190+ var feedURL string
191+ var score float64
192+ if err := rows.Scan(&feedURL, &score); err != nil {
193+ return err
194+ }
195+ _, err := db.ExecContext(ctx, `
196+ INSERT INTO user_feed_recommendations (user_did, feed_url, score, computed_at)
197+ VALUES (?, ?, ?, CURRENT_TIMESTAMP)
198+ `, userDID, feedURL, score)
199+ if err != nil {
200+ return err
201+ }
202+ }
203+ return rows.Err()
204+}
205+
206+func (db *DB) GetFeedRecommendations(ctx context.Context, userDID string, limit int) ([]map[string]any, error) {
207+ rows, err := db.QueryContext(ctx, `
208+ SELECT r.feed_url, r.score, f.title, f.site_url, f.description, f.subscriber_count
209+ FROM user_feed_recommendations r
210+ JOIN feeds f ON f.feed_url = r.feed_url
211+ WHERE r.user_did = ?
212+ ORDER BY r.score DESC
213+ LIMIT ?
214+ `, userDID, limit)
215+ if err != nil {
216+ return nil, err
217+ }
218+ defer rows.Close()
219+
220+ var results []map[string]any
221+ for rows.Next() {
222+ var feedURL string
223+ var score float64
224+ var title, siteURL, description sql.NullString
225+ var subCount int
226+ if err := rows.Scan(&feedURL, &score, &title, &siteURL, &description, &subCount); err != nil {
227+ return nil, err
228+ }
229+ results = append(results, map[string]any{
230+ "feed_url": feedURL,
231+ "score": score,
232+ "title": title,
233+ "site_url": siteURL,
234+ "description": description,
235+ "subscriber_count": subCount,
236+ })
237+ }
238+ return results, rows.Err()
239+}
240+
241+func (db *DB) GetPeopleRecommendations(ctx context.Context, userDID string, limit int) ([]map[string]any, error) {
242+ rows, err := db.QueryContext(ctx, fmt.Sprintf(`
243+ SELECT
244+ CASE WHEN us.user_a = ? THEN us.user_b ELSE us.user_a END AS recommended_user,
245+ us.jaccard, us.common_feeds,
246+ u.handle, u.display_name, u.avatar_url
247+ FROM user_similarity us
248+ JOIN users u ON u.did = CASE WHEN us.user_a = ? THEN us.user_b ELSE us.user_a END
249+ WHERE us.user_a = ? OR us.user_b = ?
250+ ORDER BY us.jaccard DESC
251+ LIMIT %d
252+ `, limit), userDID, userDID, userDID, userDID)
253+ if err != nil {
254+ return nil, err
255+ }
256+ defer rows.Close()
257+
258+ var results []map[string]any
259+ for rows.Next() {
260+ var recUser, handle string
261+ var jaccard float64
262+ var commonFeeds int
263+ var displayName, avatarURL sql.NullString
264+ if err := rows.Scan(&recUser, &jaccard, &commonFeeds, &handle, &displayName, &avatarURL); err != nil {
265+ return nil, err
266+ }
267+ results = append(results, map[string]any{
268+ "did": recUser,
269+ "jaccard": jaccard,
270+ "common_feeds": commonFeeds,
271+ "handle": handle,
272+ "display_name": displayName,
273+ "avatar_url": avatarURL,
274+ })
275+ }
276+ return results, rows.Err()
277+}
278+
279+func (db *DB) GetSimilarFeeds(ctx context.Context, feedURL string, limit int) ([]*Feed, error) {
280+ rows, err := db.QueryContext(ctx, `
281+ SELECT f.feed_url, f.title, f.site_url, f.description, f.feed_type,
282+ f.last_fetched_at, f.last_error, f.subscriber_count, f.etag, f.last_modified,
283+ f.fetch_interval_minutes, f.next_fetch_at, f.consecutive_empty_fetches, f.error_count
284+ FROM feed_similarity fs
285+ JOIN feeds f ON f.feed_url = CASE WHEN fs.feed_a = ? THEN fs.feed_b ELSE fs.feed_a END
286+ WHERE fs.feed_a = ? OR fs.feed_b = ?
287+ ORDER BY fs.jaccard DESC
288+ LIMIT ?
289+ `, feedURL, feedURL, feedURL, limit)
290+ if err != nil {
291+ return nil, err
292+ }
293+ defer rows.Close()
294+
295+ var feeds []*Feed
296+ for rows.Next() {
297+ f := &Feed{}
298+ if err := rows.Scan(&f.FeedURL, &f.Title, &f.SiteURL, &f.Description, &f.FeedType,
299+ &f.LastFetchedAt, &f.LastError, &f.SubscriberCount, &f.Etag, &f.LastModified,
300+ &f.FetchIntervalMinutes, &f.NextFetchAt, &f.ConsecutiveEmptyFetches, &f.ErrorCount); err != nil {
301+ return nil, err
302+ }
303+ feeds = append(feeds, f)
304+ }
305+ return feeds, rows.Err()
306+}
new file mode 100644
@@ -0,0 +1,306 @@
1+package db
2+
3+import (
4+ "context"
5+ "database/sql"
6+ "fmt"
7+)
8+
9+func (db *DB) ComputeFeedSimilarity(ctx context.Context) error {
10+ _, err := db.ExecContext(ctx, `DELETE FROM feed_similarity`)
11+ if err != nil {
12+ return err
13+ }
14+
15+ rows, err := db.QueryContext(ctx, `
16+ SELECT s1.feed_url, s2.feed_url, COUNT(*) AS overlap
17+ FROM subscriptions s1
18+ JOIN subscriptions s2 ON s1.user_did = s2.user_did AND s1.feed_url < s2.feed_url
19+ GROUP BY s1.feed_url, s2.feed_url
20+ HAVING overlap > 0
21+ `)
22+ if err != nil {
23+ return err
24+ }
25+ defer rows.Close()
26+
27+ type pair struct {
28+ feedA string
29+ feedB string
30+ overlap int
31+ }
32+ var pairs []pair
33+ for rows.Next() {
34+ var p pair
35+ if err := rows.Scan(&p.feedA, &p.feedB, &p.overlap); err != nil {
36+ return err
37+ }
38+ pairs = append(pairs, p)
39+ }
40+ if err := rows.Err(); err != nil {
41+ return err
42+ }
43+
44+ subCounts := make(map[string]int)
45+ for _, p := range pairs {
46+ subCounts[p.feedA] = 0
47+ subCounts[p.feedB] = 0
48+ }
49+
50+ if len(subCounts) > 0 {
51+ countRows, err := db.QueryContext(ctx, `
52+ SELECT feed_url, COUNT(*) FROM subscriptions GROUP BY feed_url
53+ `)
54+ if err != nil {
55+ return err
56+ }
57+ for countRows.Next() {
58+ var feedURL string
59+ var count int
60+ if err := countRows.Scan(&feedURL, &count); err != nil {
61+ countRows.Close()
62+ return err
63+ }
64+ subCounts[feedURL] = count
65+ }
66+ countRows.Close()
67+ }
68+
69+ for _, p := range pairs {
70+ total := subCounts[p.feedA] + subCounts[p.feedB] - p.overlap
71+ if total == 0 {
72+ continue
73+ }
74+ jaccard := float64(p.overlap) / float64(total)
75+ _, err := db.ExecContext(ctx, `
76+ INSERT INTO feed_similarity (feed_a, feed_b, jaccard, computed_at)
77+ VALUES (?, ?, ?, CURRENT_TIMESTAMP)
78+ `, p.feedA, p.feedB, jaccard)
79+ if err != nil {
80+ return err
81+ }
82+ }
83+
84+ return nil
85+}
86+
87+func (db *DB) ComputeUserSimilarity(ctx context.Context) error {
88+ _, err := db.ExecContext(ctx, `DELETE FROM user_similarity`)
89+ if err != nil {
90+ return err
91+ }
92+
93+ rows, err := db.QueryContext(ctx, `
94+ SELECT s1.user_did, s2.user_did, COUNT(*) AS common
95+ FROM subscriptions s1
96+ JOIN subscriptions s2 ON s1.user_did < s2.user_did AND s1.feed_url = s2.feed_url
97+ GROUP BY s1.user_did, s2.user_did
98+ HAVING common > 0
99+ `)
100+ if err != nil {
101+ return err
102+ }
103+ defer rows.Close()
104+
105+ type pair struct {
106+ userA string
107+ userB string
108+ common int
109+ }
110+ var pairs []pair
111+ for rows.Next() {
112+ var p pair
113+ if err := rows.Scan(&p.userA, &p.userB, &p.common); err != nil {
114+ return err
115+ }
116+ pairs = append(pairs, p)
117+ }
118+ if err := rows.Err(); err != nil {
119+ return err
120+ }
121+
122+ subCounts := make(map[string]int)
123+ for _, p := range pairs {
124+ subCounts[p.userA] = 0
125+ subCounts[p.userB] = 0
126+ }
127+
128+ if len(subCounts) > 0 {
129+ countRows, err := db.QueryContext(ctx, `
130+ SELECT user_did, COUNT(*) FROM subscriptions GROUP BY user_did
131+ `)
132+ if err != nil {
133+ return err
134+ }
135+ for countRows.Next() {
136+ var userDID string
137+ var count int
138+ if err := countRows.Scan(&userDID, &count); err != nil {
139+ countRows.Close()
140+ return err
141+ }
142+ subCounts[userDID] = count
143+ }
144+ countRows.Close()
145+ }
146+
147+ for _, p := range pairs {
148+ total := subCounts[p.userA] + subCounts[p.userB] - p.common
149+ if total == 0 {
150+ continue
151+ }
152+ jaccard := float64(p.common) / float64(total)
153+ _, err := db.ExecContext(ctx, `
154+ INSERT INTO user_similarity (user_a, user_b, jaccard, common_feeds, computed_at)
155+ VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP)
156+ `, p.userA, p.userB, jaccard, p.common)
157+ if err != nil {
158+ return err
159+ }
160+ }
161+
162+ return nil
163+}
164+
165+func (db *DB) ComputeFeedRecommendations(ctx context.Context, userDID string) error {
166+ _, err := db.ExecContext(ctx, `
167+ DELETE FROM user_feed_recommendations WHERE user_did = ?
168+ `, userDID)
169+ if err != nil {
170+ return err
171+ }
172+
173+ rows, err := db.QueryContext(ctx, `
174+ SELECT
175+ CASE WHEN fs.feed_a IN (SELECT feed_url FROM subscriptions WHERE user_did = ?) THEN fs.feed_b ELSE fs.feed_a END AS recommended_feed,
176+ SUM(fs.jaccard) AS score
177+ FROM feed_similarity fs
178+ WHERE fs.feed_a IN (SELECT feed_url FROM subscriptions WHERE user_did = ?)
179+ OR fs.feed_b IN (SELECT feed_url FROM subscriptions WHERE user_did = ?)
180+ GROUP BY recommended_feed
181+ HAVING recommended_feed NOT IN (SELECT feed_url FROM subscriptions WHERE user_did = ?)
182+ ORDER BY score DESC
183+ `, userDID, userDID, userDID, userDID)
184+ if err != nil {
185+ return err
186+ }
187+ defer rows.Close()
188+
189+ for rows.Next() {
190+ var feedURL string
191+ var score float64
192+ if err := rows.Scan(&feedURL, &score); err != nil {
193+ return err
194+ }
195+ _, err := db.ExecContext(ctx, `
196+ INSERT INTO user_feed_recommendations (user_did, feed_url, score, computed_at)
197+ VALUES (?, ?, ?, CURRENT_TIMESTAMP)
198+ `, userDID, feedURL, score)
199+ if err != nil {
200+ return err
201+ }
202+ }
203+ return rows.Err()
204+}
205+
206+func (db *DB) GetFeedRecommendations(ctx context.Context, userDID string, limit int) ([]map[string]any, error) {
207+ rows, err := db.QueryContext(ctx, `
208+ SELECT r.feed_url, r.score, f.title, f.site_url, f.description, f.subscriber_count
209+ FROM user_feed_recommendations r
210+ JOIN feeds f ON f.feed_url = r.feed_url
211+ WHERE r.user_did = ?
212+ ORDER BY r.score DESC
213+ LIMIT ?
214+ `, userDID, limit)
215+ if err != nil {
216+ return nil, err
217+ }
218+ defer rows.Close()
219+
220+ var results []map[string]any
221+ for rows.Next() {
222+ var feedURL string
223+ var score float64
224+ var title, siteURL, description sql.NullString
225+ var subCount int
226+ if err := rows.Scan(&feedURL, &score, &title, &siteURL, &description, &subCount); err != nil {
227+ return nil, err
228+ }
229+ results = append(results, map[string]any{
230+ "feed_url": feedURL,
231+ "score": score,
232+ "title": title,
233+ "site_url": siteURL,
234+ "description": description,
235+ "subscriber_count": subCount,
236+ })
237+ }
238+ return results, rows.Err()
239+}
240+
241+func (db *DB) GetPeopleRecommendations(ctx context.Context, userDID string, limit int) ([]map[string]any, error) {
242+ rows, err := db.QueryContext(ctx, fmt.Sprintf(`
243+ SELECT
244+ CASE WHEN us.user_a = ? THEN us.user_b ELSE us.user_a END AS recommended_user,
245+ us.jaccard, us.common_feeds,
246+ u.handle, u.display_name, u.avatar_url
247+ FROM user_similarity us
248+ JOIN users u ON u.did = CASE WHEN us.user_a = ? THEN us.user_b ELSE us.user_a END
249+ WHERE us.user_a = ? OR us.user_b = ?
250+ ORDER BY us.jaccard DESC
251+ LIMIT %d
252+ `, limit), userDID, userDID, userDID, userDID)
253+ if err != nil {
254+ return nil, err
255+ }
256+ defer rows.Close()
257+
258+ var results []map[string]any
259+ for rows.Next() {
260+ var recUser, handle string
261+ var jaccard float64
262+ var commonFeeds int
263+ var displayName, avatarURL sql.NullString
264+ if err := rows.Scan(&recUser, &jaccard, &commonFeeds, &handle, &displayName, &avatarURL); err != nil {
265+ return nil, err
266+ }
267+ results = append(results, map[string]any{
268+ "did": recUser,
269+ "jaccard": jaccard,
270+ "common_feeds": commonFeeds,
271+ "handle": handle,
272+ "display_name": displayName,
273+ "avatar_url": avatarURL,
274+ })
275+ }
276+ return results, rows.Err()
277+}
278+
279+func (db *DB) GetSimilarFeeds(ctx context.Context, feedURL string, limit int) ([]*Feed, error) {
280+ rows, err := db.QueryContext(ctx, `
281+ SELECT f.feed_url, f.title, f.site_url, f.description, f.feed_type,
282+ f.last_fetched_at, f.last_error, f.subscriber_count, f.etag, f.last_modified,
283+ f.fetch_interval_minutes, f.next_fetch_at, f.consecutive_empty_fetches, f.error_count
284+ FROM feed_similarity fs
285+ JOIN feeds f ON f.feed_url = CASE WHEN fs.feed_a = ? THEN fs.feed_b ELSE fs.feed_a END
286+ WHERE fs.feed_a = ? OR fs.feed_b = ?
287+ ORDER BY fs.jaccard DESC
288+ LIMIT ?
289+ `, feedURL, feedURL, feedURL, limit)
290+ if err != nil {
291+ return nil, err
292+ }
293+ defer rows.Close()
294+
295+ var feeds []*Feed
296+ for rows.Next() {
297+ f := &Feed{}
298+ if err := rows.Scan(&f.FeedURL, &f.Title, &f.SiteURL, &f.Description, &f.FeedType,
299+ &f.LastFetchedAt, &f.LastError, &f.SubscriberCount, &f.Etag, &f.LastModified,
300+ &f.FetchIntervalMinutes, &f.NextFetchAt, &f.ConsecutiveEmptyFetches, &f.ErrorCount); err != nil {
301+ return nil, err
302+ }
303+ feeds = append(feeds, f)
304+ }
305+ return feeds, rows.Err()
306+}
added internal/db/db.go +164 -0
new file mode 100644
@@ -0,0 +1,164 @@
1+package db
2+
3+import (
4+ "database/sql"
5+ _ "modernc.org/sqlite"
6+)
7+
8+type DB struct {
9+ *sql.DB
10+}
11+
12+func Open(path string) (*DB, error) {
13+ db, err := sql.Open("sqlite", path)
14+ if err != nil {
15+ return nil, err
16+ }
17+
18+ db.SetMaxOpenConns(1)
19+
20+ if err := migrate(db); err != nil {
21+ db.Close()
22+ return nil, err
23+ }
24+
25+ return &DB{db}, nil
26+}
27+
28+func migrate(db *sql.DB) error {
29+ tx, err := db.Begin()
30+ if err != nil {
31+ return err
32+ }
33+ defer func() { _ = tx.Rollback() }()
34+
35+ stmts := []string{
36+ `CREATE TABLE IF NOT EXISTS users (
37+ did TEXT PRIMARY KEY,
38+ handle TEXT NOT NULL,
39+ display_name TEXT,
40+ avatar_url TEXT,
41+ indexed_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
42+ updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
43+ )`,
44+ `CREATE TABLE IF NOT EXISTS feeds (
45+ feed_url TEXT PRIMARY KEY,
46+ title TEXT,
47+ site_url TEXT,
48+ description TEXT,
49+ feed_type TEXT CHECK(feed_type IN ('rss', 'atom', 'json')),
50+ last_fetched_at DATETIME,
51+ last_error TEXT,
52+ subscriber_count INTEGER NOT NULL DEFAULT 0,
53+ etag TEXT,
54+ last_modified TEXT,
55+ fetch_interval_minutes INTEGER NOT NULL DEFAULT 30,
56+ next_fetch_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
57+ consecutive_empty_fetches INTEGER NOT NULL DEFAULT 0,
58+ error_count INTEGER NOT NULL DEFAULT 0
59+ )`,
60+ `CREATE TABLE IF NOT EXISTS subscriptions (
61+ id INTEGER PRIMARY KEY AUTOINCREMENT,
62+ user_did TEXT NOT NULL REFERENCES users(did),
63+ feed_url TEXT NOT NULL REFERENCES feeds(feed_url),
64+ category TEXT,
65+ added_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
66+ UNIQUE(user_did, feed_url)
67+ )`,
68+ `CREATE TABLE IF NOT EXISTS articles (
69+ id INTEGER PRIMARY KEY AUTOINCREMENT,
70+ feed_url TEXT NOT NULL REFERENCES feeds(feed_url),
71+ guid TEXT NOT NULL,
72+ title TEXT NOT NULL DEFAULT '',
73+ url TEXT,
74+ author TEXT,
75+ summary TEXT,
76+ content TEXT,
77+ published DATETIME,
78+ updated DATETIME,
79+ fetched_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
80+ UNIQUE(feed_url, guid)
81+ )`,
82+ `CREATE TABLE IF NOT EXISTS read_state (
83+ user_did TEXT NOT NULL REFERENCES users(did),
84+ article_id INTEGER NOT NULL REFERENCES articles(id),
85+ is_read BOOLEAN NOT NULL DEFAULT 0,
86+ read_at DATETIME,
87+ is_starred BOOLEAN NOT NULL DEFAULT 0,
88+ starred_at DATETIME,
89+ PRIMARY KEY (user_did, article_id)
90+ )`,
91+ `CREATE TABLE IF NOT EXISTS annotations (
92+ id INTEGER PRIMARY KEY AUTOINCREMENT,
93+ uri TEXT NOT NULL UNIQUE,
94+ author_did TEXT NOT NULL REFERENCES users(did),
95+ feed_url TEXT NOT NULL,
96+ article_url TEXT NOT NULL,
97+ quote TEXT,
98+ note TEXT,
99+ tags TEXT,
100+ rating INTEGER,
101+ created_at DATETIME NOT NULL,
102+ cid TEXT
103+ )`,
104+ `CREATE TABLE IF NOT EXISTS likes (
105+ id INTEGER PRIMARY KEY AUTOINCREMENT,
106+ uri TEXT NOT NULL UNIQUE,
107+ author_did TEXT NOT NULL REFERENCES users(did),
108+ feed_url TEXT NOT NULL,
109+ article_url TEXT NOT NULL,
110+ created_at DATETIME NOT NULL,
111+ cid TEXT,
112+ UNIQUE(author_did, feed_url, article_url)
113+ )`,
114+ `CREATE TABLE IF NOT EXISTS feed_similarity (
115+ feed_a TEXT NOT NULL REFERENCES feeds(feed_url),
116+ feed_b TEXT NOT NULL REFERENCES feeds(feed_url),
117+ jaccard REAL NOT NULL,
118+ computed_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
119+ PRIMARY KEY (feed_a, feed_b),
120+ CHECK(feed_a < feed_b)
121+ )`,
122+ `CREATE TABLE IF NOT EXISTS user_similarity (
123+ user_a TEXT NOT NULL REFERENCES users(did),
124+ user_b TEXT NOT NULL REFERENCES users(did),
125+ jaccard REAL NOT NULL,
126+ common_feeds INTEGER NOT NULL,
127+ computed_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
128+ PRIMARY KEY (user_a, user_b),
129+ CHECK(user_a < user_b)
130+ )`,
131+ `CREATE TABLE IF NOT EXISTS user_feed_recommendations (
132+ user_did TEXT NOT NULL REFERENCES users(did),
133+ feed_url TEXT NOT NULL REFERENCES feeds(feed_url),
134+ score REAL NOT NULL,
135+ computed_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
136+ PRIMARY KEY (user_did, feed_url)
137+ )`,
138+ `CREATE TABLE IF NOT EXISTS user_article_recommendations (
139+ user_did TEXT NOT NULL REFERENCES users(did),
140+ feed_url TEXT NOT NULL,
141+ article_url TEXT NOT NULL,
142+ score REAL NOT NULL,
143+ computed_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
144+ PRIMARY KEY (user_did, feed_url, article_url)
145+ )`,
146+ `CREATE INDEX IF NOT EXISTS idx_subscriptions_feed ON subscriptions(feed_url)`,
147+ `CREATE INDEX IF NOT EXISTS idx_subscriptions_user ON subscriptions(user_did)`,
148+ `CREATE INDEX IF NOT EXISTS idx_articles_feed ON articles(feed_url)`,
149+ `CREATE INDEX IF NOT EXISTS idx_articles_published ON articles(published DESC)`,
150+ `CREATE INDEX IF NOT EXISTS idx_read_state_unread ON read_state(user_did, is_read) WHERE is_read = 0`,
151+ `CREATE INDEX IF NOT EXISTS idx_read_state_starred ON read_state(user_did, is_starred) WHERE is_starred = 1`,
152+ `CREATE INDEX IF NOT EXISTS idx_annotations_article ON annotations(article_url)`,
153+ `CREATE INDEX IF NOT EXISTS idx_likes_article ON likes(feed_url, article_url)`,
154+ `CREATE INDEX IF NOT EXISTS idx_likes_author ON likes(author_did)`,
155+ }
156+
157+ for _, s := range stmts {
158+ if _, err := tx.Exec(s); err != nil {
159+ return err
160+ }
161+ }
162+
163+ return tx.Commit()
164+}
new file mode 100644
@@ -0,0 +1,164 @@
1+package db
2+
3+import (
4+ "database/sql"
5+ _ "modernc.org/sqlite"
6+)
7+
8+type DB struct {
9+ *sql.DB
10+}
11+
12+func Open(path string) (*DB, error) {
13+ db, err := sql.Open("sqlite", path)
14+ if err != nil {
15+ return nil, err
16+ }
17+
18+ db.SetMaxOpenConns(1)
19+
20+ if err := migrate(db); err != nil {
21+ db.Close()
22+ return nil, err
23+ }
24+
25+ return &DB{db}, nil
26+}
27+
28+func migrate(db *sql.DB) error {
29+ tx, err := db.Begin()
30+ if err != nil {
31+ return err
32+ }
33+ defer func() { _ = tx.Rollback() }()
34+
35+ stmts := []string{
36+ `CREATE TABLE IF NOT EXISTS users (
37+ did TEXT PRIMARY KEY,
38+ handle TEXT NOT NULL,
39+ display_name TEXT,
40+ avatar_url TEXT,
41+ indexed_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
42+ updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
43+ )`,
44+ `CREATE TABLE IF NOT EXISTS feeds (
45+ feed_url TEXT PRIMARY KEY,
46+ title TEXT,
47+ site_url TEXT,
48+ description TEXT,
49+ feed_type TEXT CHECK(feed_type IN ('rss', 'atom', 'json')),
50+ last_fetched_at DATETIME,
51+ last_error TEXT,
52+ subscriber_count INTEGER NOT NULL DEFAULT 0,
53+ etag TEXT,
54+ last_modified TEXT,
55+ fetch_interval_minutes INTEGER NOT NULL DEFAULT 30,
56+ next_fetch_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
57+ consecutive_empty_fetches INTEGER NOT NULL DEFAULT 0,
58+ error_count INTEGER NOT NULL DEFAULT 0
59+ )`,
60+ `CREATE TABLE IF NOT EXISTS subscriptions (
61+ id INTEGER PRIMARY KEY AUTOINCREMENT,
62+ user_did TEXT NOT NULL REFERENCES users(did),
63+ feed_url TEXT NOT NULL REFERENCES feeds(feed_url),
64+ category TEXT,
65+ added_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
66+ UNIQUE(user_did, feed_url)
67+ )`,
68+ `CREATE TABLE IF NOT EXISTS articles (
69+ id INTEGER PRIMARY KEY AUTOINCREMENT,
70+ feed_url TEXT NOT NULL REFERENCES feeds(feed_url),
71+ guid TEXT NOT NULL,
72+ title TEXT NOT NULL DEFAULT '',
73+ url TEXT,
74+ author TEXT,
75+ summary TEXT,
76+ content TEXT,
77+ published DATETIME,
78+ updated DATETIME,
79+ fetched_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
80+ UNIQUE(feed_url, guid)
81+ )`,
82+ `CREATE TABLE IF NOT EXISTS read_state (
83+ user_did TEXT NOT NULL REFERENCES users(did),
84+ article_id INTEGER NOT NULL REFERENCES articles(id),
85+ is_read BOOLEAN NOT NULL DEFAULT 0,
86+ read_at DATETIME,
87+ is_starred BOOLEAN NOT NULL DEFAULT 0,
88+ starred_at DATETIME,
89+ PRIMARY KEY (user_did, article_id)
90+ )`,
91+ `CREATE TABLE IF NOT EXISTS annotations (
92+ id INTEGER PRIMARY KEY AUTOINCREMENT,
93+ uri TEXT NOT NULL UNIQUE,
94+ author_did TEXT NOT NULL REFERENCES users(did),
95+ feed_url TEXT NOT NULL,
96+ article_url TEXT NOT NULL,
97+ quote TEXT,
98+ note TEXT,
99+ tags TEXT,
100+ rating INTEGER,
101+ created_at DATETIME NOT NULL,
102+ cid TEXT
103+ )`,
104+ `CREATE TABLE IF NOT EXISTS likes (
105+ id INTEGER PRIMARY KEY AUTOINCREMENT,
106+ uri TEXT NOT NULL UNIQUE,
107+ author_did TEXT NOT NULL REFERENCES users(did),
108+ feed_url TEXT NOT NULL,
109+ article_url TEXT NOT NULL,
110+ created_at DATETIME NOT NULL,
111+ cid TEXT,
112+ UNIQUE(author_did, feed_url, article_url)
113+ )`,
114+ `CREATE TABLE IF NOT EXISTS feed_similarity (
115+ feed_a TEXT NOT NULL REFERENCES feeds(feed_url),
116+ feed_b TEXT NOT NULL REFERENCES feeds(feed_url),
117+ jaccard REAL NOT NULL,
118+ computed_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
119+ PRIMARY KEY (feed_a, feed_b),
120+ CHECK(feed_a < feed_b)
121+ )`,
122+ `CREATE TABLE IF NOT EXISTS user_similarity (
123+ user_a TEXT NOT NULL REFERENCES users(did),
124+ user_b TEXT NOT NULL REFERENCES users(did),
125+ jaccard REAL NOT NULL,
126+ common_feeds INTEGER NOT NULL,
127+ computed_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
128+ PRIMARY KEY (user_a, user_b),
129+ CHECK(user_a < user_b)
130+ )`,
131+ `CREATE TABLE IF NOT EXISTS user_feed_recommendations (
132+ user_did TEXT NOT NULL REFERENCES users(did),
133+ feed_url TEXT NOT NULL REFERENCES feeds(feed_url),
134+ score REAL NOT NULL,
135+ computed_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
136+ PRIMARY KEY (user_did, feed_url)
137+ )`,
138+ `CREATE TABLE IF NOT EXISTS user_article_recommendations (
139+ user_did TEXT NOT NULL REFERENCES users(did),
140+ feed_url TEXT NOT NULL,
141+ article_url TEXT NOT NULL,
142+ score REAL NOT NULL,
143+ computed_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
144+ PRIMARY KEY (user_did, feed_url, article_url)
145+ )`,
146+ `CREATE INDEX IF NOT EXISTS idx_subscriptions_feed ON subscriptions(feed_url)`,
147+ `CREATE INDEX IF NOT EXISTS idx_subscriptions_user ON subscriptions(user_did)`,
148+ `CREATE INDEX IF NOT EXISTS idx_articles_feed ON articles(feed_url)`,
149+ `CREATE INDEX IF NOT EXISTS idx_articles_published ON articles(published DESC)`,
150+ `CREATE INDEX IF NOT EXISTS idx_read_state_unread ON read_state(user_did, is_read) WHERE is_read = 0`,
151+ `CREATE INDEX IF NOT EXISTS idx_read_state_starred ON read_state(user_did, is_starred) WHERE is_starred = 1`,
152+ `CREATE INDEX IF NOT EXISTS idx_annotations_article ON annotations(article_url)`,
153+ `CREATE INDEX IF NOT EXISTS idx_likes_article ON likes(feed_url, article_url)`,
154+ `CREATE INDEX IF NOT EXISTS idx_likes_author ON likes(author_did)`,
155+ }
156+
157+ for _, s := range stmts {
158+ if _, err := tx.Exec(s); err != nil {
159+ return err
160+ }
161+ }
162+
163+ return tx.Commit()
164+}
added internal/db/feed.go +210 -0
new file mode 100644
@@ -0,0 +1,210 @@
1+package db
2+
3+import (
4+ "context"
5+ "database/sql"
6+ "fmt"
7+ "time"
8+)
9+
10+type Feed struct {
11+ FeedURL string
12+ Title sql.NullString
13+ SiteURL sql.NullString
14+ Description sql.NullString
15+ FeedType sql.NullString
16+ LastFetchedAt sql.NullTime
17+ LastError sql.NullString
18+ SubscriberCount int
19+ Etag sql.NullString
20+ LastModified sql.NullString
21+ FetchIntervalMinutes int
22+ NextFetchAt sql.NullTime
23+ ConsecutiveEmptyFetches int
24+ ErrorCount int
25+}
26+
27+type Subscription struct {
28+ ID int64
29+ UserDID string
30+ FeedURL string
31+ FeedTitle string
32+ Category sql.NullString
33+ AddedAt sql.NullTime
34+ UnreadCount int
35+}
36+
37+func (db *DB) UpsertFeed(ctx context.Context, feed *Feed) error {
38+ _, err := db.ExecContext(ctx, `
39+ INSERT INTO feeds (feed_url, title, site_url, description, feed_type)
40+ VALUES (?, ?, ?, ?, ?)
41+ ON CONFLICT(feed_url) DO UPDATE SET
42+ title = excluded.title,
43+ site_url = excluded.site_url,
44+ description = excluded.description,
45+ feed_type = excluded.feed_type
46+ `, feed.FeedURL, feed.Title, feed.SiteURL, feed.Description, feed.FeedType)
47+ return err
48+}
49+
50+func (db *DB) GetFeed(ctx context.Context, feedURL string) (*Feed, error) {
51+ f := &Feed{}
52+ err := db.QueryRowContext(ctx, `
53+ SELECT feed_url, title, site_url, description, feed_type,
54+ last_fetched_at, last_error, subscriber_count, etag, last_modified,
55+ fetch_interval_minutes, next_fetch_at, consecutive_empty_fetches, error_count
56+ FROM feeds WHERE feed_url = ?
57+ `, feedURL).Scan(&f.FeedURL, &f.Title, &f.SiteURL, &f.Description, &f.FeedType,
58+ &f.LastFetchedAt, &f.LastError, &f.SubscriberCount, &f.Etag, &f.LastModified,
59+ &f.FetchIntervalMinutes, &f.NextFetchAt, &f.ConsecutiveEmptyFetches, &f.ErrorCount)
60+ if err != nil {
61+ return nil, err
62+ }
63+ return f, nil
64+}
65+
66+func (db *DB) GetFeedsToFetch(ctx context.Context, limit int) ([]*Feed, error) {
67+ rows, err := db.QueryContext(ctx, `
68+ SELECT feed_url, title, site_url, description, feed_type,
69+ last_fetched_at, last_error, subscriber_count, etag, last_modified,
70+ fetch_interval_minutes, next_fetch_at, consecutive_empty_fetches, error_count
71+ FROM feeds
72+ WHERE next_fetch_at <= CURRENT_TIMESTAMP
73+ ORDER BY next_fetch_at
74+ LIMIT ?
75+ `, limit)
76+ if err != nil {
77+ return nil, err
78+ }
79+ defer rows.Close()
80+
81+ var feeds []*Feed
82+ for rows.Next() {
83+ f := &Feed{}
84+ if err := rows.Scan(&f.FeedURL, &f.Title, &f.SiteURL, &f.Description, &f.FeedType,
85+ &f.LastFetchedAt, &f.LastError, &f.SubscriberCount, &f.Etag, &f.LastModified,
86+ &f.FetchIntervalMinutes, &f.NextFetchAt, &f.ConsecutiveEmptyFetches, &f.ErrorCount); err != nil {
87+ return nil, err
88+ }
89+ feeds = append(feeds, f)
90+ }
91+ return feeds, rows.Err()
92+}
93+
94+func (db *DB) UpdateFeedFetchResult(ctx context.Context, feedURL, etag, lastModified string, intervalMinutes, consecutiveEmpty, errorCount int, lastError string) error {
95+ nextFetch := time.Now().Add(time.Duration(intervalMinutes) * time.Minute)
96+ _, err := db.ExecContext(ctx, `
97+ UPDATE feeds SET
98+ etag = ?,
99+ last_modified = ?,
100+ fetch_interval_minutes = ?,
101+ next_fetch_at = ?,
102+ consecutive_empty_fetches = ?,
103+ error_count = ?,
104+ last_error = ?,
105+ last_fetched_at = CURRENT_TIMESTAMP
106+ WHERE feed_url = ?
107+ `, etag, lastModified, intervalMinutes, nextFetch, consecutiveEmpty, errorCount, lastError, feedURL)
108+ return err
109+}
110+
111+func (db *DB) IncrementSubscriberCount(ctx context.Context, feedURL string) error {
112+ _, err := db.ExecContext(ctx, `
113+ UPDATE feeds SET subscriber_count = subscriber_count + 1 WHERE feed_url = ?
114+ `, feedURL)
115+ return err
116+}
117+
118+func (db *DB) DecrementSubscriberCount(ctx context.Context, feedURL string) error {
119+ _, err := db.ExecContext(ctx, `
120+ UPDATE feeds SET subscriber_count = MAX(subscriber_count - 1, 0) WHERE feed_url = ?
121+ `, feedURL)
122+ return err
123+}
124+
125+func (db *DB) CreateSubscription(ctx context.Context, userDID, feedURL, category string) error {
126+ _, err := db.ExecContext(ctx, `
127+ INSERT INTO subscriptions (user_did, feed_url, category)
128+ VALUES (?, ?, ?)
129+ `, userDID, feedURL, category)
130+ if err != nil {
131+ return err
132+ }
133+ return db.IncrementSubscriberCount(ctx, feedURL)
134+}
135+
136+func (db *DB) DeleteSubscription(ctx context.Context, userDID, feedURL string) error {
137+ _, err := db.ExecContext(ctx, `
138+ DELETE FROM subscriptions WHERE user_did = ? AND feed_url = ?
139+ `, userDID, feedURL)
140+ if err != nil {
141+ return err
142+ }
143+ return db.DecrementSubscriberCount(ctx, feedURL)
144+}
145+
146+func (db *DB) ListSubscriptions(ctx context.Context, userDID, category string, limit, offset int) ([]*Subscription, error) {
147+ query := `SELECT s.id, s.user_did, s.feed_url, COALESCE(f.title, ''), s.category, s.added_at
148+ FROM subscriptions s
149+ LEFT JOIN feeds f ON s.feed_url = f.feed_url
150+ WHERE s.user_did = ?`
151+ args := []any{userDID}
152+
153+ if category != "" {
154+ query += ` AND s.category = ?`
155+ args = append(args, category)
156+ }
157+
158+ query += fmt.Sprintf(` ORDER BY s.added_at DESC LIMIT %d OFFSET %d`, limit, offset)
159+
160+ rows, err := db.QueryContext(ctx, query, args...)
161+ if err != nil {
162+ return nil, err
163+ }
164+ defer rows.Close()
165+
166+ var subs []*Subscription
167+ for rows.Next() {
168+ s := &Subscription{}
169+ if err := rows.Scan(&s.ID, &s.UserDID, &s.FeedURL, &s.FeedTitle, &s.Category, &s.AddedAt); err != nil {
170+ return nil, err
171+ }
172+ subs = append(subs, s)
173+ }
174+ return subs, rows.Err()
175+}
176+
177+func (db *DB) GetSubscriptionCount(ctx context.Context, userDID string) (int, error) {
178+ var count int
179+ err := db.QueryRowContext(ctx, `
180+ SELECT COUNT(*) FROM subscriptions WHERE user_did = ?
181+ `, userDID).Scan(&count)
182+ return count, err
183+}
184+
185+func (db *DB) ListAllFeeds(ctx context.Context, limit, offset int) ([]*Feed, error) {
186+ rows, err := db.QueryContext(ctx, fmt.Sprintf(`
187+ SELECT feed_url, title, site_url, description, feed_type,
188+ last_fetched_at, last_error, subscriber_count, etag, last_modified,
189+ fetch_interval_minutes, next_fetch_at, consecutive_empty_fetches, error_count
190+ FROM feeds
191+ ORDER BY subscriber_count DESC
192+ LIMIT %d OFFSET %d
193+ `, limit, offset))
194+ if err != nil {
195+ return nil, err
196+ }
197+ defer rows.Close()
198+
199+ var feeds []*Feed
200+ for rows.Next() {
201+ f := &Feed{}
202+ if err := rows.Scan(&f.FeedURL, &f.Title, &f.SiteURL, &f.Description, &f.FeedType,
203+ &f.LastFetchedAt, &f.LastError, &f.SubscriberCount, &f.Etag, &f.LastModified,
204+ &f.FetchIntervalMinutes, &f.NextFetchAt, &f.ConsecutiveEmptyFetches, &f.ErrorCount); err != nil {
205+ return nil, err
206+ }
207+ feeds = append(feeds, f)
208+ }
209+ return feeds, rows.Err()
210+}
new file mode 100644
@@ -0,0 +1,210 @@
1+package db
2+
3+import (
4+ "context"
5+ "database/sql"
6+ "fmt"
7+ "time"
8+)
9+
10+type Feed struct {
11+ FeedURL string
12+ Title sql.NullString
13+ SiteURL sql.NullString
14+ Description sql.NullString
15+ FeedType sql.NullString
16+ LastFetchedAt sql.NullTime
17+ LastError sql.NullString
18+ SubscriberCount int
19+ Etag sql.NullString
20+ LastModified sql.NullString
21+ FetchIntervalMinutes int
22+ NextFetchAt sql.NullTime
23+ ConsecutiveEmptyFetches int
24+ ErrorCount int
25+}
26+
27+type Subscription struct {
28+ ID int64
29+ UserDID string
30+ FeedURL string
31+ FeedTitle string
32+ Category sql.NullString
33+ AddedAt sql.NullTime
34+ UnreadCount int
35+}
36+
37+func (db *DB) UpsertFeed(ctx context.Context, feed *Feed) error {
38+ _, err := db.ExecContext(ctx, `
39+ INSERT INTO feeds (feed_url, title, site_url, description, feed_type)
40+ VALUES (?, ?, ?, ?, ?)
41+ ON CONFLICT(feed_url) DO UPDATE SET
42+ title = excluded.title,
43+ site_url = excluded.site_url,
44+ description = excluded.description,
45+ feed_type = excluded.feed_type
46+ `, feed.FeedURL, feed.Title, feed.SiteURL, feed.Description, feed.FeedType)
47+ return err
48+}
49+
50+func (db *DB) GetFeed(ctx context.Context, feedURL string) (*Feed, error) {
51+ f := &Feed{}
52+ err := db.QueryRowContext(ctx, `
53+ SELECT feed_url, title, site_url, description, feed_type,
54+ last_fetched_at, last_error, subscriber_count, etag, last_modified,
55+ fetch_interval_minutes, next_fetch_at, consecutive_empty_fetches, error_count
56+ FROM feeds WHERE feed_url = ?
57+ `, feedURL).Scan(&f.FeedURL, &f.Title, &f.SiteURL, &f.Description, &f.FeedType,
58+ &f.LastFetchedAt, &f.LastError, &f.SubscriberCount, &f.Etag, &f.LastModified,
59+ &f.FetchIntervalMinutes, &f.NextFetchAt, &f.ConsecutiveEmptyFetches, &f.ErrorCount)
60+ if err != nil {
61+ return nil, err
62+ }
63+ return f, nil
64+}
65+
66+func (db *DB) GetFeedsToFetch(ctx context.Context, limit int) ([]*Feed, error) {
67+ rows, err := db.QueryContext(ctx, `
68+ SELECT feed_url, title, site_url, description, feed_type,
69+ last_fetched_at, last_error, subscriber_count, etag, last_modified,
70+ fetch_interval_minutes, next_fetch_at, consecutive_empty_fetches, error_count
71+ FROM feeds
72+ WHERE next_fetch_at <= CURRENT_TIMESTAMP
73+ ORDER BY next_fetch_at
74+ LIMIT ?
75+ `, limit)
76+ if err != nil {
77+ return nil, err
78+ }
79+ defer rows.Close()
80+
81+ var feeds []*Feed
82+ for rows.Next() {
83+ f := &Feed{}
84+ if err := rows.Scan(&f.FeedURL, &f.Title, &f.SiteURL, &f.Description, &f.FeedType,
85+ &f.LastFetchedAt, &f.LastError, &f.SubscriberCount, &f.Etag, &f.LastModified,
86+ &f.FetchIntervalMinutes, &f.NextFetchAt, &f.ConsecutiveEmptyFetches, &f.ErrorCount); err != nil {
87+ return nil, err
88+ }
89+ feeds = append(feeds, f)
90+ }
91+ return feeds, rows.Err()
92+}
93+
94+func (db *DB) UpdateFeedFetchResult(ctx context.Context, feedURL, etag, lastModified string, intervalMinutes, consecutiveEmpty, errorCount int, lastError string) error {
95+ nextFetch := time.Now().Add(time.Duration(intervalMinutes) * time.Minute)
96+ _, err := db.ExecContext(ctx, `
97+ UPDATE feeds SET
98+ etag = ?,
99+ last_modified = ?,
100+ fetch_interval_minutes = ?,
101+ next_fetch_at = ?,
102+ consecutive_empty_fetches = ?,
103+ error_count = ?,
104+ last_error = ?,
105+ last_fetched_at = CURRENT_TIMESTAMP
106+ WHERE feed_url = ?
107+ `, etag, lastModified, intervalMinutes, nextFetch, consecutiveEmpty, errorCount, lastError, feedURL)
108+ return err
109+}
110+
111+func (db *DB) IncrementSubscriberCount(ctx context.Context, feedURL string) error {
112+ _, err := db.ExecContext(ctx, `
113+ UPDATE feeds SET subscriber_count = subscriber_count + 1 WHERE feed_url = ?
114+ `, feedURL)
115+ return err
116+}
117+
118+func (db *DB) DecrementSubscriberCount(ctx context.Context, feedURL string) error {
119+ _, err := db.ExecContext(ctx, `
120+ UPDATE feeds SET subscriber_count = MAX(subscriber_count - 1, 0) WHERE feed_url = ?
121+ `, feedURL)
122+ return err
123+}
124+
125+func (db *DB) CreateSubscription(ctx context.Context, userDID, feedURL, category string) error {
126+ _, err := db.ExecContext(ctx, `
127+ INSERT INTO subscriptions (user_did, feed_url, category)
128+ VALUES (?, ?, ?)
129+ `, userDID, feedURL, category)
130+ if err != nil {
131+ return err
132+ }
133+ return db.IncrementSubscriberCount(ctx, feedURL)
134+}
135+
136+func (db *DB) DeleteSubscription(ctx context.Context, userDID, feedURL string) error {
137+ _, err := db.ExecContext(ctx, `
138+ DELETE FROM subscriptions WHERE user_did = ? AND feed_url = ?
139+ `, userDID, feedURL)
140+ if err != nil {
141+ return err
142+ }
143+ return db.DecrementSubscriberCount(ctx, feedURL)
144+}
145+
146+func (db *DB) ListSubscriptions(ctx context.Context, userDID, category string, limit, offset int) ([]*Subscription, error) {
147+ query := `SELECT s.id, s.user_did, s.feed_url, COALESCE(f.title, ''), s.category, s.added_at
148+ FROM subscriptions s
149+ LEFT JOIN feeds f ON s.feed_url = f.feed_url
150+ WHERE s.user_did = ?`
151+ args := []any{userDID}
152+
153+ if category != "" {
154+ query += ` AND s.category = ?`
155+ args = append(args, category)
156+ }
157+
158+ query += fmt.Sprintf(` ORDER BY s.added_at DESC LIMIT %d OFFSET %d`, limit, offset)
159+
160+ rows, err := db.QueryContext(ctx, query, args...)
161+ if err != nil {
162+ return nil, err
163+ }
164+ defer rows.Close()
165+
166+ var subs []*Subscription
167+ for rows.Next() {
168+ s := &Subscription{}
169+ if err := rows.Scan(&s.ID, &s.UserDID, &s.FeedURL, &s.FeedTitle, &s.Category, &s.AddedAt); err != nil {
170+ return nil, err
171+ }
172+ subs = append(subs, s)
173+ }
174+ return subs, rows.Err()
175+}
176+
177+func (db *DB) GetSubscriptionCount(ctx context.Context, userDID string) (int, error) {
178+ var count int
179+ err := db.QueryRowContext(ctx, `
180+ SELECT COUNT(*) FROM subscriptions WHERE user_did = ?
181+ `, userDID).Scan(&count)
182+ return count, err
183+}
184+
185+func (db *DB) ListAllFeeds(ctx context.Context, limit, offset int) ([]*Feed, error) {
186+ rows, err := db.QueryContext(ctx, fmt.Sprintf(`
187+ SELECT feed_url, title, site_url, description, feed_type,
188+ last_fetched_at, last_error, subscriber_count, etag, last_modified,
189+ fetch_interval_minutes, next_fetch_at, consecutive_empty_fetches, error_count
190+ FROM feeds
191+ ORDER BY subscriber_count DESC
192+ LIMIT %d OFFSET %d
193+ `, limit, offset))
194+ if err != nil {
195+ return nil, err
196+ }
197+ defer rows.Close()
198+
199+ var feeds []*Feed
200+ for rows.Next() {
201+ f := &Feed{}
202+ if err := rows.Scan(&f.FeedURL, &f.Title, &f.SiteURL, &f.Description, &f.FeedType,
203+ &f.LastFetchedAt, &f.LastError, &f.SubscriberCount, &f.Etag, &f.LastModified,
204+ &f.FetchIntervalMinutes, &f.NextFetchAt, &f.ConsecutiveEmptyFetches, &f.ErrorCount); err != nil {
205+ return nil, err
206+ }
207+ feeds = append(feeds, f)
208+ }
209+ return feeds, rows.Err()
210+}
added internal/db/social.go +210 -0
new file mode 100644
@@ -0,0 +1,210 @@
1+package db
2+
3+import (
4+ "context"
5+ "database/sql"
6+ "fmt"
7+ "strings"
8+)
9+
10+type Annotation struct {
11+ ID int64
12+ URI string
13+ AuthorDID string
14+ FeedURL string
15+ ArticleURL string
16+ Quote sql.NullString
17+ Note sql.NullString
18+ Tags sql.NullString
19+ Rating sql.NullInt64
20+ CreatedAt sql.NullTime
21+ CID sql.NullString
22+}
23+
24+type Like struct {
25+ ID int64
26+ URI string
27+ AuthorDID string
28+ FeedURL string
29+ ArticleURL string
30+ CreatedAt sql.NullTime
31+ CID sql.NullString
32+}
33+
34+func (db *DB) CreateAnnotation(ctx context.Context, a *Annotation) error {
35+ _, err := db.ExecContext(ctx, `
36+ INSERT INTO annotations (uri, author_did, feed_url, article_url, quote, note, tags, rating, created_at, cid)
37+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
38+ `, a.URI, a.AuthorDID, a.FeedURL, a.ArticleURL, a.Quote, a.Note, a.Tags, a.Rating, a.CreatedAt, a.CID)
39+ return err
40+}
41+
42+func (db *DB) DeleteAnnotation(ctx context.Context, uri string) error {
43+ _, err := db.ExecContext(ctx, `DELETE FROM annotations WHERE uri = ?`, uri)
44+ return err
45+}
46+
47+func (db *DB) ListAnnotations(ctx context.Context, feedURL, articleURL, authorDID string, limit, offset int) ([]*Annotation, error) {
48+ var conds []string
49+ var args []any
50+
51+ if feedURL != "" {
52+ conds = append(conds, "feed_url = ?")
53+ args = append(args, feedURL)
54+ }
55+ if articleURL != "" {
56+ conds = append(conds, "article_url = ?")
57+ args = append(args, articleURL)
58+ }
59+ if authorDID != "" {
60+ conds = append(conds, "author_did = ?")
61+ args = append(args, authorDID)
62+ }
63+
64+ query := `SELECT id, uri, author_did, feed_url, article_url, quote, note, tags, rating, created_at, cid
65+ FROM annotations`
66+ if len(conds) > 0 {
67+ query += ` WHERE ` + strings.Join(conds, " AND ")
68+ }
69+ query += fmt.Sprintf(` ORDER BY created_at DESC LIMIT %d OFFSET %d`, limit, offset)
70+
71+ rows, err := db.QueryContext(ctx, query, args...)
72+ if err != nil {
73+ return nil, err
74+ }
75+ defer rows.Close()
76+
77+ var annotations []*Annotation
78+ for rows.Next() {
79+ a := &Annotation{}
80+ if err := rows.Scan(&a.ID, &a.URI, &a.AuthorDID, &a.FeedURL, &a.ArticleURL,
81+ &a.Quote, &a.Note, &a.Tags, &a.Rating, &a.CreatedAt, &a.CID); err != nil {
82+ return nil, err
83+ }
84+ annotations = append(annotations, a)
85+ }
86+ return annotations, rows.Err()
87+}
88+
89+func (db *DB) CreateLike(ctx context.Context, l *Like) error {
90+ _, err := db.ExecContext(ctx, `
91+ INSERT INTO likes (uri, author_did, feed_url, article_url, created_at, cid)
92+ VALUES (?, ?, ?, ?, ?, ?)
93+ `, l.URI, l.AuthorDID, l.FeedURL, l.ArticleURL, l.CreatedAt, l.CID)
94+ return err
95+}
96+
97+func (db *DB) DeleteLike(ctx context.Context, uri string) error {
98+ _, err := db.ExecContext(ctx, `DELETE FROM likes WHERE uri = ?`, uri)
99+ return err
100+}
101+
102+func (db *DB) DeleteLikeByUserArticle(ctx context.Context, authorDID, feedURL, articleURL string) error {
103+ _, err := db.ExecContext(ctx, `
104+ DELETE FROM likes WHERE author_did = ? AND feed_url = ? AND article_url = ?
105+ `, authorDID, feedURL, articleURL)
106+ return err
107+}
108+
109+func (db *DB) ListLikes(ctx context.Context, authorDID, feedURL string, limit, offset int) ([]*Like, error) {
110+ var conds []string
111+ var args []any
112+
113+ if authorDID != "" {
114+ conds = append(conds, "author_did = ?")
115+ args = append(args, authorDID)
116+ }
117+ if feedURL != "" {
118+ conds = append(conds, "feed_url = ?")
119+ args = append(args, feedURL)
120+ }
121+
122+ query := `SELECT id, uri, author_did, feed_url, article_url, created_at, cid FROM likes`
123+ if len(conds) > 0 {
124+ query += ` WHERE ` + strings.Join(conds, " AND ")
125+ }
126+ query += fmt.Sprintf(` ORDER BY created_at DESC LIMIT %d OFFSET %d`, limit, offset)
127+
128+ rows, err := db.QueryContext(ctx, query, args...)
129+ if err != nil {
130+ return nil, err
131+ }
132+ defer rows.Close()
133+
134+ var likes []*Like
135+ for rows.Next() {
136+ l := &Like{}
137+ if err := rows.Scan(&l.ID, &l.URI, &l.AuthorDID, &l.FeedURL, &l.ArticleURL, &l.CreatedAt, &l.CID); err != nil {
138+ return nil, err
139+ }
140+ likes = append(likes, l)
141+ }
142+ return likes, rows.Err()
143+}
144+
145+func (db *DB) GetLikeCount(ctx context.Context, feedURL, articleURL string) (int, error) {
146+ var count int
147+ err := db.QueryRowContext(ctx, `
148+ SELECT COUNT(*) FROM likes WHERE feed_url = ? AND article_url = ?
149+ `, feedURL, articleURL).Scan(&count)
150+ return count, err
151+}
152+
153+func (db *DB) HasLiked(ctx context.Context, authorDID, feedURL, articleURL string) (bool, error) {
154+ var exists int
155+ err := db.QueryRowContext(ctx, `
156+ SELECT 1 FROM likes WHERE author_did = ? AND feed_url = ? AND article_url = ?
157+ `, authorDID, feedURL, articleURL).Scan(&exists)
158+ if err == sql.ErrNoRows {
159+ return false, nil
160+ }
161+ if err != nil {
162+ return false, err
163+ }
164+ return true, nil
165+}
166+
167+type TrendingItem struct {
168+ ArticleID int64
169+ Title string
170+ URL string
171+ Author string
172+ Summary string
173+ FeedURL string
174+ FeedTitle string
175+ LikeCount int
176+ AnnotationCount int
177+}
178+
179+func (db *DB) ListTrendingArticles(ctx context.Context, since string, limit, offset int) ([]*TrendingItem, error) {
180+ rows, err := db.QueryContext(ctx, fmt.Sprintf(`
181+ SELECT ar.id, ar.title, COALESCE(ar.url, ''), COALESCE(ar.author, ''),
182+ COALESCE(ar.summary, ''), l.feed_url, COALESCE(f.title, ''),
183+ COUNT(DISTINCT l.id) AS like_count,
184+ COUNT(DISTINCT a.id) AS annotation_count
185+ FROM likes l
186+ JOIN articles ar ON ar.url = l.article_url AND ar.feed_url = l.feed_url
187+ LEFT JOIN feeds f ON f.feed_url = l.feed_url
188+ LEFT JOIN annotations a ON a.feed_url = l.feed_url AND a.article_url = l.article_url AND a.created_at >= ?
189+ WHERE l.created_at >= ?
190+ GROUP BY ar.id
191+ ORDER BY like_count DESC, annotation_count DESC
192+ LIMIT %d OFFSET %d
193+ `, limit, offset), since, since)
194+ if err != nil {
195+ return nil, err
196+ }
197+ defer rows.Close()
198+
199+ var results []*TrendingItem
200+ for rows.Next() {
201+ item := &TrendingItem{}
202+ if err := rows.Scan(&item.ArticleID, &item.Title, &item.URL, &item.Author,
203+ &item.Summary, &item.FeedURL, &item.FeedTitle,
204+ &item.LikeCount, &item.AnnotationCount); err != nil {
205+ return nil, err
206+ }
207+ results = append(results, item)
208+ }
209+ return results, rows.Err()
210+}
new file mode 100644
@@ -0,0 +1,210 @@
1+package db
2+
3+import (
4+ "context"
5+ "database/sql"
6+ "fmt"
7+ "strings"
8+)
9+
10+type Annotation struct {
11+ ID int64
12+ URI string
13+ AuthorDID string
14+ FeedURL string
15+ ArticleURL string
16+ Quote sql.NullString
17+ Note sql.NullString
18+ Tags sql.NullString
19+ Rating sql.NullInt64
20+ CreatedAt sql.NullTime
21+ CID sql.NullString
22+}
23+
24+type Like struct {
25+ ID int64
26+ URI string
27+ AuthorDID string
28+ FeedURL string
29+ ArticleURL string
30+ CreatedAt sql.NullTime
31+ CID sql.NullString
32+}
33+
34+func (db *DB) CreateAnnotation(ctx context.Context, a *Annotation) error {
35+ _, err := db.ExecContext(ctx, `
36+ INSERT INTO annotations (uri, author_did, feed_url, article_url, quote, note, tags, rating, created_at, cid)
37+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
38+ `, a.URI, a.AuthorDID, a.FeedURL, a.ArticleURL, a.Quote, a.Note, a.Tags, a.Rating, a.CreatedAt, a.CID)
39+ return err
40+}
41+
42+func (db *DB) DeleteAnnotation(ctx context.Context, uri string) error {
43+ _, err := db.ExecContext(ctx, `DELETE FROM annotations WHERE uri = ?`, uri)
44+ return err
45+}
46+
47+func (db *DB) ListAnnotations(ctx context.Context, feedURL, articleURL, authorDID string, limit, offset int) ([]*Annotation, error) {
48+ var conds []string
49+ var args []any
50+
51+ if feedURL != "" {
52+ conds = append(conds, "feed_url = ?")
53+ args = append(args, feedURL)
54+ }
55+ if articleURL != "" {
56+ conds = append(conds, "article_url = ?")
57+ args = append(args, articleURL)
58+ }
59+ if authorDID != "" {
60+ conds = append(conds, "author_did = ?")
61+ args = append(args, authorDID)
62+ }
63+
64+ query := `SELECT id, uri, author_did, feed_url, article_url, quote, note, tags, rating, created_at, cid
65+ FROM annotations`
66+ if len(conds) > 0 {
67+ query += ` WHERE ` + strings.Join(conds, " AND ")
68+ }
69+ query += fmt.Sprintf(` ORDER BY created_at DESC LIMIT %d OFFSET %d`, limit, offset)
70+
71+ rows, err := db.QueryContext(ctx, query, args...)
72+ if err != nil {
73+ return nil, err
74+ }
75+ defer rows.Close()
76+
77+ var annotations []*Annotation
78+ for rows.Next() {
79+ a := &Annotation{}
80+ if err := rows.Scan(&a.ID, &a.URI, &a.AuthorDID, &a.FeedURL, &a.ArticleURL,
81+ &a.Quote, &a.Note, &a.Tags, &a.Rating, &a.CreatedAt, &a.CID); err != nil {
82+ return nil, err
83+ }
84+ annotations = append(annotations, a)
85+ }
86+ return annotations, rows.Err()
87+}
88+
89+func (db *DB) CreateLike(ctx context.Context, l *Like) error {
90+ _, err := db.ExecContext(ctx, `
91+ INSERT INTO likes (uri, author_did, feed_url, article_url, created_at, cid)
92+ VALUES (?, ?, ?, ?, ?, ?)
93+ `, l.URI, l.AuthorDID, l.FeedURL, l.ArticleURL, l.CreatedAt, l.CID)
94+ return err
95+}
96+
97+func (db *DB) DeleteLike(ctx context.Context, uri string) error {
98+ _, err := db.ExecContext(ctx, `DELETE FROM likes WHERE uri = ?`, uri)
99+ return err
100+}
101+
102+func (db *DB) DeleteLikeByUserArticle(ctx context.Context, authorDID, feedURL, articleURL string) error {
103+ _, err := db.ExecContext(ctx, `
104+ DELETE FROM likes WHERE author_did = ? AND feed_url = ? AND article_url = ?
105+ `, authorDID, feedURL, articleURL)
106+ return err
107+}
108+
109+func (db *DB) ListLikes(ctx context.Context, authorDID, feedURL string, limit, offset int) ([]*Like, error) {
110+ var conds []string
111+ var args []any
112+
113+ if authorDID != "" {
114+ conds = append(conds, "author_did = ?")
115+ args = append(args, authorDID)
116+ }
117+ if feedURL != "" {
118+ conds = append(conds, "feed_url = ?")
119+ args = append(args, feedURL)
120+ }
121+
122+ query := `SELECT id, uri, author_did, feed_url, article_url, created_at, cid FROM likes`
123+ if len(conds) > 0 {
124+ query += ` WHERE ` + strings.Join(conds, " AND ")
125+ }
126+ query += fmt.Sprintf(` ORDER BY created_at DESC LIMIT %d OFFSET %d`, limit, offset)
127+
128+ rows, err := db.QueryContext(ctx, query, args...)
129+ if err != nil {
130+ return nil, err
131+ }
132+ defer rows.Close()
133+
134+ var likes []*Like
135+ for rows.Next() {
136+ l := &Like{}
137+ if err := rows.Scan(&l.ID, &l.URI, &l.AuthorDID, &l.FeedURL, &l.ArticleURL, &l.CreatedAt, &l.CID); err != nil {
138+ return nil, err
139+ }
140+ likes = append(likes, l)
141+ }
142+ return likes, rows.Err()
143+}
144+
145+func (db *DB) GetLikeCount(ctx context.Context, feedURL, articleURL string) (int, error) {
146+ var count int
147+ err := db.QueryRowContext(ctx, `
148+ SELECT COUNT(*) FROM likes WHERE feed_url = ? AND article_url = ?
149+ `, feedURL, articleURL).Scan(&count)
150+ return count, err
151+}
152+
153+func (db *DB) HasLiked(ctx context.Context, authorDID, feedURL, articleURL string) (bool, error) {
154+ var exists int
155+ err := db.QueryRowContext(ctx, `
156+ SELECT 1 FROM likes WHERE author_did = ? AND feed_url = ? AND article_url = ?
157+ `, authorDID, feedURL, articleURL).Scan(&exists)
158+ if err == sql.ErrNoRows {
159+ return false, nil
160+ }
161+ if err != nil {
162+ return false, err
163+ }
164+ return true, nil
165+}
166+
167+type TrendingItem struct {
168+ ArticleID int64
169+ Title string
170+ URL string
171+ Author string
172+ Summary string
173+ FeedURL string
174+ FeedTitle string
175+ LikeCount int
176+ AnnotationCount int
177+}
178+
179+func (db *DB) ListTrendingArticles(ctx context.Context, since string, limit, offset int) ([]*TrendingItem, error) {
180+ rows, err := db.QueryContext(ctx, fmt.Sprintf(`
181+ SELECT ar.id, ar.title, COALESCE(ar.url, ''), COALESCE(ar.author, ''),
182+ COALESCE(ar.summary, ''), l.feed_url, COALESCE(f.title, ''),
183+ COUNT(DISTINCT l.id) AS like_count,
184+ COUNT(DISTINCT a.id) AS annotation_count
185+ FROM likes l
186+ JOIN articles ar ON ar.url = l.article_url AND ar.feed_url = l.feed_url
187+ LEFT JOIN feeds f ON f.feed_url = l.feed_url
188+ LEFT JOIN annotations a ON a.feed_url = l.feed_url AND a.article_url = l.article_url AND a.created_at >= ?
189+ WHERE l.created_at >= ?
190+ GROUP BY ar.id
191+ ORDER BY like_count DESC, annotation_count DESC
192+ LIMIT %d OFFSET %d
193+ `, limit, offset), since, since)
194+ if err != nil {
195+ return nil, err
196+ }
197+ defer rows.Close()
198+
199+ var results []*TrendingItem
200+ for rows.Next() {
201+ item := &TrendingItem{}
202+ if err := rows.Scan(&item.ArticleID, &item.Title, &item.URL, &item.Author,
203+ &item.Summary, &item.FeedURL, &item.FeedTitle,
204+ &item.LikeCount, &item.AnnotationCount); err != nil {
205+ return nil, err
206+ }
207+ results = append(results, item)
208+ }
209+ return results, rows.Err()
210+}
added internal/db/store.go +59 -0
new file mode 100644
@@ -0,0 +1,59 @@
1+package db
2+
3+import (
4+ "context"
5+ "database/sql"
6+
7+ "pkg.rbrt.fr/glean/internal/feed"
8+)
9+
10+type FeedStoreAdapter struct {
11+ db *DB
12+}
13+
14+func NewFeedStoreAdapter(db *DB) *FeedStoreAdapter {
15+ return &FeedStoreAdapter{db: db}
16+}
17+
18+func (a *FeedStoreAdapter) GetFeedsToFetch(ctx context.Context, limit int) ([]*feed.Feed, error) {
19+ dbFeeds, err := a.db.GetFeedsToFetch(ctx, limit)
20+ if err != nil {
21+ return nil, err
22+ }
23+ var feeds []*feed.Feed
24+ for _, df := range dbFeeds {
25+ feeds = append(feeds, &feed.Feed{
26+ URL: df.FeedURL,
27+ Title: df.Title.String,
28+ SiteURL: df.SiteURL.String,
29+ Description: df.Description.String,
30+ Type: df.FeedType.String,
31+ ETag: df.Etag.String,
32+ LastModified: df.LastModified.String,
33+ })
34+ }
35+ return feeds, nil
36+}
37+
38+func (a *FeedStoreAdapter) UpsertArticle(ctx context.Context, article *feed.Article) (int64, error) {
39+ dbArticle := &Article{
40+ FeedURL: article.FeedURL,
41+ GUID: article.GUID,
42+ Title: article.Title,
43+ Summary: sql.NullString{String: article.Summary, Valid: article.Summary != ""},
44+ Content: sql.NullString{String: article.Content, Valid: article.Content != ""},
45+ Author: sql.NullString{String: article.Author, Valid: article.Author != ""},
46+ URL: sql.NullString{String: article.URL, Valid: article.URL != ""},
47+ }
48+ if !article.Published.IsZero() {
49+ dbArticle.Published = sql.NullTime{Time: article.Published, Valid: true}
50+ }
51+ if !article.Updated.IsZero() {
52+ dbArticle.Updated = sql.NullTime{Time: article.Updated, Valid: true}
53+ }
54+ return a.db.UpsertArticle(ctx, dbArticle)
55+}
56+
57+func (a *FeedStoreAdapter) UpdateFeedFetchResult(ctx context.Context, feedURL, etag, lastModified string, intervalMinutes, consecutiveEmpty, errorCount int, lastError string) error {
58+ return a.db.UpdateFeedFetchResult(ctx, feedURL, etag, lastModified, intervalMinutes, consecutiveEmpty, errorCount, lastError)
59+}
new file mode 100644
@@ -0,0 +1,59 @@
1+package db
2+
3+import (
4+ "context"
5+ "database/sql"
6+
7+ "pkg.rbrt.fr/glean/internal/feed"
8+)
9+
10+type FeedStoreAdapter struct {
11+ db *DB
12+}
13+
14+func NewFeedStoreAdapter(db *DB) *FeedStoreAdapter {
15+ return &FeedStoreAdapter{db: db}
16+}
17+
18+func (a *FeedStoreAdapter) GetFeedsToFetch(ctx context.Context, limit int) ([]*feed.Feed, error) {
19+ dbFeeds, err := a.db.GetFeedsToFetch(ctx, limit)
20+ if err != nil {
21+ return nil, err
22+ }
23+ var feeds []*feed.Feed
24+ for _, df := range dbFeeds {
25+ feeds = append(feeds, &feed.Feed{
26+ URL: df.FeedURL,
27+ Title: df.Title.String,
28+ SiteURL: df.SiteURL.String,
29+ Description: df.Description.String,
30+ Type: df.FeedType.String,
31+ ETag: df.Etag.String,
32+ LastModified: df.LastModified.String,
33+ })
34+ }
35+ return feeds, nil
36+}
37+
38+func (a *FeedStoreAdapter) UpsertArticle(ctx context.Context, article *feed.Article) (int64, error) {
39+ dbArticle := &Article{
40+ FeedURL: article.FeedURL,
41+ GUID: article.GUID,
42+ Title: article.Title,
43+ Summary: sql.NullString{String: article.Summary, Valid: article.Summary != ""},
44+ Content: sql.NullString{String: article.Content, Valid: article.Content != ""},
45+ Author: sql.NullString{String: article.Author, Valid: article.Author != ""},
46+ URL: sql.NullString{String: article.URL, Valid: article.URL != ""},
47+ }
48+ if !article.Published.IsZero() {
49+ dbArticle.Published = sql.NullTime{Time: article.Published, Valid: true}
50+ }
51+ if !article.Updated.IsZero() {
52+ dbArticle.Updated = sql.NullTime{Time: article.Updated, Valid: true}
53+ }
54+ return a.db.UpsertArticle(ctx, dbArticle)
55+}
56+
57+func (a *FeedStoreAdapter) UpdateFeedFetchResult(ctx context.Context, feedURL, etag, lastModified string, intervalMinutes, consecutiveEmpty, errorCount int, lastError string) error {
58+ return a.db.UpdateFeedFetchResult(ctx, feedURL, etag, lastModified, intervalMinutes, consecutiveEmpty, errorCount, lastError)
59+}
added internal/db/user.go +55 -0
new file mode 100644
@@ -0,0 +1,55 @@
1+package db
2+
3+import (
4+ "context"
5+ "database/sql"
6+)
7+
8+type User struct {
9+ DID string
10+ Handle string
11+ DisplayName sql.NullString
12+ AvatarURL sql.NullString
13+ IndexedAt sql.NullTime
14+ UpdatedAt sql.NullTime
15+}
16+
17+func (db *DB) CreateUser(ctx context.Context, did, handle, displayName, avatarURL string) (*User, error) {
18+ _, err := db.ExecContext(ctx, `
19+ INSERT INTO users (did, handle, display_name, avatar_url, updated_at)
20+ VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP)
21+ ON CONFLICT(did) DO UPDATE SET
22+ handle = excluded.handle,
23+ display_name = excluded.display_name,
24+ avatar_url = excluded.avatar_url,
25+ updated_at = CURRENT_TIMESTAMP
26+ `, did, handle, displayName, avatarURL)
27+ if err != nil {
28+ return nil, err
29+ }
30+ return db.GetUser(ctx, did)
31+}
32+
33+func (db *DB) GetUser(ctx context.Context, did string) (*User, error) {
34+ u := &User{}
35+ err := db.QueryRowContext(ctx, `
36+ SELECT did, handle, display_name, avatar_url, indexed_at, updated_at
37+ FROM users WHERE did = ?
38+ `, did).Scan(&u.DID, &u.Handle, &u.DisplayName, &u.AvatarURL, &u.IndexedAt, &u.UpdatedAt)
39+ if err != nil {
40+ return nil, err
41+ }
42+ return u, nil
43+}
44+
45+func (db *DB) GetUserByHandle(ctx context.Context, handle string) (*User, error) {
46+ u := &User{}
47+ err := db.QueryRowContext(ctx, `
48+ SELECT did, handle, display_name, avatar_url, indexed_at, updated_at
49+ FROM users WHERE handle = ?
50+ `, handle).Scan(&u.DID, &u.Handle, &u.DisplayName, &u.AvatarURL, &u.IndexedAt, &u.UpdatedAt)
51+ if err != nil {
52+ return nil, err
53+ }
54+ return u, nil
55+}
new file mode 100644
@@ -0,0 +1,55 @@
1+package db
2+
3+import (
4+ "context"
5+ "database/sql"
6+)
7+
8+type User struct {
9+ DID string
10+ Handle string
11+ DisplayName sql.NullString
12+ AvatarURL sql.NullString
13+ IndexedAt sql.NullTime
14+ UpdatedAt sql.NullTime
15+}
16+
17+func (db *DB) CreateUser(ctx context.Context, did, handle, displayName, avatarURL string) (*User, error) {
18+ _, err := db.ExecContext(ctx, `
19+ INSERT INTO users (did, handle, display_name, avatar_url, updated_at)
20+ VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP)
21+ ON CONFLICT(did) DO UPDATE SET
22+ handle = excluded.handle,
23+ display_name = excluded.display_name,
24+ avatar_url = excluded.avatar_url,
25+ updated_at = CURRENT_TIMESTAMP
26+ `, did, handle, displayName, avatarURL)
27+ if err != nil {
28+ return nil, err
29+ }
30+ return db.GetUser(ctx, did)
31+}
32+
33+func (db *DB) GetUser(ctx context.Context, did string) (*User, error) {
34+ u := &User{}
35+ err := db.QueryRowContext(ctx, `
36+ SELECT did, handle, display_name, avatar_url, indexed_at, updated_at
37+ FROM users WHERE did = ?
38+ `, did).Scan(&u.DID, &u.Handle, &u.DisplayName, &u.AvatarURL, &u.IndexedAt, &u.UpdatedAt)
39+ if err != nil {
40+ return nil, err
41+ }
42+ return u, nil
43+}
44+
45+func (db *DB) GetUserByHandle(ctx context.Context, handle string) (*User, error) {
46+ u := &User{}
47+ err := db.QueryRowContext(ctx, `
48+ SELECT did, handle, display_name, avatar_url, indexed_at, updated_at
49+ FROM users WHERE handle = ?
50+ `, handle).Scan(&u.DID, &u.Handle, &u.DisplayName, &u.AvatarURL, &u.IndexedAt, &u.UpdatedAt)
51+ if err != nil {
52+ return nil, err
53+ }
54+ return u, nil
55+}
added internal/feed/fetcher.go +187 -0
new file mode 100644
@@ -0,0 +1,187 @@
1+package feed
2+
3+import (
4+ "context"
5+ "fmt"
6+ "log/slog"
7+ "net/http"
8+ "time"
9+)
10+
11+type Fetcher struct {
12+ httpClient *http.Client
13+}
14+
15+func NewFetcher() *Fetcher {
16+ return &Fetcher{
17+ httpClient: &http.Client{
18+ Timeout: 30 * time.Second,
19+ CheckRedirect: func(req *http.Request, via []*http.Request) error {
20+ if len(via) >= 10 {
21+ return fmt.Errorf("too many redirects")
22+ }
23+ return nil
24+ },
25+ },
26+ }
27+}
28+
29+func (f *Fetcher) Fetch(ctx context.Context, feedURL, etag, lastModified string) (*ParseResult, string, string, error) {
30+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, feedURL, nil)
31+ if err != nil {
32+ return nil, "", "", fmt.Errorf("creating request: %w", err)
33+ }
34+
35+ if etag != "" {
36+ req.Header.Set("If-None-Match", etag)
37+ }
38+ if lastModified != "" {
39+ req.Header.Set("If-Modified-Since", lastModified)
40+ }
41+
42+ resp, err := f.httpClient.Do(req)
43+ if err != nil {
44+ return nil, "", "", fmt.Errorf("fetching feed: %w", err)
45+ }
46+ defer resp.Body.Close()
47+
48+ if resp.StatusCode == http.StatusNotModified {
49+ return nil, "", "", nil
50+ }
51+
52+ if resp.StatusCode != http.StatusOK {
53+ return nil, "", "", fmt.Errorf("unexpected status: %d", resp.StatusCode)
54+ }
55+
56+ newEtag := resp.Header.Get("ETag")
57+ newLastModified := resp.Header.Get("Last-Modified")
58+
59+ result, err := Parse(resp.Body, feedURL)
60+ if err != nil {
61+ return nil, "", "", fmt.Errorf("parsing feed: %w", err)
62+ }
63+
64+ return result, newEtag, newLastModified, nil
65+}
66+
67+type FeedStore interface {
68+ GetFeedsToFetch(ctx context.Context, limit int) ([]*Feed, error)
69+ UpsertArticle(ctx context.Context, article *Article) (int64, error)
70+ UpdateFeedFetchResult(ctx context.Context, feedURL string, etag, lastModified string, intervalMinutes, consecutiveEmpty, errorCount int, lastError string) error
71+}
72+
73+type feedState struct {
74+ interval time.Duration
75+ consecutiveEmpty int
76+ errorCount int
77+}
78+
79+type Scheduler struct {
80+ fetcher *Fetcher
81+ store FeedStore
82+ logger *slog.Logger
83+ interval time.Duration
84+ states map[string]*feedState
85+}
86+
87+func NewScheduler(store FeedStore, logger *slog.Logger) *Scheduler {
88+ return &Scheduler{
89+ fetcher: NewFetcher(),
90+ store: store,
91+ logger: logger,
92+ interval: 5 * time.Minute,
93+ states: make(map[string]*feedState),
94+ }
95+}
96+
97+func (s *Scheduler) Run(ctx context.Context) error {
98+ ticker := time.NewTicker(s.interval)
99+ defer ticker.Stop()
100+
101+ for {
102+ select {
103+ case <-ctx.Done():
104+ return ctx.Err()
105+ case <-ticker.C:
106+ feeds, err := s.store.GetFeedsToFetch(ctx, 100)
107+ if err != nil {
108+ s.logger.Error("failed to get feeds", "error", err)
109+ continue
110+ }
111+ for _, feed := range feeds {
112+ s.FetchFeed(ctx, feed)
113+ }
114+ }
115+ }
116+}
117+
118+func (s *Scheduler) FetchFeed(ctx context.Context, feed *Feed) {
119+ state, ok := s.states[feed.URL]
120+ if !ok {
121+ state = &feedState{
122+ interval: 30 * time.Minute,
123+ }
124+ s.states[feed.URL] = state
125+ }
126+
127+ result, newEtag, newLastModified, err := s.fetcher.Fetch(ctx, feed.URL, feed.ETag, feed.LastModified)
128+ if err != nil {
129+ state.errorCount++
130+ state.interval *= 2
131+ if state.interval > 24*time.Hour {
132+ state.interval = 24 * time.Hour
133+ }
134+
135+ updErr := s.store.UpdateFeedFetchResult(
136+ ctx, feed.URL,
137+ feed.ETag, feed.LastModified,
138+ int(state.interval.Minutes()), state.consecutiveEmpty, state.errorCount,
139+ err.Error(),
140+ )
141+ if updErr != nil {
142+ s.logger.Error("failed to update feed fetch result", "error", updErr, "feed", feed.URL)
143+ }
144+ return
145+ }
146+
147+ if result == nil {
148+ return
149+ }
150+
151+ newCount := 0
152+ for i := range result.Articles {
153+ result.Articles[i].FeedURL = feed.URL
154+ id, upsertErr := s.store.UpsertArticle(ctx, &result.Articles[i])
155+ if upsertErr != nil {
156+ s.logger.Error("failed to upsert article", "error", upsertErr, "url", result.Articles[i].URL)
157+ continue
158+ }
159+ if id > 0 {
160+ newCount++
161+ }
162+ }
163+
164+ if newCount > 0 {
165+ state.errorCount = 0
166+ state.consecutiveEmpty = 0
167+ state.interval = 30 * time.Minute
168+ } else {
169+ state.consecutiveEmpty++
170+ if state.consecutiveEmpty > 3 {
171+ state.interval *= 2
172+ if state.interval > 6*time.Hour {
173+ state.interval = 6 * time.Hour
174+ }
175+ }
176+ }
177+
178+ updErr := s.store.UpdateFeedFetchResult(
179+ ctx, feed.URL,
180+ newEtag, newLastModified,
181+ int(state.interval.Minutes()), state.consecutiveEmpty, state.errorCount,
182+ "",
183+ )
184+ if updErr != nil {
185+ s.logger.Error("failed to update feed fetch result", "error", updErr, "feed", feed.URL)
186+ }
187+}
new file mode 100644
@@ -0,0 +1,187 @@
1+package feed
2+
3+import (
4+ "context"
5+ "fmt"
6+ "log/slog"
7+ "net/http"
8+ "time"
9+)
10+
11+type Fetcher struct {
12+ httpClient *http.Client
13+}
14+
15+func NewFetcher() *Fetcher {
16+ return &Fetcher{
17+ httpClient: &http.Client{
18+ Timeout: 30 * time.Second,
19+ CheckRedirect: func(req *http.Request, via []*http.Request) error {
20+ if len(via) >= 10 {
21+ return fmt.Errorf("too many redirects")
22+ }
23+ return nil
24+ },
25+ },
26+ }
27+}
28+
29+func (f *Fetcher) Fetch(ctx context.Context, feedURL, etag, lastModified string) (*ParseResult, string, string, error) {
30+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, feedURL, nil)
31+ if err != nil {
32+ return nil, "", "", fmt.Errorf("creating request: %w", err)
33+ }
34+
35+ if etag != "" {
36+ req.Header.Set("If-None-Match", etag)
37+ }
38+ if lastModified != "" {
39+ req.Header.Set("If-Modified-Since", lastModified)
40+ }
41+
42+ resp, err := f.httpClient.Do(req)
43+ if err != nil {
44+ return nil, "", "", fmt.Errorf("fetching feed: %w", err)
45+ }
46+ defer resp.Body.Close()
47+
48+ if resp.StatusCode == http.StatusNotModified {
49+ return nil, "", "", nil
50+ }
51+
52+ if resp.StatusCode != http.StatusOK {
53+ return nil, "", "", fmt.Errorf("unexpected status: %d", resp.StatusCode)
54+ }
55+
56+ newEtag := resp.Header.Get("ETag")
57+ newLastModified := resp.Header.Get("Last-Modified")
58+
59+ result, err := Parse(resp.Body, feedURL)
60+ if err != nil {
61+ return nil, "", "", fmt.Errorf("parsing feed: %w", err)
62+ }
63+
64+ return result, newEtag, newLastModified, nil
65+}
66+
67+type FeedStore interface {
68+ GetFeedsToFetch(ctx context.Context, limit int) ([]*Feed, error)
69+ UpsertArticle(ctx context.Context, article *Article) (int64, error)
70+ UpdateFeedFetchResult(ctx context.Context, feedURL string, etag, lastModified string, intervalMinutes, consecutiveEmpty, errorCount int, lastError string) error
71+}
72+
73+type feedState struct {
74+ interval time.Duration
75+ consecutiveEmpty int
76+ errorCount int
77+}
78+
79+type Scheduler struct {
80+ fetcher *Fetcher
81+ store FeedStore
82+ logger *slog.Logger
83+ interval time.Duration
84+ states map[string]*feedState
85+}
86+
87+func NewScheduler(store FeedStore, logger *slog.Logger) *Scheduler {
88+ return &Scheduler{
89+ fetcher: NewFetcher(),
90+ store: store,
91+ logger: logger,
92+ interval: 5 * time.Minute,
93+ states: make(map[string]*feedState),
94+ }
95+}
96+
97+func (s *Scheduler) Run(ctx context.Context) error {
98+ ticker := time.NewTicker(s.interval)
99+ defer ticker.Stop()
100+
101+ for {
102+ select {
103+ case <-ctx.Done():
104+ return ctx.Err()
105+ case <-ticker.C:
106+ feeds, err := s.store.GetFeedsToFetch(ctx, 100)
107+ if err != nil {
108+ s.logger.Error("failed to get feeds", "error", err)
109+ continue
110+ }
111+ for _, feed := range feeds {
112+ s.FetchFeed(ctx, feed)
113+ }
114+ }
115+ }
116+}
117+
118+func (s *Scheduler) FetchFeed(ctx context.Context, feed *Feed) {
119+ state, ok := s.states[feed.URL]
120+ if !ok {
121+ state = &feedState{
122+ interval: 30 * time.Minute,
123+ }
124+ s.states[feed.URL] = state
125+ }
126+
127+ result, newEtag, newLastModified, err := s.fetcher.Fetch(ctx, feed.URL, feed.ETag, feed.LastModified)
128+ if err != nil {
129+ state.errorCount++
130+ state.interval *= 2
131+ if state.interval > 24*time.Hour {
132+ state.interval = 24 * time.Hour
133+ }
134+
135+ updErr := s.store.UpdateFeedFetchResult(
136+ ctx, feed.URL,
137+ feed.ETag, feed.LastModified,
138+ int(state.interval.Minutes()), state.consecutiveEmpty, state.errorCount,
139+ err.Error(),
140+ )
141+ if updErr != nil {
142+ s.logger.Error("failed to update feed fetch result", "error", updErr, "feed", feed.URL)
143+ }
144+ return
145+ }
146+
147+ if result == nil {
148+ return
149+ }
150+
151+ newCount := 0
152+ for i := range result.Articles {
153+ result.Articles[i].FeedURL = feed.URL
154+ id, upsertErr := s.store.UpsertArticle(ctx, &result.Articles[i])
155+ if upsertErr != nil {
156+ s.logger.Error("failed to upsert article", "error", upsertErr, "url", result.Articles[i].URL)
157+ continue
158+ }
159+ if id > 0 {
160+ newCount++
161+ }
162+ }
163+
164+ if newCount > 0 {
165+ state.errorCount = 0
166+ state.consecutiveEmpty = 0
167+ state.interval = 30 * time.Minute
168+ } else {
169+ state.consecutiveEmpty++
170+ if state.consecutiveEmpty > 3 {
171+ state.interval *= 2
172+ if state.interval > 6*time.Hour {
173+ state.interval = 6 * time.Hour
174+ }
175+ }
176+ }
177+
178+ updErr := s.store.UpdateFeedFetchResult(
179+ ctx, feed.URL,
180+ newEtag, newLastModified,
181+ int(state.interval.Minutes()), state.consecutiveEmpty, state.errorCount,
182+ "",
183+ )
184+ if updErr != nil {
185+ s.logger.Error("failed to update feed fetch result", "error", updErr, "feed", feed.URL)
186+ }
187+}
added internal/feed/opml.go +95 -0
new file mode 100644
@@ -0,0 +1,95 @@
1+package feed
2+
3+import (
4+ "bytes"
5+ "encoding/xml"
6+ "io"
7+)
8+
9+type OPML struct {
10+ XMLName xml.Name `xml:"opml"`
11+ Version string `xml:"version,attr"`
12+ Head struct {
13+ Title string `xml:"title"`
14+ } `xml:"head"`
15+ Body struct {
16+ Outlines []Outline `xml:"outline"`
17+ } `xml:"body"`
18+}
19+
20+type Outline struct {
21+ Text string `xml:"text,attr"`
22+ Title string `xml:"title,attr"`
23+ XMLURL string `xml:"xmlUrl,attr"`
24+ HTMLURL string `xml:"htmlUrl,attr"`
25+ Outlines []Outline `xml:"outline"`
26+}
27+
28+type FeedURL struct {
29+ URL string
30+ Title string
31+ Category string
32+}
33+
34+func ParseOPML(r io.Reader) (*OPML, error) {
35+ data, err := io.ReadAll(r)
36+ if err != nil {
37+ return nil, err
38+ }
39+
40+ var opml OPML
41+ if err := xml.Unmarshal(data, &opml); err != nil {
42+ return nil, err
43+ }
44+
45+ return &opml, nil
46+}
47+
48+func ExtractFeedURLs(opml *OPML) []FeedURL {
49+ var urls []FeedURL
50+ extractOutlines(opml.Body.Outlines, "", &urls)
51+ return urls
52+}
53+
54+func extractOutlines(outlines []Outline, category string, urls *[]FeedURL) {
55+ for _, o := range outlines {
56+ if o.XMLURL != "" {
57+ *urls = append(*urls, FeedURL{
58+ URL: o.XMLURL,
59+ Title: o.Title,
60+ Category: category,
61+ })
62+ }
63+ childCategory := o.Text
64+ if childCategory == "" {
65+ childCategory = category
66+ }
67+ extractOutlines(o.Outlines, childCategory, urls)
68+ }
69+}
70+
71+func GenerateOPML(feeds []FeedURL, title string) ([]byte, error) {
72+ opml := OPML{
73+ Version: "2.0",
74+ }
75+ opml.Head.Title = title
76+
77+ for _, f := range feeds {
78+ opml.Body.Outlines = append(opml.Body.Outlines, Outline{
79+ Text: f.Title,
80+ Title: f.Title,
81+ XMLURL: f.URL,
82+ })
83+ }
84+
85+ var buf bytes.Buffer
86+ buf.WriteString(xml.Header)
87+
88+ enc := xml.NewEncoder(&buf)
89+ enc.Indent("", " ")
90+ if err := enc.Encode(opml); err != nil {
91+ return nil, err
92+ }
93+
94+ return buf.Bytes(), nil
95+}
new file mode 100644
@@ -0,0 +1,95 @@
1+package feed
2+
3+import (
4+ "bytes"
5+ "encoding/xml"
6+ "io"
7+)
8+
9+type OPML struct {
10+ XMLName xml.Name `xml:"opml"`
11+ Version string `xml:"version,attr"`
12+ Head struct {
13+ Title string `xml:"title"`
14+ } `xml:"head"`
15+ Body struct {
16+ Outlines []Outline `xml:"outline"`
17+ } `xml:"body"`
18+}
19+
20+type Outline struct {
21+ Text string `xml:"text,attr"`
22+ Title string `xml:"title,attr"`
23+ XMLURL string `xml:"xmlUrl,attr"`
24+ HTMLURL string `xml:"htmlUrl,attr"`
25+ Outlines []Outline `xml:"outline"`
26+}
27+
28+type FeedURL struct {
29+ URL string
30+ Title string
31+ Category string
32+}
33+
34+func ParseOPML(r io.Reader) (*OPML, error) {
35+ data, err := io.ReadAll(r)
36+ if err != nil {
37+ return nil, err
38+ }
39+
40+ var opml OPML
41+ if err := xml.Unmarshal(data, &opml); err != nil {
42+ return nil, err
43+ }
44+
45+ return &opml, nil
46+}
47+
48+func ExtractFeedURLs(opml *OPML) []FeedURL {
49+ var urls []FeedURL
50+ extractOutlines(opml.Body.Outlines, "", &urls)
51+ return urls
52+}
53+
54+func extractOutlines(outlines []Outline, category string, urls *[]FeedURL) {
55+ for _, o := range outlines {
56+ if o.XMLURL != "" {
57+ *urls = append(*urls, FeedURL{
58+ URL: o.XMLURL,
59+ Title: o.Title,
60+ Category: category,
61+ })
62+ }
63+ childCategory := o.Text
64+ if childCategory == "" {
65+ childCategory = category
66+ }
67+ extractOutlines(o.Outlines, childCategory, urls)
68+ }
69+}
70+
71+func GenerateOPML(feeds []FeedURL, title string) ([]byte, error) {
72+ opml := OPML{
73+ Version: "2.0",
74+ }
75+ opml.Head.Title = title
76+
77+ for _, f := range feeds {
78+ opml.Body.Outlines = append(opml.Body.Outlines, Outline{
79+ Text: f.Title,
80+ Title: f.Title,
81+ XMLURL: f.URL,
82+ })
83+ }
84+
85+ var buf bytes.Buffer
86+ buf.WriteString(xml.Header)
87+
88+ enc := xml.NewEncoder(&buf)
89+ enc.Indent("", " ")
90+ if err := enc.Encode(opml); err != nil {
91+ return nil, err
92+ }
93+
94+ return buf.Bytes(), nil
95+}
added internal/feed/parser.go +270 -0
new file mode 100644
@@ -0,0 +1,270 @@
1+package feed
2+
3+import (
4+ "encoding/json"
5+ "encoding/xml"
6+ "fmt"
7+ "io"
8+ "strings"
9+ "time"
10+)
11+
12+type Feed struct {
13+ URL string
14+ Title string
15+ SiteURL string
16+ Description string
17+ Type string
18+ ETag string
19+ LastModified string
20+}
21+
22+type Article struct {
23+ FeedURL string
24+ GUID string
25+ Title string
26+ URL string
27+ Author string
28+ Content string
29+ Summary string
30+ Published time.Time
31+ Updated time.Time
32+}
33+
34+type ParseResult struct {
35+ Feed Feed
36+ Articles []Article
37+}
38+
39+type rssFeed struct {
40+ XMLName xml.Name `xml:"rss"`
41+ Channel struct {
42+ Title string `xml:"title"`
43+ Link string `xml:"link"`
44+ Description string `xml:"description"`
45+ Items []struct {
46+ Title string `xml:"title"`
47+ Link string `xml:"link"`
48+ GUID string `xml:"guid"`
49+ Description string `xml:"description"`
50+ Content string `xml:"http://purl.org/rss/1.0/modules/content/ encoded"`
51+ Author string `xml:"author"`
52+ PubDate string `xml:"pubDate"`
53+ } `xml:"item"`
54+ } `xml:"channel"`
55+}
56+
57+type atomLink struct {
58+ Href string `xml:"href,attr"`
59+ Rel string `xml:"rel,attr"`
60+}
61+
62+type atomFeed struct {
63+ XMLName xml.Name `xml:"feed"`
64+ Title string `xml:"title"`
65+ Link []atomLink `xml:"link"`
66+ Subtitle string `xml:"subtitle"`
67+ Entry []struct {
68+ Title string `xml:"title"`
69+ Link []atomLink `xml:"link"`
70+ ID string `xml:"id"`
71+ Summary string `xml:"summary"`
72+ Content string `xml:"content"`
73+ Author struct {
74+ Name string `xml:"name"`
75+ } `xml:"author"`
76+ Published string `xml:"published"`
77+ Updated string `xml:"updated"`
78+ } `xml:"entry"`
79+}
80+
81+type jsonFeed struct {
82+ Version string `json:"version"`
83+ Title string `json:"title"`
84+ HomePageURL string `json:"home_page_url"`
85+ Description string `json:"description"`
86+ Items []struct {
87+ ID string `json:"id"`
88+ URL string `json:"url"`
89+ Title string `json:"title"`
90+ ContentHTML string `json:"content_html"`
91+ ContentText string `json:"content_text"`
92+ Summary string `json:"summary"`
93+ Author struct {
94+ Name string `json:"name"`
95+ } `json:"author"`
96+ DatePublished string `json:"date_published"`
97+ DateModified string `json:"date_modified"`
98+ } `json:"items"`
99+}
100+
101+func Parse(r io.Reader, feedURL string) (*ParseResult, error) {
102+ data, err := io.ReadAll(r)
103+ if err != nil {
104+ return nil, fmt.Errorf("reading feed data: %w", err)
105+ }
106+
107+ trimmed := strings.TrimLeft(string(data), " \t\r\n")
108+ if len(trimmed) > 0 && trimmed[0] == '{' {
109+ return parseJSONFeed(data, feedURL)
110+ }
111+
112+ return parseXMLFeed(data, feedURL)
113+}
114+
115+func parseJSONFeed(data []byte, feedURL string) (*ParseResult, error) {
116+ var jf jsonFeed
117+ if err := json.Unmarshal(data, &jf); err != nil {
118+ return nil, fmt.Errorf("parsing JSON feed: %w", err)
119+ }
120+
121+ result := &ParseResult{
122+ Feed: Feed{
123+ URL: feedURL,
124+ Title: jf.Title,
125+ SiteURL: jf.HomePageURL,
126+ Description: jf.Description,
127+ Type: "json",
128+ },
129+ }
130+
131+ for _, item := range jf.Items {
132+ content := item.ContentHTML
133+ if content == "" {
134+ content = item.ContentText
135+ }
136+
137+ article := Article{
138+ GUID: item.ID,
139+ Title: item.Title,
140+ URL: item.URL,
141+ Content: content,
142+ Summary: item.Summary,
143+ Author: item.Author.Name,
144+ Published: parseTime(item.DatePublished),
145+ Updated: parseTime(item.DateModified),
146+ }
147+ if article.GUID == "" {
148+ article.GUID = article.URL
149+ }
150+ result.Articles = append(result.Articles, article)
151+ }
152+
153+ return result, nil
154+}
155+
156+func parseXMLFeed(data []byte, feedURL string) (*ParseResult, error) {
157+ var rss rssFeed
158+ if err := xml.Unmarshal(data, &rss); err == nil {
159+ if rss.XMLName.Local == "rss" {
160+ return convertRSS(&rss, feedURL), nil
161+ }
162+ }
163+
164+ var atom atomFeed
165+ if err := xml.Unmarshal(data, &atom); err == nil {
166+ if atom.XMLName.Local == "feed" {
167+ return convertAtom(&atom, feedURL), nil
168+ }
169+ }
170+
171+ return nil, fmt.Errorf("unable to detect feed format")
172+}
173+
174+func convertRSS(rss *rssFeed, feedURL string) *ParseResult {
175+ result := &ParseResult{
176+ Feed: Feed{
177+ URL: feedURL,
178+ Title: rss.Channel.Title,
179+ SiteURL: rss.Channel.Link,
180+ Description: rss.Channel.Description,
181+ Type: "rss",
182+ },
183+ }
184+
185+ for _, item := range rss.Channel.Items {
186+ article := Article{
187+ GUID: item.GUID,
188+ Title: item.Title,
189+ URL: item.Link,
190+ Content: item.Content,
191+ Summary: item.Description,
192+ Author: item.Author,
193+ Published: parseTime(item.PubDate),
194+ }
195+ if article.GUID == "" {
196+ article.GUID = article.URL
197+ }
198+ result.Articles = append(result.Articles, article)
199+ }
200+
201+ return result
202+}
203+
204+func convertAtom(atom *atomFeed, feedURL string) *ParseResult {
205+ result := &ParseResult{
206+ Feed: Feed{
207+ URL: feedURL,
208+ Title: atom.Title,
209+ SiteURL: pickAtomLink(atom.Link),
210+ Description: atom.Subtitle,
211+ Type: "atom",
212+ },
213+ }
214+
215+ for _, entry := range atom.Entry {
216+ article := Article{
217+ GUID: entry.ID,
218+ Title: entry.Title,
219+ URL: pickAtomLink(entry.Link),
220+ Content: entry.Content,
221+ Summary: entry.Summary,
222+ Author: entry.Author.Name,
223+ Published: parseTime(entry.Published),
224+ Updated: parseTime(entry.Updated),
225+ }
226+ if article.GUID == "" {
227+ article.GUID = article.URL
228+ }
229+ result.Articles = append(result.Articles, article)
230+ }
231+
232+ return result
233+}
234+
235+func pickAtomLink(links []atomLink) string {
236+ for _, l := range links {
237+ if l.Rel == "alternate" || l.Rel == "" {
238+ return l.Href
239+ }
240+ }
241+ if len(links) > 0 {
242+ return links[0].Href
243+ }
244+ return ""
245+}
246+
247+func parseTime(s string) time.Time {
248+ if s == "" {
249+ return time.Time{}
250+ }
251+
252+ formats := []string{
253+ time.RFC3339,
254+ "Mon, 02 Jan 2006 15:04:05 -0700",
255+ "Mon, 02 Jan 2006 15:04:05 MST",
256+ "2006-01-02T15:04:05Z",
257+ "2006-01-02T15:04:05-07:00",
258+ "2006-01-02 15:04:05",
259+ time.RFC1123,
260+ time.RFC1123Z,
261+ }
262+
263+ for _, format := range formats {
264+ if t, err := time.Parse(format, s); err == nil {
265+ return t
266+ }
267+ }
268+
269+ return time.Time{}
270+}
new file mode 100644
@@ -0,0 +1,270 @@
1+package feed
2+
3+import (
4+ "encoding/json"
5+ "encoding/xml"
6+ "fmt"
7+ "io"
8+ "strings"
9+ "time"
10+)
11+
12+type Feed struct {
13+ URL string
14+ Title string
15+ SiteURL string
16+ Description string
17+ Type string
18+ ETag string
19+ LastModified string
20+}
21+
22+type Article struct {
23+ FeedURL string
24+ GUID string
25+ Title string
26+ URL string
27+ Author string
28+ Content string
29+ Summary string
30+ Published time.Time
31+ Updated time.Time
32+}
33+
34+type ParseResult struct {
35+ Feed Feed
36+ Articles []Article
37+}
38+
39+type rssFeed struct {
40+ XMLName xml.Name `xml:"rss"`
41+ Channel struct {
42+ Title string `xml:"title"`
43+ Link string `xml:"link"`
44+ Description string `xml:"description"`
45+ Items []struct {
46+ Title string `xml:"title"`
47+ Link string `xml:"link"`
48+ GUID string `xml:"guid"`
49+ Description string `xml:"description"`
50+ Content string `xml:"http://purl.org/rss/1.0/modules/content/ encoded"`
51+ Author string `xml:"author"`
52+ PubDate string `xml:"pubDate"`
53+ } `xml:"item"`
54+ } `xml:"channel"`
55+}
56+
57+type atomLink struct {
58+ Href string `xml:"href,attr"`
59+ Rel string `xml:"rel,attr"`
60+}
61+
62+type atomFeed struct {
63+ XMLName xml.Name `xml:"feed"`
64+ Title string `xml:"title"`
65+ Link []atomLink `xml:"link"`
66+ Subtitle string `xml:"subtitle"`
67+ Entry []struct {
68+ Title string `xml:"title"`
69+ Link []atomLink `xml:"link"`
70+ ID string `xml:"id"`
71+ Summary string `xml:"summary"`
72+ Content string `xml:"content"`
73+ Author struct {
74+ Name string `xml:"name"`
75+ } `xml:"author"`
76+ Published string `xml:"published"`
77+ Updated string `xml:"updated"`
78+ } `xml:"entry"`
79+}
80+
81+type jsonFeed struct {
82+ Version string `json:"version"`
83+ Title string `json:"title"`
84+ HomePageURL string `json:"home_page_url"`
85+ Description string `json:"description"`
86+ Items []struct {
87+ ID string `json:"id"`
88+ URL string `json:"url"`
89+ Title string `json:"title"`
90+ ContentHTML string `json:"content_html"`
91+ ContentText string `json:"content_text"`
92+ Summary string `json:"summary"`
93+ Author struct {
94+ Name string `json:"name"`
95+ } `json:"author"`
96+ DatePublished string `json:"date_published"`
97+ DateModified string `json:"date_modified"`
98+ } `json:"items"`
99+}
100+
101+func Parse(r io.Reader, feedURL string) (*ParseResult, error) {
102+ data, err := io.ReadAll(r)
103+ if err != nil {
104+ return nil, fmt.Errorf("reading feed data: %w", err)
105+ }
106+
107+ trimmed := strings.TrimLeft(string(data), " \t\r\n")
108+ if len(trimmed) > 0 && trimmed[0] == '{' {
109+ return parseJSONFeed(data, feedURL)
110+ }
111+
112+ return parseXMLFeed(data, feedURL)
113+}
114+
115+func parseJSONFeed(data []byte, feedURL string) (*ParseResult, error) {
116+ var jf jsonFeed
117+ if err := json.Unmarshal(data, &jf); err != nil {
118+ return nil, fmt.Errorf("parsing JSON feed: %w", err)
119+ }
120+
121+ result := &ParseResult{
122+ Feed: Feed{
123+ URL: feedURL,
124+ Title: jf.Title,
125+ SiteURL: jf.HomePageURL,
126+ Description: jf.Description,
127+ Type: "json",
128+ },
129+ }
130+
131+ for _, item := range jf.Items {
132+ content := item.ContentHTML
133+ if content == "" {
134+ content = item.ContentText
135+ }
136+
137+ article := Article{
138+ GUID: item.ID,
139+ Title: item.Title,
140+ URL: item.URL,
141+ Content: content,
142+ Summary: item.Summary,
143+ Author: item.Author.Name,
144+ Published: parseTime(item.DatePublished),
145+ Updated: parseTime(item.DateModified),
146+ }
147+ if article.GUID == "" {
148+ article.GUID = article.URL
149+ }
150+ result.Articles = append(result.Articles, article)
151+ }
152+
153+ return result, nil
154+}
155+
156+func parseXMLFeed(data []byte, feedURL string) (*ParseResult, error) {
157+ var rss rssFeed
158+ if err := xml.Unmarshal(data, &rss); err == nil {
159+ if rss.XMLName.Local == "rss" {
160+ return convertRSS(&rss, feedURL), nil
161+ }
162+ }
163+
164+ var atom atomFeed
165+ if err := xml.Unmarshal(data, &atom); err == nil {
166+ if atom.XMLName.Local == "feed" {
167+ return convertAtom(&atom, feedURL), nil
168+ }
169+ }
170+
171+ return nil, fmt.Errorf("unable to detect feed format")
172+}
173+
174+func convertRSS(rss *rssFeed, feedURL string) *ParseResult {
175+ result := &ParseResult{
176+ Feed: Feed{
177+ URL: feedURL,
178+ Title: rss.Channel.Title,
179+ SiteURL: rss.Channel.Link,
180+ Description: rss.Channel.Description,
181+ Type: "rss",
182+ },
183+ }
184+
185+ for _, item := range rss.Channel.Items {
186+ article := Article{
187+ GUID: item.GUID,
188+ Title: item.Title,
189+ URL: item.Link,
190+ Content: item.Content,
191+ Summary: item.Description,
192+ Author: item.Author,
193+ Published: parseTime(item.PubDate),
194+ }
195+ if article.GUID == "" {
196+ article.GUID = article.URL
197+ }
198+ result.Articles = append(result.Articles, article)
199+ }
200+
201+ return result
202+}
203+
204+func convertAtom(atom *atomFeed, feedURL string) *ParseResult {
205+ result := &ParseResult{
206+ Feed: Feed{
207+ URL: feedURL,
208+ Title: atom.Title,
209+ SiteURL: pickAtomLink(atom.Link),
210+ Description: atom.Subtitle,
211+ Type: "atom",
212+ },
213+ }
214+
215+ for _, entry := range atom.Entry {
216+ article := Article{
217+ GUID: entry.ID,
218+ Title: entry.Title,
219+ URL: pickAtomLink(entry.Link),
220+ Content: entry.Content,
221+ Summary: entry.Summary,
222+ Author: entry.Author.Name,
223+ Published: parseTime(entry.Published),
224+ Updated: parseTime(entry.Updated),
225+ }
226+ if article.GUID == "" {
227+ article.GUID = article.URL
228+ }
229+ result.Articles = append(result.Articles, article)
230+ }
231+
232+ return result
233+}
234+
235+func pickAtomLink(links []atomLink) string {
236+ for _, l := range links {
237+ if l.Rel == "alternate" || l.Rel == "" {
238+ return l.Href
239+ }
240+ }
241+ if len(links) > 0 {
242+ return links[0].Href
243+ }
244+ return ""
245+}
246+
247+func parseTime(s string) time.Time {
248+ if s == "" {
249+ return time.Time{}
250+ }
251+
252+ formats := []string{
253+ time.RFC3339,
254+ "Mon, 02 Jan 2006 15:04:05 -0700",
255+ "Mon, 02 Jan 2006 15:04:05 MST",
256+ "2006-01-02T15:04:05Z",
257+ "2006-01-02T15:04:05-07:00",
258+ "2006-01-02 15:04:05",
259+ time.RFC1123,
260+ time.RFC1123Z,
261+ }
262+
263+ for _, format := range formats {
264+ if t, err := time.Parse(format, s); err == nil {
265+ return t
266+ }
267+ }
268+
269+ return time.Time{}
270+}
added internal/sanitize/sanitize.go +113 -0
new file mode 100644
@@ -0,0 +1,113 @@
1+package sanitize
2+
3+import (
4+ "regexp"
5+ "strings"
6+)
7+
8+var (
9+ tagRe = regexp.MustCompile(`<[^>]+>`)
10+ entityRe = regexp.MustCompile(`&[^;]+;`)
11+ scriptRe = regexp.MustCompile(`(?is)<script[^>]*>.*?</script>`)
12+ objectRe = regexp.MustCompile(`(?is)<object[^>]*>.*?</object>`)
13+ appletRe = regexp.MustCompile(`(?is)<applet[^>]*>.*?</applet>`)
14+ formRe = regexp.MustCompile(`(?is)<form[^>]*>.*?</form>`)
15+ metaRe = regexp.MustCompile(`(?is)<meta[^>]*/?>`)
16+ baseRe = regexp.MustCompile(`(?is)<base[^>]*/?>`)
17+ linkRe = regexp.MustCompile(`(?is)<link[^>]*/?>`)
18+ onAttrRe = regexp.MustCompile(`(?i)\s+on\w+\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]*)`)
19+ jsHrefRe = regexp.MustCompile(`(?i)href\s*=\s*"\s*javascript:[^"]*"`)
20+ jsHrefSRe = regexp.MustCompile(`(?i)href\s*=\s*'\s*javascript:[^']*'`)
21+ styleExprRe = regexp.MustCompile(`(?i)expression\s*\(`)
22+ styleUrlRe = regexp.MustCompile(`(?i)url\s*\(\s*javascript:`)
23+ iframeSrcRe = regexp.MustCompile(`(?is)<iframe[^>]*>.*?</iframe>`)
24+)
25+
26+var (
27+ youtubeLinkRe = regexp.MustCompile(`(?i)<a[^>]+href="https?://(?:www\.)?(?:youtube\.com/watch\?v=|youtu\.be/|youtube\.com/shorts/|m\.youtube\.com/watch\?v=)([a-zA-Z0-9_-]{11})"[^>]*>.*?</a>`)
28+ youtuBeLinkRe = regexp.MustCompile(`(?i)<a[^>]+href="https?://youtu\.be/([a-zA-Z0-9_-]{11})"[^>]*>.*?</a>`)
29+ vimeoLinkRe = regexp.MustCompile(`(?i)<a[^>]+href="https?://(?:www\.)?vimeo\.com/(\d+)"[^>]*>.*?</a>`)
30+)
31+
32+var allowedIframeHosts = []string{
33+ "www.youtube.com",
34+ "youtube.com",
35+ "youtu.be",
36+ "www.youtube-nocookie.com",
37+ "player.vimeo.com",
38+ "vimeo.com",
39+ "open.spotify.com",
40+ "embed.spotify.com",
41+ "w.soundcloud.com",
42+ "bandcamp.com",
43+}
44+
45+func isAllowedIframe(tag string) bool {
46+ lower := strings.ToLower(tag)
47+ for _, host := range allowedIframeHosts {
48+ if strings.Contains(lower, host) {
49+ return true
50+ }
51+ }
52+ return false
53+}
54+
55+func HTML(input string) string {
56+ s := input
57+ s = scriptRe.ReplaceAllString(s, "")
58+
59+ s = convertMediaLinks(s)
60+
61+ s = filterIframes(s)
62+
63+ s = objectRe.ReplaceAllString(s, "")
64+ s = appletRe.ReplaceAllString(s, "")
65+ s = formRe.ReplaceAllString(s, "")
66+ s = metaRe.ReplaceAllString(s, "")
67+ s = baseRe.ReplaceAllString(s, "")
68+ s = linkRe.ReplaceAllString(s, "")
69+ s = onAttrRe.ReplaceAllString(s, "")
70+ s = jsHrefRe.ReplaceAllString(s, `href="#"`)
71+ s = jsHrefSRe.ReplaceAllString(s, `href="#"`)
72+ s = styleExprRe.ReplaceAllString(s, "")
73+ s = styleUrlRe.ReplaceAllString(s, "")
74+ return strings.TrimSpace(s)
75+}
76+
77+func convertMediaLinks(s string) string {
78+ s = youtubeLinkRe.ReplaceAllString(s, `<iframe src="https://www.youtube-nocookie.com/embed/$1" allowfullscreen loading="lazy"></iframe>`)
79+ s = youtuBeLinkRe.ReplaceAllString(s, `<iframe src="https://www.youtube-nocookie.com/embed/$1" allowfullscreen loading="lazy"></iframe>`)
80+ s = vimeoLinkRe.ReplaceAllString(s, `<iframe src="https://player.vimeo.com/video/$1" allowfullscreen loading="lazy"></iframe>`)
81+ return s
82+}
83+
84+func filterIframes(s string) string {
85+ return iframeSrcRe.ReplaceAllStringFunc(s, func(match string) string {
86+ if isAllowedIframe(match) {
87+ return match
88+ }
89+ return ""
90+ })
91+}
92+
93+var htmlEntities = map[string]string{
94+ "&amp;": "&", "&lt;": "<", "&gt;": ">", "&quot;": `"`, "&#39;": "'",
95+ "&apos;": "'", "&nbsp;": " ", "&hellip;": "...", "&mdash;": "—",
96+ "&ndash;": "–", "&laquo;": "«", "&raquo;": "»", "&rsquo;": "'",
97+ "&lsquo;": "'", "&rdquo;": "\"", "&ldquo;": "\"",
98+}
99+
100+func PlainText(input string) string {
101+ s := tagRe.ReplaceAllString(input, " ")
102+ for entity, replacement := range htmlEntities {
103+ s = strings.ReplaceAll(s, entity, replacement)
104+ }
105+ s = entityRe.ReplaceAllStringFunc(s, func(e string) string {
106+ if strings.HasPrefix(e, "&#") {
107+ return ""
108+ }
109+ return e
110+ })
111+ s = strings.Join(strings.Fields(s), " ")
112+ return strings.TrimSpace(s)
113+}
new file mode 100644
@@ -0,0 +1,113 @@
1+package sanitize
2+
3+import (
4+ "regexp"
5+ "strings"
6+)
7+
8+var (
9+ tagRe = regexp.MustCompile(`<[^>]+>`)
10+ entityRe = regexp.MustCompile(`&[^;]+;`)
11+ scriptRe = regexp.MustCompile(`(?is)<script[^>]*>.*?</script>`)
12+ objectRe = regexp.MustCompile(`(?is)<object[^>]*>.*?</object>`)
13+ appletRe = regexp.MustCompile(`(?is)<applet[^>]*>.*?</applet>`)
14+ formRe = regexp.MustCompile(`(?is)<form[^>]*>.*?</form>`)
15+ metaRe = regexp.MustCompile(`(?is)<meta[^>]*/?>`)
16+ baseRe = regexp.MustCompile(`(?is)<base[^>]*/?>`)
17+ linkRe = regexp.MustCompile(`(?is)<link[^>]*/?>`)
18+ onAttrRe = regexp.MustCompile(`(?i)\s+on\w+\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]*)`)
19+ jsHrefRe = regexp.MustCompile(`(?i)href\s*=\s*"\s*javascript:[^"]*"`)
20+ jsHrefSRe = regexp.MustCompile(`(?i)href\s*=\s*'\s*javascript:[^']*'`)
21+ styleExprRe = regexp.MustCompile(`(?i)expression\s*\(`)
22+ styleUrlRe = regexp.MustCompile(`(?i)url\s*\(\s*javascript:`)
23+ iframeSrcRe = regexp.MustCompile(`(?is)<iframe[^>]*>.*?</iframe>`)
24+)
25+
26+var (
27+ youtubeLinkRe = regexp.MustCompile(`(?i)<a[^>]+href="https?://(?:www\.)?(?:youtube\.com/watch\?v=|youtu\.be/|youtube\.com/shorts/|m\.youtube\.com/watch\?v=)([a-zA-Z0-9_-]{11})"[^>]*>.*?</a>`)
28+ youtuBeLinkRe = regexp.MustCompile(`(?i)<a[^>]+href="https?://youtu\.be/([a-zA-Z0-9_-]{11})"[^>]*>.*?</a>`)
29+ vimeoLinkRe = regexp.MustCompile(`(?i)<a[^>]+href="https?://(?:www\.)?vimeo\.com/(\d+)"[^>]*>.*?</a>`)
30+)
31+
32+var allowedIframeHosts = []string{
33+ "www.youtube.com",
34+ "youtube.com",
35+ "youtu.be",
36+ "www.youtube-nocookie.com",
37+ "player.vimeo.com",
38+ "vimeo.com",
39+ "open.spotify.com",
40+ "embed.spotify.com",
41+ "w.soundcloud.com",
42+ "bandcamp.com",
43+}
44+
45+func isAllowedIframe(tag string) bool {
46+ lower := strings.ToLower(tag)
47+ for _, host := range allowedIframeHosts {
48+ if strings.Contains(lower, host) {
49+ return true
50+ }
51+ }
52+ return false
53+}
54+
55+func HTML(input string) string {
56+ s := input
57+ s = scriptRe.ReplaceAllString(s, "")
58+
59+ s = convertMediaLinks(s)
60+
61+ s = filterIframes(s)
62+
63+ s = objectRe.ReplaceAllString(s, "")
64+ s = appletRe.ReplaceAllString(s, "")
65+ s = formRe.ReplaceAllString(s, "")
66+ s = metaRe.ReplaceAllString(s, "")
67+ s = baseRe.ReplaceAllString(s, "")
68+ s = linkRe.ReplaceAllString(s, "")
69+ s = onAttrRe.ReplaceAllString(s, "")
70+ s = jsHrefRe.ReplaceAllString(s, `href="#"`)
71+ s = jsHrefSRe.ReplaceAllString(s, `href="#"`)
72+ s = styleExprRe.ReplaceAllString(s, "")
73+ s = styleUrlRe.ReplaceAllString(s, "")
74+ return strings.TrimSpace(s)
75+}
76+
77+func convertMediaLinks(s string) string {
78+ s = youtubeLinkRe.ReplaceAllString(s, `<iframe src="https://www.youtube-nocookie.com/embed/$1" allowfullscreen loading="lazy"></iframe>`)
79+ s = youtuBeLinkRe.ReplaceAllString(s, `<iframe src="https://www.youtube-nocookie.com/embed/$1" allowfullscreen loading="lazy"></iframe>`)
80+ s = vimeoLinkRe.ReplaceAllString(s, `<iframe src="https://player.vimeo.com/video/$1" allowfullscreen loading="lazy"></iframe>`)
81+ return s
82+}
83+
84+func filterIframes(s string) string {
85+ return iframeSrcRe.ReplaceAllStringFunc(s, func(match string) string {
86+ if isAllowedIframe(match) {
87+ return match
88+ }
89+ return ""
90+ })
91+}
92+
93+var htmlEntities = map[string]string{
94+ "&amp;": "&", "&lt;": "<", "&gt;": ">", "&quot;": `"`, "&#39;": "'",
95+ "&apos;": "'", "&nbsp;": " ", "&hellip;": "...", "&mdash;": "—",
96+ "&ndash;": "–", "&laquo;": "«", "&raquo;": "»", "&rsquo;": "'",
97+ "&lsquo;": "'", "&rdquo;": "\"", "&ldquo;": "\"",
98+}
99+
100+func PlainText(input string) string {
101+ s := tagRe.ReplaceAllString(input, " ")
102+ for entity, replacement := range htmlEntities {
103+ s = strings.ReplaceAll(s, entity, replacement)
104+ }
105+ s = entityRe.ReplaceAllStringFunc(s, func(e string) string {
106+ if strings.HasPrefix(e, "&#") {
107+ return ""
108+ }
109+ return e
110+ })
111+ s = strings.Join(strings.Fields(s), " ")
112+ return strings.TrimSpace(s)
113+}
added internal/sanitize/sanitize_test.go +208 -0
new file mode 100644
@@ -0,0 +1,208 @@
1+package sanitize
2+
3+import (
4+ "strings"
5+ "testing"
6+)
7+
8+func TestHTML_RemovesScriptTags(t *testing.T) {
9+ input := `<p>Hello</p><script>alert('xss')</script><p>World</p>`
10+ got := HTML(input)
11+ if strings.Contains(got, "<script") {
12+ t.Fatalf("script tag not removed: %s", got)
13+ }
14+ if !strings.Contains(got, "Hello") || !strings.Contains(got, "World") {
15+ t.Fatalf("content removed: %s", got)
16+ }
17+}
18+
19+func TestHTML_RemovesEvilIframeTags(t *testing.T) {
20+ input := `<p>Hello</p><iframe src="evil.com"></iframe>`
21+ got := HTML(input)
22+ if strings.Contains(got, "<iframe") {
23+ t.Fatalf("iframe tag not removed: %s", got)
24+ }
25+}
26+
27+func TestHTML_PreservesYouTubeIframe(t *testing.T) {
28+ input := `<iframe src="https://www.youtube.com/embed/dQw4w9WgXcQ" width="560" height="315"></iframe>`
29+ got := HTML(input)
30+ if !strings.Contains(got, "<iframe") {
31+ t.Fatalf("youtube iframe removed: %s", got)
32+ }
33+ if !strings.Contains(got, "youtube.com") {
34+ t.Fatalf("youtube iframe src lost: %s", got)
35+ }
36+}
37+
38+func TestHTML_PreservesVimeoIframe(t *testing.T) {
39+ input := `<iframe src="https://player.vimeo.com/video/12345" width="640" height="360"></iframe>`
40+ got := HTML(input)
41+ if !strings.Contains(got, "<iframe") {
42+ t.Fatalf("vimeo iframe removed: %s", got)
43+ }
44+}
45+
46+func TestHTML_PreservesSpotifyIframe(t *testing.T) {
47+ input := `<iframe src="https://open.spotify.com/embed/track/abc123" width="300" height="80"></iframe>`
48+ got := HTML(input)
49+ if !strings.Contains(got, "<iframe") {
50+ t.Fatalf("spotify iframe removed: %s", got)
51+ }
52+}
53+
54+func TestHTML_RemovesEventHandler(t *testing.T) {
55+ input := `<div onclick="alert('xss')">Hello</div>`
56+ got := HTML(input)
57+ if strings.Contains(got, "onclick") {
58+ t.Fatalf("onclick handler not removed: %s", got)
59+ }
60+ if !strings.Contains(got, "Hello") {
61+ t.Fatalf("content removed: %s", got)
62+ }
63+}
64+
65+func TestHTML_RemovesJavascriptHref(t *testing.T) {
66+ input := `<a href="javascript:alert('xss')">click</a>`
67+ got := HTML(input)
68+ if strings.Contains(got, "javascript:") {
69+ t.Fatalf("javascript: href not removed: %s", got)
70+ }
71+}
72+
73+func TestHTML_RemovesObjectTags(t *testing.T) {
74+ input := `<object data="evil.swf"></object>`
75+ got := HTML(input)
76+ if strings.Contains(got, "<object") {
77+ t.Fatalf("object tag not removed: %s", got)
78+ }
79+}
80+
81+func TestHTML_RemovesFormTags(t *testing.T) {
82+ input := `<form action="evil.com"><input type="submit"></form>`
83+ got := HTML(input)
84+ if strings.Contains(got, "<form") {
85+ t.Fatalf("form tag not removed: %s", got)
86+ }
87+}
88+
89+func TestHTML_RemovesMetaTags(t *testing.T) {
90+ input := `<meta http-equiv="refresh" content="0;url=evil.com">`
91+ got := HTML(input)
92+ if strings.Contains(got, "<meta") {
93+ t.Fatalf("meta tag not removed: %s", got)
94+ }
95+}
96+
97+func TestHTML_RemovesBaseTags(t *testing.T) {
98+ input := `<base href="evil.com">`
99+ got := HTML(input)
100+ if strings.Contains(got, "<base") {
101+ t.Fatalf("base tag not removed: %s", got)
102+ }
103+}
104+
105+func TestHTML_RemovesStyleExpression(t *testing.T) {
106+ input := `<div style="background: expression(alert('xss'))">Hello</div>`
107+ got := HTML(input)
108+ if strings.Contains(got, "expression") {
109+ t.Fatalf("expression not removed: %s", got)
110+ }
111+}
112+
113+func TestHTML_PreservesSafeContent(t *testing.T) {
114+ input := `<h1>Title</h1><p>Paragraph with <strong>bold</strong> and <em>italic</em>.</p><ul><li>item</li></ul>`
115+ got := HTML(input)
116+ if got != input {
117+ t.Fatalf("safe content modified:\ngot: %s\nwant: %s", got, input)
118+ }
119+}
120+
121+func TestHTML_PreservesImages(t *testing.T) {
122+ input := `<img src="photo.jpg" alt="photo">`
123+ got := HTML(input)
124+ if got != input {
125+ t.Fatalf("img tag modified: %s", got)
126+ }
127+}
128+
129+func TestHTML_PreservesLinks(t *testing.T) {
130+ input := `<a href="https://example.com">link</a>`
131+ got := HTML(input)
132+ if got != input {
133+ t.Fatalf("link modified: %s", got)
134+ }
135+}
136+
137+func TestHTML_HandlesCaseInsensitiveScript(t *testing.T) {
138+ input := `<SCRIPT>alert('xss')</SCRIPT>`
139+ got := HTML(input)
140+ if strings.Contains(got, "<SCRIPT") {
141+ t.Fatalf("case-insensitive script not removed: %s", got)
142+ }
143+}
144+
145+func TestHTML_HandlesMultilineScript(t *testing.T) {
146+ input := "<script>\nalert('xss');\n</script>"
147+ got := HTML(input)
148+ if strings.Contains(got, "<script") {
149+ t.Fatalf("multiline script not removed: %s", got)
150+ }
151+}
152+
153+func TestHTML_RemovesOnEventHandlers(t *testing.T) {
154+ cases := []string{
155+ `<div onmouseover="alert(1)">`,
156+ `<img onerror="alert(1)" src="x">`,
157+ `<body onload="alert(1)">`,
158+ }
159+ for _, input := range cases {
160+ got := HTML(input)
161+ if strings.Contains(got, " on") {
162+ t.Fatalf("event handler not removed from %q: %s", input, got)
163+ }
164+ }
165+}
166+
167+func TestHTML_ConvertsYouTubeLink(t *testing.T) {
168+ input := `<p>Check this out:</p><a href="https://www.youtube.com/watch?v=dQw4w9WgXcQ">Watch on YouTube</a>`
169+ got := HTML(input)
170+ if !strings.Contains(got, `<iframe src="https://www.youtube-nocookie.com/embed/dQw4w9WgXcQ"`) {
171+ t.Fatalf("youtube link not converted to iframe: %s", got)
172+ }
173+ if strings.Contains(got, "<a href") && strings.Contains(got, "youtube.com/watch") {
174+ t.Fatalf("original youtube link not replaced: %s", got)
175+ }
176+}
177+
178+func TestHTML_ConvertsYoutuBeLink(t *testing.T) {
179+ input := `<a href="https://youtu.be/dQw4w9WgXcQ">Watch</a>`
180+ got := HTML(input)
181+ if !strings.Contains(got, `<iframe src="https://www.youtube-nocookie.com/embed/dQw4w9WgXcQ"`) {
182+ t.Fatalf("youtu.be link not converted to iframe: %s", got)
183+ }
184+}
185+
186+func TestHTML_ConvertsYouTubeShortsLink(t *testing.T) {
187+ input := `<a href="https://www.youtube.com/shorts/abc12345678">Short</a>`
188+ got := HTML(input)
189+ if !strings.Contains(got, `<iframe src="https://www.youtube-nocookie.com/embed/abc12345678"`) {
190+ t.Fatalf("youtube shorts link not converted to iframe: %s", got)
191+ }
192+}
193+
194+func TestHTML_ConvertsVimeoLink(t *testing.T) {
195+ input := `<a href="https://vimeo.com/123456789">Watch on Vimeo</a>`
196+ got := HTML(input)
197+ if !strings.Contains(got, `<iframe src="https://player.vimeo.com/video/123456789"`) {
198+ t.Fatalf("vimeo link not converted to iframe: %s", got)
199+ }
200+}
201+
202+func TestHTML_PreservesNonMediaLinks(t *testing.T) {
203+ input := `<a href="https://example.com/article">Read more</a>`
204+ got := HTML(input)
205+ if !strings.Contains(got, `<a href="https://example.com/article">Read more</a>`) {
206+ t.Fatalf("non-media link was modified: %s", got)
207+ }
208+}
new file mode 100644
@@ -0,0 +1,208 @@
1+package sanitize
2+
3+import (
4+ "strings"
5+ "testing"
6+)
7+
8+func TestHTML_RemovesScriptTags(t *testing.T) {
9+ input := `<p>Hello</p><script>alert('xss')</script><p>World</p>`
10+ got := HTML(input)
11+ if strings.Contains(got, "<script") {
12+ t.Fatalf("script tag not removed: %s", got)
13+ }
14+ if !strings.Contains(got, "Hello") || !strings.Contains(got, "World") {
15+ t.Fatalf("content removed: %s", got)
16+ }
17+}
18+
19+func TestHTML_RemovesEvilIframeTags(t *testing.T) {
20+ input := `<p>Hello</p><iframe src="evil.com"></iframe>`
21+ got := HTML(input)
22+ if strings.Contains(got, "<iframe") {
23+ t.Fatalf("iframe tag not removed: %s", got)
24+ }
25+}
26+
27+func TestHTML_PreservesYouTubeIframe(t *testing.T) {
28+ input := `<iframe src="https://www.youtube.com/embed/dQw4w9WgXcQ" width="560" height="315"></iframe>`
29+ got := HTML(input)
30+ if !strings.Contains(got, "<iframe") {
31+ t.Fatalf("youtube iframe removed: %s", got)
32+ }
33+ if !strings.Contains(got, "youtube.com") {
34+ t.Fatalf("youtube iframe src lost: %s", got)
35+ }
36+}
37+
38+func TestHTML_PreservesVimeoIframe(t *testing.T) {
39+ input := `<iframe src="https://player.vimeo.com/video/12345" width="640" height="360"></iframe>`
40+ got := HTML(input)
41+ if !strings.Contains(got, "<iframe") {
42+ t.Fatalf("vimeo iframe removed: %s", got)
43+ }
44+}
45+
46+func TestHTML_PreservesSpotifyIframe(t *testing.T) {
47+ input := `<iframe src="https://open.spotify.com/embed/track/abc123" width="300" height="80"></iframe>`
48+ got := HTML(input)
49+ if !strings.Contains(got, "<iframe") {
50+ t.Fatalf("spotify iframe removed: %s", got)
51+ }
52+}
53+
54+func TestHTML_RemovesEventHandler(t *testing.T) {
55+ input := `<div onclick="alert('xss')">Hello</div>`
56+ got := HTML(input)
57+ if strings.Contains(got, "onclick") {
58+ t.Fatalf("onclick handler not removed: %s", got)
59+ }
60+ if !strings.Contains(got, "Hello") {
61+ t.Fatalf("content removed: %s", got)
62+ }
63+}
64+
65+func TestHTML_RemovesJavascriptHref(t *testing.T) {
66+ input := `<a href="javascript:alert('xss')">click</a>`
67+ got := HTML(input)
68+ if strings.Contains(got, "javascript:") {
69+ t.Fatalf("javascript: href not removed: %s", got)
70+ }
71+}
72+
73+func TestHTML_RemovesObjectTags(t *testing.T) {
74+ input := `<object data="evil.swf"></object>`
75+ got := HTML(input)
76+ if strings.Contains(got, "<object") {
77+ t.Fatalf("object tag not removed: %s", got)
78+ }
79+}
80+
81+func TestHTML_RemovesFormTags(t *testing.T) {
82+ input := `<form action="evil.com"><input type="submit"></form>`
83+ got := HTML(input)
84+ if strings.Contains(got, "<form") {
85+ t.Fatalf("form tag not removed: %s", got)
86+ }
87+}
88+
89+func TestHTML_RemovesMetaTags(t *testing.T) {
90+ input := `<meta http-equiv="refresh" content="0;url=evil.com">`
91+ got := HTML(input)
92+ if strings.Contains(got, "<meta") {
93+ t.Fatalf("meta tag not removed: %s", got)
94+ }
95+}
96+
97+func TestHTML_RemovesBaseTags(t *testing.T) {
98+ input := `<base href="evil.com">`
99+ got := HTML(input)
100+ if strings.Contains(got, "<base") {
101+ t.Fatalf("base tag not removed: %s", got)
102+ }
103+}
104+
105+func TestHTML_RemovesStyleExpression(t *testing.T) {
106+ input := `<div style="background: expression(alert('xss'))">Hello</div>`
107+ got := HTML(input)
108+ if strings.Contains(got, "expression") {
109+ t.Fatalf("expression not removed: %s", got)
110+ }
111+}
112+
113+func TestHTML_PreservesSafeContent(t *testing.T) {
114+ input := `<h1>Title</h1><p>Paragraph with <strong>bold</strong> and <em>italic</em>.</p><ul><li>item</li></ul>`
115+ got := HTML(input)
116+ if got != input {
117+ t.Fatalf("safe content modified:\ngot: %s\nwant: %s", got, input)
118+ }
119+}
120+
121+func TestHTML_PreservesImages(t *testing.T) {
122+ input := `<img src="photo.jpg" alt="photo">`
123+ got := HTML(input)
124+ if got != input {
125+ t.Fatalf("img tag modified: %s", got)
126+ }
127+}
128+
129+func TestHTML_PreservesLinks(t *testing.T) {
130+ input := `<a href="https://example.com">link</a>`
131+ got := HTML(input)
132+ if got != input {
133+ t.Fatalf("link modified: %s", got)
134+ }
135+}
136+
137+func TestHTML_HandlesCaseInsensitiveScript(t *testing.T) {
138+ input := `<SCRIPT>alert('xss')</SCRIPT>`
139+ got := HTML(input)
140+ if strings.Contains(got, "<SCRIPT") {
141+ t.Fatalf("case-insensitive script not removed: %s", got)
142+ }
143+}
144+
145+func TestHTML_HandlesMultilineScript(t *testing.T) {
146+ input := "<script>\nalert('xss');\n</script>"
147+ got := HTML(input)
148+ if strings.Contains(got, "<script") {
149+ t.Fatalf("multiline script not removed: %s", got)
150+ }
151+}
152+
153+func TestHTML_RemovesOnEventHandlers(t *testing.T) {
154+ cases := []string{
155+ `<div onmouseover="alert(1)">`,
156+ `<img onerror="alert(1)" src="x">`,
157+ `<body onload="alert(1)">`,
158+ }
159+ for _, input := range cases {
160+ got := HTML(input)
161+ if strings.Contains(got, " on") {
162+ t.Fatalf("event handler not removed from %q: %s", input, got)
163+ }
164+ }
165+}
166+
167+func TestHTML_ConvertsYouTubeLink(t *testing.T) {
168+ input := `<p>Check this out:</p><a href="https://www.youtube.com/watch?v=dQw4w9WgXcQ">Watch on YouTube</a>`
169+ got := HTML(input)
170+ if !strings.Contains(got, `<iframe src="https://www.youtube-nocookie.com/embed/dQw4w9WgXcQ"`) {
171+ t.Fatalf("youtube link not converted to iframe: %s", got)
172+ }
173+ if strings.Contains(got, "<a href") && strings.Contains(got, "youtube.com/watch") {
174+ t.Fatalf("original youtube link not replaced: %s", got)
175+ }
176+}
177+
178+func TestHTML_ConvertsYoutuBeLink(t *testing.T) {
179+ input := `<a href="https://youtu.be/dQw4w9WgXcQ">Watch</a>`
180+ got := HTML(input)
181+ if !strings.Contains(got, `<iframe src="https://www.youtube-nocookie.com/embed/dQw4w9WgXcQ"`) {
182+ t.Fatalf("youtu.be link not converted to iframe: %s", got)
183+ }
184+}
185+
186+func TestHTML_ConvertsYouTubeShortsLink(t *testing.T) {
187+ input := `<a href="https://www.youtube.com/shorts/abc12345678">Short</a>`
188+ got := HTML(input)
189+ if !strings.Contains(got, `<iframe src="https://www.youtube-nocookie.com/embed/abc12345678"`) {
190+ t.Fatalf("youtube shorts link not converted to iframe: %s", got)
191+ }
192+}
193+
194+func TestHTML_ConvertsVimeoLink(t *testing.T) {
195+ input := `<a href="https://vimeo.com/123456789">Watch on Vimeo</a>`
196+ got := HTML(input)
197+ if !strings.Contains(got, `<iframe src="https://player.vimeo.com/video/123456789"`) {
198+ t.Fatalf("vimeo link not converted to iframe: %s", got)
199+ }
200+}
201+
202+func TestHTML_PreservesNonMediaLinks(t *testing.T) {
203+ input := `<a href="https://example.com/article">Read more</a>`
204+ got := HTML(input)
205+ if !strings.Contains(got, `<a href="https://example.com/article">Read more</a>`) {
206+ t.Fatalf("non-media link was modified: %s", got)
207+ }
208+}
added internal/server/annotations_handler.go +63 -0
new file mode 100644
@@ -0,0 +1,63 @@
1+package server
2+
3+import (
4+ "database/sql"
5+ "fmt"
6+ "net/http"
7+ "time"
8+
9+ "pkg.rbrt.fr/glean/internal/atproto"
10+ "pkg.rbrt.fr/glean/internal/db"
11+)
12+
13+func (s *Server) handleAnnotations(w http.ResponseWriter, r *http.Request) {
14+ user := currentUser(r)
15+ articleURL := r.URL.Query().Get("article")
16+ annotations, _ := s.db.ListAnnotations(r.Context(), "", articleURL, "", 50, 0)
17+ s.render(w, r, "annotations.html", map[string]any{
18+ "User": user,
19+ "Annotations": annotations,
20+ "ArticleURL": articleURL,
21+ })
22+}
23+
24+func (s *Server) handleCreateAnnotation(w http.ResponseWriter, r *http.Request) {
25+ user := currentUser(r)
26+ a := &db.Annotation{
27+ URI: fmt.Sprintf("glean:annotation:%d", time.Now().UnixNano()),
28+ AuthorDID: user.DID,
29+ FeedURL: r.FormValue("feed_url"),
30+ ArticleURL: r.FormValue("article_url"),
31+ Quote: sql.NullString{String: r.FormValue("quote"), Valid: r.FormValue("quote") != ""},
32+ Note: sql.NullString{String: r.FormValue("note"), Valid: r.FormValue("note") != ""},
33+ Tags: sql.NullString{String: r.FormValue("tags"), Valid: r.FormValue("tags") != ""},
34+ CreatedAt: sql.NullTime{Time: time.Now(), Valid: true},
35+ }
36+ if rv := r.FormValue("rating"); rv != "" {
37+ var n int64
38+ if _, err := fmt.Sscanf(rv, "%d", &n); err == nil {
39+ a.Rating = sql.NullInt64{Int64: n, Valid: true}
40+ }
41+ }
42+
43+ if err := s.db.CreateAnnotation(r.Context(), a); err != nil {
44+ http.Error(w, err.Error(), http.StatusInternalServerError)
45+ return
46+ }
47+
48+ if client := s.pdsClientForUser(r); client != nil {
49+ record := atproto.AnnotationRecord{
50+ CreatedAt: time.Now().Format(time.RFC3339),
51+ FeedURL: a.FeedURL,
52+ ArticleURL: a.ArticleURL,
53+ Quote: a.Quote.String,
54+ Note: a.Note.String,
55+ Rating: int(a.Rating.Int64),
56+ }
57+ if _, _, err := client.CreateRecord(r.Context(), user.DID, "at.glean.annotation", record); err != nil {
58+ s.logger.Warn("failed to write annotation to PDS", "error", err)
59+ }
60+ }
61+
62+ w.WriteHeader(http.StatusNoContent)
63+}
new file mode 100644
@@ -0,0 +1,63 @@
1+package server
2+
3+import (
4+ "database/sql"
5+ "fmt"
6+ "net/http"
7+ "time"
8+
9+ "pkg.rbrt.fr/glean/internal/atproto"
10+ "pkg.rbrt.fr/glean/internal/db"
11+)
12+
13+func (s *Server) handleAnnotations(w http.ResponseWriter, r *http.Request) {
14+ user := currentUser(r)
15+ articleURL := r.URL.Query().Get("article")
16+ annotations, _ := s.db.ListAnnotations(r.Context(), "", articleURL, "", 50, 0)
17+ s.render(w, r, "annotations.html", map[string]any{
18+ "User": user,
19+ "Annotations": annotations,
20+ "ArticleURL": articleURL,
21+ })
22+}
23+
24+func (s *Server) handleCreateAnnotation(w http.ResponseWriter, r *http.Request) {
25+ user := currentUser(r)
26+ a := &db.Annotation{
27+ URI: fmt.Sprintf("glean:annotation:%d", time.Now().UnixNano()),
28+ AuthorDID: user.DID,
29+ FeedURL: r.FormValue("feed_url"),
30+ ArticleURL: r.FormValue("article_url"),
31+ Quote: sql.NullString{String: r.FormValue("quote"), Valid: r.FormValue("quote") != ""},
32+ Note: sql.NullString{String: r.FormValue("note"), Valid: r.FormValue("note") != ""},
33+ Tags: sql.NullString{String: r.FormValue("tags"), Valid: r.FormValue("tags") != ""},
34+ CreatedAt: sql.NullTime{Time: time.Now(), Valid: true},
35+ }
36+ if rv := r.FormValue("rating"); rv != "" {
37+ var n int64
38+ if _, err := fmt.Sscanf(rv, "%d", &n); err == nil {
39+ a.Rating = sql.NullInt64{Int64: n, Valid: true}
40+ }
41+ }
42+
43+ if err := s.db.CreateAnnotation(r.Context(), a); err != nil {
44+ http.Error(w, err.Error(), http.StatusInternalServerError)
45+ return
46+ }
47+
48+ if client := s.pdsClientForUser(r); client != nil {
49+ record := atproto.AnnotationRecord{
50+ CreatedAt: time.Now().Format(time.RFC3339),
51+ FeedURL: a.FeedURL,
52+ ArticleURL: a.ArticleURL,
53+ Quote: a.Quote.String,
54+ Note: a.Note.String,
55+ Rating: int(a.Rating.Int64),
56+ }
57+ if _, _, err := client.CreateRecord(r.Context(), user.DID, "at.glean.annotation", record); err != nil {
58+ s.logger.Warn("failed to write annotation to PDS", "error", err)
59+ }
60+ }
61+
62+ w.WriteHeader(http.StatusNoContent)
63+}
added internal/server/articles_handler.go +250 -0
new file mode 100644
@@ -0,0 +1,250 @@
1+package server
2+
3+import (
4+ "database/sql"
5+ "fmt"
6+ "net/http"
7+ "strconv"
8+ "time"
9+
10+ "github.com/go-chi/chi/v5"
11+
12+ "pkg.rbrt.fr/glean/internal/atproto"
13+ "pkg.rbrt.fr/glean/internal/db"
14+)
15+
16+func writeStarButton(w http.ResponseWriter, articleID int64, starred bool) {
17+ cls := "text-gray-300 hover:text-yellow-500"
18+ ch := "&#9734;"
19+ if starred {
20+ cls = "text-yellow-500"
21+ ch = "&#9733;"
22+ }
23+ w.Header().Set("Content-Type", "text/html")
24+ _, _ = fmt.Fprintf(w, `<button hx-post="/articles/%d/star" hx-target="#star-btn" hx-swap="outerHTML" id="star-btn" class="text-lg %s">%s</button>`, articleID, cls, ch)
25+}
26+
27+func writeLikeButton(w http.ResponseWriter, articleID int64, liked bool, count int) {
28+ cls := "text-gray-300 hover:text-red-500"
29+ if liked {
30+ cls = "text-red-500"
31+ }
32+ w.Header().Set("Content-Type", "text/html")
33+ _, _ = fmt.Fprintf(w, `<button hx-post="/articles/%d/like" hx-target="#like-btn" hx-swap="outerHTML" id="like-btn" class="text-lg %s">&#9829; <span class="text-sm text-gray-600">%d</span></button>`, articleID, cls, count)
34+}
35+
36+func writeReadButton(w http.ResponseWriter, articleID int64, isRead bool) {
37+ label := "Mark read"
38+ action := "read"
39+ if isRead {
40+ label = "Mark unread"
41+ action = "unread"
42+ }
43+ w.Header().Set("Content-Type", "text/html")
44+ _, _ = fmt.Fprintf(w, `<button hx-post="/articles/%d/%s" hx-target="#read-btn" hx-swap="outerHTML" id="read-btn" class="text-xs border border-gray-300 rounded px-2 py-1 hover:bg-gray-50">%s</button>`, articleID, action, label)
45+}
46+
47+func (s *Server) handleArticles(w http.ResponseWriter, r *http.Request) {
48+ user := currentUser(r)
49+ feedURL := r.URL.Query().Get("feed")
50+
51+ articles, err := s.db.ListArticles(r.Context(), user.DID, feedURL, 50, 0)
52+ if err != nil {
53+ s.logger.Error("failed to list articles", "error", err)
54+ http.Error(w, err.Error(), http.StatusInternalServerError)
55+ return
56+ }
57+
58+ s.render(w, r, "articles.html", map[string]any{
59+ "User": user,
60+ "Articles": articles,
61+ "FeedURL": feedURL,
62+ })
63+}
64+
65+func (s *Server) handleArticleDetail(w http.ResponseWriter, r *http.Request) {
66+ user := currentUser(r)
67+ id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
68+ if err != nil {
69+ http.Error(w, "invalid id", http.StatusBadRequest)
70+ return
71+ }
72+
73+ article, err := s.db.GetArticle(r.Context(), id)
74+ if err != nil {
75+ http.Error(w, "article not found", http.StatusNotFound)
76+ return
77+ }
78+
79+ _ = s.db.MarkArticleRead(r.Context(), user.DID, id)
80+
81+ readState, _ := s.db.GetReadState(r.Context(), user.DID, id)
82+
83+ likeCount, _ := s.db.GetLikeCount(r.Context(), article.FeedURL, article.URL.String)
84+ liked := false
85+ if article.URL.Valid {
86+ liked, _ = s.db.HasLiked(r.Context(), user.DID, article.FeedURL, article.URL.String)
87+ }
88+ annotations, _ := s.db.ListAnnotations(r.Context(), "", article.URL.String, "", 20, 0)
89+ feed, _ := s.db.GetFeed(r.Context(), article.FeedURL)
90+
91+ s.render(w, r, "article_detail.html", map[string]any{
92+ "User": user,
93+ "Article": article,
94+ "Feed": feed,
95+ "ReadState": readState,
96+ "LikeCount": likeCount,
97+ "HasLiked": liked,
98+ "Annotations": annotations,
99+ })
100+}
101+
102+func (s *Server) handleMarkRead(w http.ResponseWriter, r *http.Request) {
103+ user := currentUser(r)
104+ id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
105+ if err != nil {
106+ http.Error(w, "invalid id", http.StatusBadRequest)
107+ return
108+ }
109+ if err := s.db.MarkArticleRead(r.Context(), user.DID, id); err != nil {
110+ http.Error(w, err.Error(), http.StatusInternalServerError)
111+ return
112+ }
113+ writeReadButton(w, id, true)
114+}
115+
116+func (s *Server) handleMarkUnread(w http.ResponseWriter, r *http.Request) {
117+ user := currentUser(r)
118+ id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
119+ if err != nil {
120+ http.Error(w, "invalid id", http.StatusBadRequest)
121+ return
122+ }
123+ if err := s.db.MarkArticleUnread(r.Context(), user.DID, id); err != nil {
124+ http.Error(w, err.Error(), http.StatusInternalServerError)
125+ return
126+ }
127+ writeReadButton(w, id, false)
128+}
129+
130+func (s *Server) handleStar(w http.ResponseWriter, r *http.Request) {
131+ user := currentUser(r)
132+ id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
133+ if err != nil {
134+ http.Error(w, "invalid id", http.StatusBadRequest)
135+ return
136+ }
137+ rs, _ := s.db.GetReadState(r.Context(), user.DID, id)
138+ if rs.IsStarred {
139+ if err := s.db.UnstarArticle(r.Context(), user.DID, id); err != nil {
140+ http.Error(w, err.Error(), http.StatusInternalServerError)
141+ return
142+ }
143+ writeStarButton(w, id, false)
144+ } else {
145+ if err := s.db.StarArticle(r.Context(), user.DID, id); err != nil {
146+ http.Error(w, err.Error(), http.StatusInternalServerError)
147+ return
148+ }
149+ writeStarButton(w, id, true)
150+ }
151+}
152+
153+func (s *Server) handleUnstar(w http.ResponseWriter, r *http.Request) {
154+ user := currentUser(r)
155+ id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
156+ if err != nil {
157+ http.Error(w, "invalid id", http.StatusBadRequest)
158+ return
159+ }
160+ if err := s.db.UnstarArticle(r.Context(), user.DID, id); err != nil {
161+ http.Error(w, err.Error(), http.StatusInternalServerError)
162+ return
163+ }
164+ writeStarButton(w, id, false)
165+}
166+
167+func (s *Server) handleLikeArticle(w http.ResponseWriter, r *http.Request) {
168+ user := currentUser(r)
169+ id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
170+ if err != nil {
171+ http.Error(w, "invalid id", http.StatusBadRequest)
172+ return
173+ }
174+
175+ article, err := s.db.GetArticle(r.Context(), id)
176+ if err != nil {
177+ http.Error(w, err.Error(), http.StatusNotFound)
178+ return
179+ }
180+
181+ liked, err := s.db.HasLiked(r.Context(), user.DID, article.FeedURL, article.URL.String)
182+ if err != nil {
183+ http.Error(w, err.Error(), http.StatusInternalServerError)
184+ return
185+ }
186+
187+ if liked {
188+ if err := s.db.DeleteLikeByUserArticle(r.Context(), user.DID, article.FeedURL, article.URL.String); err != nil {
189+ http.Error(w, err.Error(), http.StatusInternalServerError)
190+ return
191+ }
192+ } else {
193+ likeRecord := atproto.LikeRecord{
194+ CreatedAt: time.Now().Format(time.RFC3339),
195+ FeedURL: article.FeedURL,
196+ ArticleURL: article.URL.String,
197+ }
198+
199+ if client := s.pdsClientForUser(r); client != nil {
200+ uri, _, err := client.CreateRecord(r.Context(), user.DID, "at.glean.like", likeRecord)
201+ if err != nil {
202+ s.logger.Warn("failed to write like to PDS", "error", err)
203+ }
204+
205+ like := &db.Like{
206+ URI: uri,
207+ AuthorDID: user.DID,
208+ FeedURL: article.FeedURL,
209+ ArticleURL: article.URL.String,
210+ CreatedAt: sql.NullTime{Time: time.Now(), Valid: true},
211+ }
212+ if err := s.db.CreateLike(r.Context(), like); err != nil {
213+ http.Error(w, err.Error(), http.StatusInternalServerError)
214+ return
215+ }
216+ } else {
217+ like := &db.Like{
218+ URI: fmt.Sprintf("glean:like:%d", time.Now().UnixNano()),
219+ AuthorDID: user.DID,
220+ FeedURL: article.FeedURL,
221+ ArticleURL: article.URL.String,
222+ CreatedAt: sql.NullTime{Time: time.Now(), Valid: true},
223+ }
224+ if err := s.db.CreateLike(r.Context(), like); err != nil {
225+ http.Error(w, err.Error(), http.StatusInternalServerError)
226+ return
227+ }
228+ }
229+ }
230+
231+ likeCount, _ := s.db.GetLikeCount(r.Context(), article.FeedURL, article.URL.String)
232+ writeLikeButton(w, id, !liked, likeCount)
233+}
234+
235+func (s *Server) handleMarkAllRead(w http.ResponseWriter, r *http.Request) {
236+ user := currentUser(r)
237+ feedURL := r.FormValue("feed")
238+ var err error
239+ if feedURL != "" {
240+ err = s.db.MarkAllRead(r.Context(), user.DID, feedURL)
241+ } else {
242+ err = s.db.MarkAllSubscribedRead(r.Context(), user.DID)
243+ }
244+ if err != nil {
245+ http.Error(w, err.Error(), http.StatusInternalServerError)
246+ return
247+ }
248+ w.Header().Set("HX-Refresh", "true")
249+ w.WriteHeader(http.StatusNoContent)
250+}
new file mode 100644
@@ -0,0 +1,250 @@
1+package server
2+
3+import (
4+ "database/sql"
5+ "fmt"
6+ "net/http"
7+ "strconv"
8+ "time"
9+
10+ "github.com/go-chi/chi/v5"
11+
12+ "pkg.rbrt.fr/glean/internal/atproto"
13+ "pkg.rbrt.fr/glean/internal/db"
14+)
15+
16+func writeStarButton(w http.ResponseWriter, articleID int64, starred bool) {
17+ cls := "text-gray-300 hover:text-yellow-500"
18+ ch := "&#9734;"
19+ if starred {
20+ cls = "text-yellow-500"
21+ ch = "&#9733;"
22+ }
23+ w.Header().Set("Content-Type", "text/html")
24+ _, _ = fmt.Fprintf(w, `<button hx-post="/articles/%d/star" hx-target="#star-btn" hx-swap="outerHTML" id="star-btn" class="text-lg %s">%s</button>`, articleID, cls, ch)
25+}
26+
27+func writeLikeButton(w http.ResponseWriter, articleID int64, liked bool, count int) {
28+ cls := "text-gray-300 hover:text-red-500"
29+ if liked {
30+ cls = "text-red-500"
31+ }
32+ w.Header().Set("Content-Type", "text/html")
33+ _, _ = fmt.Fprintf(w, `<button hx-post="/articles/%d/like" hx-target="#like-btn" hx-swap="outerHTML" id="like-btn" class="text-lg %s">&#9829; <span class="text-sm text-gray-600">%d</span></button>`, articleID, cls, count)
34+}
35+
36+func writeReadButton(w http.ResponseWriter, articleID int64, isRead bool) {
37+ label := "Mark read"
38+ action := "read"
39+ if isRead {
40+ label = "Mark unread"
41+ action = "unread"
42+ }
43+ w.Header().Set("Content-Type", "text/html")
44+ _, _ = fmt.Fprintf(w, `<button hx-post="/articles/%d/%s" hx-target="#read-btn" hx-swap="outerHTML" id="read-btn" class="text-xs border border-gray-300 rounded px-2 py-1 hover:bg-gray-50">%s</button>`, articleID, action, label)
45+}
46+
47+func (s *Server) handleArticles(w http.ResponseWriter, r *http.Request) {
48+ user := currentUser(r)
49+ feedURL := r.URL.Query().Get("feed")
50+
51+ articles, err := s.db.ListArticles(r.Context(), user.DID, feedURL, 50, 0)
52+ if err != nil {
53+ s.logger.Error("failed to list articles", "error", err)
54+ http.Error(w, err.Error(), http.StatusInternalServerError)
55+ return
56+ }
57+
58+ s.render(w, r, "articles.html", map[string]any{
59+ "User": user,
60+ "Articles": articles,
61+ "FeedURL": feedURL,
62+ })
63+}
64+
65+func (s *Server) handleArticleDetail(w http.ResponseWriter, r *http.Request) {
66+ user := currentUser(r)
67+ id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
68+ if err != nil {
69+ http.Error(w, "invalid id", http.StatusBadRequest)
70+ return
71+ }
72+
73+ article, err := s.db.GetArticle(r.Context(), id)
74+ if err != nil {
75+ http.Error(w, "article not found", http.StatusNotFound)
76+ return
77+ }
78+
79+ _ = s.db.MarkArticleRead(r.Context(), user.DID, id)
80+
81+ readState, _ := s.db.GetReadState(r.Context(), user.DID, id)
82+
83+ likeCount, _ := s.db.GetLikeCount(r.Context(), article.FeedURL, article.URL.String)
84+ liked := false
85+ if article.URL.Valid {
86+ liked, _ = s.db.HasLiked(r.Context(), user.DID, article.FeedURL, article.URL.String)
87+ }
88+ annotations, _ := s.db.ListAnnotations(r.Context(), "", article.URL.String, "", 20, 0)
89+ feed, _ := s.db.GetFeed(r.Context(), article.FeedURL)
90+
91+ s.render(w, r, "article_detail.html", map[string]any{
92+ "User": user,
93+ "Article": article,
94+ "Feed": feed,
95+ "ReadState": readState,
96+ "LikeCount": likeCount,
97+ "HasLiked": liked,
98+ "Annotations": annotations,
99+ })
100+}
101+
102+func (s *Server) handleMarkRead(w http.ResponseWriter, r *http.Request) {
103+ user := currentUser(r)
104+ id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
105+ if err != nil {
106+ http.Error(w, "invalid id", http.StatusBadRequest)
107+ return
108+ }
109+ if err := s.db.MarkArticleRead(r.Context(), user.DID, id); err != nil {
110+ http.Error(w, err.Error(), http.StatusInternalServerError)
111+ return
112+ }
113+ writeReadButton(w, id, true)
114+}
115+
116+func (s *Server) handleMarkUnread(w http.ResponseWriter, r *http.Request) {
117+ user := currentUser(r)
118+ id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
119+ if err != nil {
120+ http.Error(w, "invalid id", http.StatusBadRequest)
121+ return
122+ }
123+ if err := s.db.MarkArticleUnread(r.Context(), user.DID, id); err != nil {
124+ http.Error(w, err.Error(), http.StatusInternalServerError)
125+ return
126+ }
127+ writeReadButton(w, id, false)
128+}
129+
130+func (s *Server) handleStar(w http.ResponseWriter, r *http.Request) {
131+ user := currentUser(r)
132+ id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
133+ if err != nil {
134+ http.Error(w, "invalid id", http.StatusBadRequest)
135+ return
136+ }
137+ rs, _ := s.db.GetReadState(r.Context(), user.DID, id)
138+ if rs.IsStarred {
139+ if err := s.db.UnstarArticle(r.Context(), user.DID, id); err != nil {
140+ http.Error(w, err.Error(), http.StatusInternalServerError)
141+ return
142+ }
143+ writeStarButton(w, id, false)
144+ } else {
145+ if err := s.db.StarArticle(r.Context(), user.DID, id); err != nil {
146+ http.Error(w, err.Error(), http.StatusInternalServerError)
147+ return
148+ }
149+ writeStarButton(w, id, true)
150+ }
151+}
152+
153+func (s *Server) handleUnstar(w http.ResponseWriter, r *http.Request) {
154+ user := currentUser(r)
155+ id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
156+ if err != nil {
157+ http.Error(w, "invalid id", http.StatusBadRequest)
158+ return
159+ }
160+ if err := s.db.UnstarArticle(r.Context(), user.DID, id); err != nil {
161+ http.Error(w, err.Error(), http.StatusInternalServerError)
162+ return
163+ }
164+ writeStarButton(w, id, false)
165+}
166+
167+func (s *Server) handleLikeArticle(w http.ResponseWriter, r *http.Request) {
168+ user := currentUser(r)
169+ id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
170+ if err != nil {
171+ http.Error(w, "invalid id", http.StatusBadRequest)
172+ return
173+ }
174+
175+ article, err := s.db.GetArticle(r.Context(), id)
176+ if err != nil {
177+ http.Error(w, err.Error(), http.StatusNotFound)
178+ return
179+ }
180+
181+ liked, err := s.db.HasLiked(r.Context(), user.DID, article.FeedURL, article.URL.String)
182+ if err != nil {
183+ http.Error(w, err.Error(), http.StatusInternalServerError)
184+ return
185+ }
186+
187+ if liked {
188+ if err := s.db.DeleteLikeByUserArticle(r.Context(), user.DID, article.FeedURL, article.URL.String); err != nil {
189+ http.Error(w, err.Error(), http.StatusInternalServerError)
190+ return
191+ }
192+ } else {
193+ likeRecord := atproto.LikeRecord{
194+ CreatedAt: time.Now().Format(time.RFC3339),
195+ FeedURL: article.FeedURL,
196+ ArticleURL: article.URL.String,
197+ }
198+
199+ if client := s.pdsClientForUser(r); client != nil {
200+ uri, _, err := client.CreateRecord(r.Context(), user.DID, "at.glean.like", likeRecord)
201+ if err != nil {
202+ s.logger.Warn("failed to write like to PDS", "error", err)
203+ }
204+
205+ like := &db.Like{
206+ URI: uri,
207+ AuthorDID: user.DID,
208+ FeedURL: article.FeedURL,
209+ ArticleURL: article.URL.String,
210+ CreatedAt: sql.NullTime{Time: time.Now(), Valid: true},
211+ }
212+ if err := s.db.CreateLike(r.Context(), like); err != nil {
213+ http.Error(w, err.Error(), http.StatusInternalServerError)
214+ return
215+ }
216+ } else {
217+ like := &db.Like{
218+ URI: fmt.Sprintf("glean:like:%d", time.Now().UnixNano()),
219+ AuthorDID: user.DID,
220+ FeedURL: article.FeedURL,
221+ ArticleURL: article.URL.String,
222+ CreatedAt: sql.NullTime{Time: time.Now(), Valid: true},
223+ }
224+ if err := s.db.CreateLike(r.Context(), like); err != nil {
225+ http.Error(w, err.Error(), http.StatusInternalServerError)
226+ return
227+ }
228+ }
229+ }
230+
231+ likeCount, _ := s.db.GetLikeCount(r.Context(), article.FeedURL, article.URL.String)
232+ writeLikeButton(w, id, !liked, likeCount)
233+}
234+
235+func (s *Server) handleMarkAllRead(w http.ResponseWriter, r *http.Request) {
236+ user := currentUser(r)
237+ feedURL := r.FormValue("feed")
238+ var err error
239+ if feedURL != "" {
240+ err = s.db.MarkAllRead(r.Context(), user.DID, feedURL)
241+ } else {
242+ err = s.db.MarkAllSubscribedRead(r.Context(), user.DID)
243+ }
244+ if err != nil {
245+ http.Error(w, err.Error(), http.StatusInternalServerError)
246+ return
247+ }
248+ w.Header().Set("HX-Refresh", "true")
249+ w.WriteHeader(http.StatusNoContent)
250+}
added internal/server/auth_handler.go +41 -0
new file mode 100644
@@ -0,0 +1,41 @@
1+package server
2+
3+import (
4+ "net/http"
5+
6+ "pkg.rbrt.fr/glean/internal/atproto"
7+)
8+
9+func (s *Server) handleAuthLogin(w http.ResponseWriter, r *http.Request) {
10+ s.render(w, r, "login.html", map[string]any{})
11+}
12+
13+func (s *Server) handleAuthCallback(w http.ResponseWriter, r *http.Request) {
14+ handle := r.URL.Query().Get("handle")
15+ if handle == "" {
16+ http.Error(w, "handle required", http.StatusBadRequest)
17+ return
18+ }
19+
20+ did, err := atproto.ResolveHandle(r.Context(), handle)
21+ if err != nil {
22+ s.logger.Error("failed to resolve handle", "error", err)
23+ http.Error(w, err.Error(), http.StatusInternalServerError)
24+ return
25+ }
26+
27+ user, err := s.db.CreateUser(r.Context(), did, handle, "", "")
28+ if err != nil {
29+ s.logger.Error("failed to create user", "error", err)
30+ http.Error(w, err.Error(), http.StatusInternalServerError)
31+ return
32+ }
33+
34+ s.setUserSession(w, user)
35+ http.Redirect(w, r, "/dashboard", http.StatusSeeOther)
36+}
37+
38+func (s *Server) handleAuthLogout(w http.ResponseWriter, r *http.Request) {
39+ s.clearUserSession(w)
40+ http.Redirect(w, r, "/", http.StatusSeeOther)
41+}
new file mode 100644
@@ -0,0 +1,41 @@
1+package server
2+
3+import (
4+ "net/http"
5+
6+ "pkg.rbrt.fr/glean/internal/atproto"
7+)
8+
9+func (s *Server) handleAuthLogin(w http.ResponseWriter, r *http.Request) {
10+ s.render(w, r, "login.html", map[string]any{})
11+}
12+
13+func (s *Server) handleAuthCallback(w http.ResponseWriter, r *http.Request) {
14+ handle := r.URL.Query().Get("handle")
15+ if handle == "" {
16+ http.Error(w, "handle required", http.StatusBadRequest)
17+ return
18+ }
19+
20+ did, err := atproto.ResolveHandle(r.Context(), handle)
21+ if err != nil {
22+ s.logger.Error("failed to resolve handle", "error", err)
23+ http.Error(w, err.Error(), http.StatusInternalServerError)
24+ return
25+ }
26+
27+ user, err := s.db.CreateUser(r.Context(), did, handle, "", "")
28+ if err != nil {
29+ s.logger.Error("failed to create user", "error", err)
30+ http.Error(w, err.Error(), http.StatusInternalServerError)
31+ return
32+ }
33+
34+ s.setUserSession(w, user)
35+ http.Redirect(w, r, "/dashboard", http.StatusSeeOther)
36+}
37+
38+func (s *Server) handleAuthLogout(w http.ResponseWriter, r *http.Request) {
39+ s.clearUserSession(w)
40+ http.Redirect(w, r, "/", http.StatusSeeOther)
41+}
added internal/server/dashboard_handler.go +28 -0
new file mode 100644
@@ -0,0 +1,28 @@
1+package server
2+
3+import (
4+ "net/http"
5+ "time"
6+)
7+
8+func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) {
9+ user := currentUser(r)
10+
11+ unreadCount, _ := s.db.GetUnreadCount(r.Context(), user.DID, "")
12+ subCount, _ := s.db.GetSubscriptionCount(r.Context(), user.DID)
13+ articles, _ := s.db.ListUnreadArticles(r.Context(), user.DID, "", 25, 0)
14+ feedRecs, _ := s.db.GetFeedRecommendations(r.Context(), user.DID, 5)
15+ peopleRecs, _ := s.db.GetPeopleRecommendations(r.Context(), user.DID, 5)
16+ since := time.Now().AddDate(0, 0, -7).Format(time.RFC3339)
17+ trending, _ := s.db.ListTrendingArticles(r.Context(), since, 5, 0)
18+
19+ s.render(w, r, "dashboard.html", map[string]any{
20+ "User": user,
21+ "UnreadCount": unreadCount,
22+ "SubscriptionCount": subCount,
23+ "Articles": articles,
24+ "FeedRecommendations": feedRecs,
25+ "PeopleRecommendations": peopleRecs,
26+ "Trending": trending,
27+ })
28+}
new file mode 100644
@@ -0,0 +1,28 @@
1+package server
2+
3+import (
4+ "net/http"
5+ "time"
6+)
7+
8+func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) {
9+ user := currentUser(r)
10+
11+ unreadCount, _ := s.db.GetUnreadCount(r.Context(), user.DID, "")
12+ subCount, _ := s.db.GetSubscriptionCount(r.Context(), user.DID)
13+ articles, _ := s.db.ListUnreadArticles(r.Context(), user.DID, "", 25, 0)
14+ feedRecs, _ := s.db.GetFeedRecommendations(r.Context(), user.DID, 5)
15+ peopleRecs, _ := s.db.GetPeopleRecommendations(r.Context(), user.DID, 5)
16+ since := time.Now().AddDate(0, 0, -7).Format(time.RFC3339)
17+ trending, _ := s.db.ListTrendingArticles(r.Context(), since, 5, 0)
18+
19+ s.render(w, r, "dashboard.html", map[string]any{
20+ "User": user,
21+ "UnreadCount": unreadCount,
22+ "SubscriptionCount": subCount,
23+ "Articles": articles,
24+ "FeedRecommendations": feedRecs,
25+ "PeopleRecommendations": peopleRecs,
26+ "Trending": trending,
27+ })
28+}
added internal/server/discover_handler.go +34 -0
new file mode 100644
@@ -0,0 +1,34 @@
1+package server
2+
3+import "net/http"
4+
5+func (s *Server) handleDiscover(w http.ResponseWriter, r *http.Request) {
6+ user := currentUser(r)
7+ feedRecs, _ := s.db.GetFeedRecommendations(r.Context(), user.DID, 20)
8+ people, _ := s.db.GetPeopleRecommendations(r.Context(), user.DID, 20)
9+ popular, _ := s.db.ListAllFeeds(r.Context(), 20, 0)
10+ s.render(w, r, "discover.html", map[string]any{
11+ "User": user,
12+ "FeedRecommendations": feedRecs,
13+ "PeopleRecommendations": people,
14+ "PopularFeeds": popular,
15+ })
16+}
17+
18+func (s *Server) handleDiscoverFeeds(w http.ResponseWriter, r *http.Request) {
19+ user := currentUser(r)
20+ feedRecs, _ := s.db.GetFeedRecommendations(r.Context(), user.DID, 20)
21+ s.render(w, r, "discover.html", map[string]any{
22+ "User": user,
23+ "FeedRecommendations": feedRecs,
24+ })
25+}
26+
27+func (s *Server) handleDiscoverPeople(w http.ResponseWriter, r *http.Request) {
28+ user := currentUser(r)
29+ people, _ := s.db.GetPeopleRecommendations(r.Context(), user.DID, 20)
30+ s.render(w, r, "discover.html", map[string]any{
31+ "User": user,
32+ "PeopleRecommendations": people,
33+ })
34+}
new file mode 100644
@@ -0,0 +1,34 @@
1+package server
2+
3+import "net/http"
4+
5+func (s *Server) handleDiscover(w http.ResponseWriter, r *http.Request) {
6+ user := currentUser(r)
7+ feedRecs, _ := s.db.GetFeedRecommendations(r.Context(), user.DID, 20)
8+ people, _ := s.db.GetPeopleRecommendations(r.Context(), user.DID, 20)
9+ popular, _ := s.db.ListAllFeeds(r.Context(), 20, 0)
10+ s.render(w, r, "discover.html", map[string]any{
11+ "User": user,
12+ "FeedRecommendations": feedRecs,
13+ "PeopleRecommendations": people,
14+ "PopularFeeds": popular,
15+ })
16+}
17+
18+func (s *Server) handleDiscoverFeeds(w http.ResponseWriter, r *http.Request) {
19+ user := currentUser(r)
20+ feedRecs, _ := s.db.GetFeedRecommendations(r.Context(), user.DID, 20)
21+ s.render(w, r, "discover.html", map[string]any{
22+ "User": user,
23+ "FeedRecommendations": feedRecs,
24+ })
25+}
26+
27+func (s *Server) handleDiscoverPeople(w http.ResponseWriter, r *http.Request) {
28+ user := currentUser(r)
29+ people, _ := s.db.GetPeopleRecommendations(r.Context(), user.DID, 20)
30+ s.render(w, r, "discover.html", map[string]any{
31+ "User": user,
32+ "PeopleRecommendations": people,
33+ })
34+}
added internal/server/feeds_handler.go +238 -0
new file mode 100644
@@ -0,0 +1,238 @@
1+package server
2+
3+import (
4+ "database/sql"
5+ "log/slog"
6+ "net/http"
7+ "time"
8+
9+ "pkg.rbrt.fr/glean/internal/atproto"
10+ "pkg.rbrt.fr/glean/internal/db"
11+ "pkg.rbrt.fr/glean/internal/feed"
12+)
13+
14+func (s *Server) handleFeeds(w http.ResponseWriter, r *http.Request) {
15+ user := currentUser(r)
16+ category := r.URL.Query().Get("category")
17+ subs, _ := s.db.ListSubscriptions(r.Context(), user.DID, category, 100, 0)
18+ allSubs, _ := s.db.ListSubscriptions(r.Context(), user.DID, "", 100, 0)
19+ feedRecs, _ := s.db.GetFeedRecommendations(r.Context(), user.DID, 10)
20+ peopleRecs, _ := s.db.GetPeopleRecommendations(r.Context(), user.DID, 5)
21+
22+ seen := make(map[string]bool)
23+ var categories []string
24+ for _, sub := range allSubs {
25+ if sub.Category.Valid && sub.Category.String != "" && !seen[sub.Category.String] {
26+ seen[sub.Category.String] = true
27+ categories = append(categories, sub.Category.String)
28+ }
29+ }
30+
31+ s.render(w, r, "feeds.html", map[string]any{
32+ "User": user,
33+ "Subscriptions": subs,
34+ "Categories": categories,
35+ "Category": category,
36+ "FeedRecommendations": feedRecs,
37+ "PeopleRecommendations": peopleRecs,
38+ })
39+}
40+
41+func (s *Server) handleAddFeed(w http.ResponseWriter, r *http.Request) {
42+ user := currentUser(r)
43+ feedURL := r.FormValue("feed_url")
44+ category := r.FormValue("category")
45+
46+ if feedURL == "" {
47+ http.Error(w, "url required", http.StatusBadRequest)
48+ return
49+ }
50+
51+ result, _, _, err := s.fetcher.Fetch(r.Context(), feedURL, "", "")
52+ if err != nil {
53+ s.logger.Error("failed to fetch feed", "error", err, "url", feedURL)
54+ http.Error(w, err.Error(), http.StatusInternalServerError)
55+ return
56+ }
57+
58+ if result != nil {
59+ f := &db.Feed{
60+ FeedURL: feedURL,
61+ Title: nullString(result.Feed.Title),
62+ SiteURL: nullString(result.Feed.SiteURL),
63+ Description: nullString(result.Feed.Description),
64+ FeedType: nullString(result.Feed.Type),
65+ }
66+ if err := s.db.UpsertFeed(r.Context(), f); err != nil {
67+ s.logger.Error("failed to upsert feed", "error", err)
68+ http.Error(w, err.Error(), http.StatusInternalServerError)
69+ return
70+ }
71+ }
72+
73+ if err := s.db.CreateSubscription(r.Context(), user.DID, feedURL, category); err != nil {
74+ s.logger.Error("failed to create subscription", "error", err)
75+ http.Error(w, err.Error(), http.StatusInternalServerError)
76+ return
77+ }
78+
79+ if client := s.pdsClientForUser(r); client != nil {
80+ record := atproto.SubscriptionRecord{
81+ CreatedAt: time.Now().Format(time.RFC3339),
82+ FeedURL: feedURL,
83+ Title: result.Feed.Title,
84+ Category: category,
85+ }
86+ if _, _, err := client.CreateRecord(r.Context(), user.DID, "at.glean.subscription", record); err != nil {
87+ s.logger.Warn("failed to write subscription to PDS", "error", err)
88+ }
89+ }
90+
91+ subs, _ := s.db.ListSubscriptions(r.Context(), user.DID, "", 100, 0)
92+ s.render(w, r, "feed_list.html", map[string]any{
93+ "User": user,
94+ "Subscriptions": subs,
95+ })
96+}
97+
98+func (s *Server) handleRemoveFeed(w http.ResponseWriter, r *http.Request) {
99+ user := currentUser(r)
100+ feedURL := r.FormValue("url")
101+
102+ if feedURL == "" {
103+ http.Error(w, "url required", http.StatusBadRequest)
104+ return
105+ }
106+
107+ if err := s.db.DeleteSubscription(r.Context(), user.DID, feedURL); err != nil {
108+ s.logger.Error("failed to delete subscription", "error", err)
109+ http.Error(w, err.Error(), http.StatusInternalServerError)
110+ return
111+ }
112+
113+ w.WriteHeader(http.StatusNoContent)
114+}
115+
116+func (s *Server) handleOPMLUpload(w http.ResponseWriter, r *http.Request) {
117+ user := currentUser(r)
118+ file, _, err := r.FormFile("opml")
119+ if err != nil {
120+ http.Error(w, err.Error(), http.StatusBadRequest)
121+ return
122+ }
123+ defer file.Close()
124+
125+ opml, err := feed.ParseOPML(file)
126+ if err != nil {
127+ http.Error(w, err.Error(), http.StatusBadRequest)
128+ return
129+ }
130+
131+ feedURLs := feed.ExtractFeedURLs(opml)
132+ var added int
133+ client := s.pdsClientForUser(r)
134+ for _, fu := range feedURLs {
135+ f := &db.Feed{
136+ FeedURL: fu.URL,
137+ Title: nullString(fu.Title),
138+ }
139+ if upsertErr := s.db.UpsertFeed(r.Context(), f); upsertErr != nil {
140+ s.logger.Error("failed to upsert feed", "error", upsertErr)
141+ continue
142+ }
143+ if subErr := s.db.CreateSubscription(r.Context(), user.DID, fu.URL, fu.Category); subErr != nil {
144+ s.logger.Error("failed to create subscription", "error", subErr)
145+ continue
146+ }
147+ if client != nil {
148+ record := atproto.SubscriptionRecord{
149+ CreatedAt: time.Now().Format(time.RFC3339),
150+ FeedURL: fu.URL,
151+ Title: fu.Title,
152+ Category: fu.Category,
153+ }
154+ if _, _, err := client.CreateRecord(r.Context(), user.DID, "at.glean.subscription", record); err != nil {
155+ s.logger.Warn("failed to write subscription to PDS", "error", err, "url", fu.URL)
156+ }
157+ }
158+ added++
159+ }
160+
161+ subs, _ := s.db.ListSubscriptions(r.Context(), user.DID, "", 100, 0)
162+ s.render(w, r, "feeds.html", map[string]any{
163+ "User": user,
164+ "Subscriptions": subs,
165+ "AddedCount": added,
166+ })
167+}
168+
169+func (s *Server) handleOPMLDownload(w http.ResponseWriter, r *http.Request) {
170+ user := currentUser(r)
171+ subs, _ := s.db.ListSubscriptions(r.Context(), user.DID, "", 1000, 0)
172+
173+ var feedURLs []feed.FeedURL
174+ for _, sub := range subs {
175+ f, err := s.db.GetFeed(r.Context(), sub.FeedURL)
176+ title := ""
177+ if err == nil {
178+ title = f.Title.String
179+ }
180+ feedURLs = append(feedURLs, feed.FeedURL{
181+ URL: sub.FeedURL,
182+ Title: title,
183+ })
184+ }
185+
186+ data, err := feed.GenerateOPML(feedURLs, "Glean Subscriptions")
187+ if err != nil {
188+ http.Error(w, err.Error(), http.StatusInternalServerError)
189+ return
190+ }
191+
192+ w.Header().Set("Content-Type", "text/xml")
193+ w.Header().Set("Content-Disposition", "attachment; filename=glean-subscriptions.xml")
194+ _, _ = w.Write(data)
195+}
196+
197+func (s *Server) handleFeedList(w http.ResponseWriter, r *http.Request) {
198+ user := currentUser(r)
199+ subs, _ := s.db.ListSubscriptions(r.Context(), user.DID, "", 100, 0)
200+ s.render(w, r, "feeds.html", map[string]any{
201+ "User": user,
202+ "Subscriptions": subs,
203+ })
204+}
205+
206+func (s *Server) handleRefreshFeeds(w http.ResponseWriter, r *http.Request) {
207+ user := currentUser(r)
208+ store := db.NewFeedStoreAdapter(s.db)
209+ scheduler := feed.NewScheduler(store, slog.Default())
210+
211+ subs, _ := s.db.ListSubscriptions(r.Context(), user.DID, "", 100, 0)
212+ for _, sub := range subs {
213+ f, err := s.db.GetFeed(r.Context(), sub.FeedURL)
214+ if err != nil {
215+ continue
216+ }
217+ ff := &feed.Feed{
218+ URL: f.FeedURL,
219+ Title: f.Title.String,
220+ SiteURL: f.SiteURL.String,
221+ Description: f.Description.String,
222+ Type: f.FeedType.String,
223+ ETag: f.Etag.String,
224+ LastModified: f.LastModified.String,
225+ }
226+ scheduler.FetchFeed(r.Context(), ff)
227+ }
228+
229+ subs, _ = s.db.ListSubscriptions(r.Context(), user.DID, "", 100, 0)
230+ s.render(w, r, "feeds.html", map[string]any{
231+ "User": user,
232+ "Subscriptions": subs,
233+ })
234+}
235+
236+func nullString(s string) sql.NullString {
237+ return sql.NullString{String: s, Valid: s != ""}
238+}
new file mode 100644
@@ -0,0 +1,238 @@
1+package server
2+
3+import (
4+ "database/sql"
5+ "log/slog"
6+ "net/http"
7+ "time"
8+
9+ "pkg.rbrt.fr/glean/internal/atproto"
10+ "pkg.rbrt.fr/glean/internal/db"
11+ "pkg.rbrt.fr/glean/internal/feed"
12+)
13+
14+func (s *Server) handleFeeds(w http.ResponseWriter, r *http.Request) {
15+ user := currentUser(r)
16+ category := r.URL.Query().Get("category")
17+ subs, _ := s.db.ListSubscriptions(r.Context(), user.DID, category, 100, 0)
18+ allSubs, _ := s.db.ListSubscriptions(r.Context(), user.DID, "", 100, 0)
19+ feedRecs, _ := s.db.GetFeedRecommendations(r.Context(), user.DID, 10)
20+ peopleRecs, _ := s.db.GetPeopleRecommendations(r.Context(), user.DID, 5)
21+
22+ seen := make(map[string]bool)
23+ var categories []string
24+ for _, sub := range allSubs {
25+ if sub.Category.Valid && sub.Category.String != "" && !seen[sub.Category.String] {
26+ seen[sub.Category.String] = true
27+ categories = append(categories, sub.Category.String)
28+ }
29+ }
30+
31+ s.render(w, r, "feeds.html", map[string]any{
32+ "User": user,
33+ "Subscriptions": subs,
34+ "Categories": categories,
35+ "Category": category,
36+ "FeedRecommendations": feedRecs,
37+ "PeopleRecommendations": peopleRecs,
38+ })
39+}
40+
41+func (s *Server) handleAddFeed(w http.ResponseWriter, r *http.Request) {
42+ user := currentUser(r)
43+ feedURL := r.FormValue("feed_url")
44+ category := r.FormValue("category")
45+
46+ if feedURL == "" {
47+ http.Error(w, "url required", http.StatusBadRequest)
48+ return
49+ }
50+
51+ result, _, _, err := s.fetcher.Fetch(r.Context(), feedURL, "", "")
52+ if err != nil {
53+ s.logger.Error("failed to fetch feed", "error", err, "url", feedURL)
54+ http.Error(w, err.Error(), http.StatusInternalServerError)
55+ return
56+ }
57+
58+ if result != nil {
59+ f := &db.Feed{
60+ FeedURL: feedURL,
61+ Title: nullString(result.Feed.Title),
62+ SiteURL: nullString(result.Feed.SiteURL),
63+ Description: nullString(result.Feed.Description),
64+ FeedType: nullString(result.Feed.Type),
65+ }
66+ if err := s.db.UpsertFeed(r.Context(), f); err != nil {
67+ s.logger.Error("failed to upsert feed", "error", err)
68+ http.Error(w, err.Error(), http.StatusInternalServerError)
69+ return
70+ }
71+ }
72+
73+ if err := s.db.CreateSubscription(r.Context(), user.DID, feedURL, category); err != nil {
74+ s.logger.Error("failed to create subscription", "error", err)
75+ http.Error(w, err.Error(), http.StatusInternalServerError)
76+ return
77+ }
78+
79+ if client := s.pdsClientForUser(r); client != nil {
80+ record := atproto.SubscriptionRecord{
81+ CreatedAt: time.Now().Format(time.RFC3339),
82+ FeedURL: feedURL,
83+ Title: result.Feed.Title,
84+ Category: category,
85+ }
86+ if _, _, err := client.CreateRecord(r.Context(), user.DID, "at.glean.subscription", record); err != nil {
87+ s.logger.Warn("failed to write subscription to PDS", "error", err)
88+ }
89+ }
90+
91+ subs, _ := s.db.ListSubscriptions(r.Context(), user.DID, "", 100, 0)
92+ s.render(w, r, "feed_list.html", map[string]any{
93+ "User": user,
94+ "Subscriptions": subs,
95+ })
96+}
97+
98+func (s *Server) handleRemoveFeed(w http.ResponseWriter, r *http.Request) {
99+ user := currentUser(r)
100+ feedURL := r.FormValue("url")
101+
102+ if feedURL == "" {
103+ http.Error(w, "url required", http.StatusBadRequest)
104+ return
105+ }
106+
107+ if err := s.db.DeleteSubscription(r.Context(), user.DID, feedURL); err != nil {
108+ s.logger.Error("failed to delete subscription", "error", err)
109+ http.Error(w, err.Error(), http.StatusInternalServerError)
110+ return
111+ }
112+
113+ w.WriteHeader(http.StatusNoContent)
114+}
115+
116+func (s *Server) handleOPMLUpload(w http.ResponseWriter, r *http.Request) {
117+ user := currentUser(r)
118+ file, _, err := r.FormFile("opml")
119+ if err != nil {
120+ http.Error(w, err.Error(), http.StatusBadRequest)
121+ return
122+ }
123+ defer file.Close()
124+
125+ opml, err := feed.ParseOPML(file)
126+ if err != nil {
127+ http.Error(w, err.Error(), http.StatusBadRequest)
128+ return
129+ }
130+
131+ feedURLs := feed.ExtractFeedURLs(opml)
132+ var added int
133+ client := s.pdsClientForUser(r)
134+ for _, fu := range feedURLs {
135+ f := &db.Feed{
136+ FeedURL: fu.URL,
137+ Title: nullString(fu.Title),
138+ }
139+ if upsertErr := s.db.UpsertFeed(r.Context(), f); upsertErr != nil {
140+ s.logger.Error("failed to upsert feed", "error", upsertErr)
141+ continue
142+ }
143+ if subErr := s.db.CreateSubscription(r.Context(), user.DID, fu.URL, fu.Category); subErr != nil {
144+ s.logger.Error("failed to create subscription", "error", subErr)
145+ continue
146+ }
147+ if client != nil {
148+ record := atproto.SubscriptionRecord{
149+ CreatedAt: time.Now().Format(time.RFC3339),
150+ FeedURL: fu.URL,
151+ Title: fu.Title,
152+ Category: fu.Category,
153+ }
154+ if _, _, err := client.CreateRecord(r.Context(), user.DID, "at.glean.subscription", record); err != nil {
155+ s.logger.Warn("failed to write subscription to PDS", "error", err, "url", fu.URL)
156+ }
157+ }
158+ added++
159+ }
160+
161+ subs, _ := s.db.ListSubscriptions(r.Context(), user.DID, "", 100, 0)
162+ s.render(w, r, "feeds.html", map[string]any{
163+ "User": user,
164+ "Subscriptions": subs,
165+ "AddedCount": added,
166+ })
167+}
168+
169+func (s *Server) handleOPMLDownload(w http.ResponseWriter, r *http.Request) {
170+ user := currentUser(r)
171+ subs, _ := s.db.ListSubscriptions(r.Context(), user.DID, "", 1000, 0)
172+
173+ var feedURLs []feed.FeedURL
174+ for _, sub := range subs {
175+ f, err := s.db.GetFeed(r.Context(), sub.FeedURL)
176+ title := ""
177+ if err == nil {
178+ title = f.Title.String
179+ }
180+ feedURLs = append(feedURLs, feed.FeedURL{
181+ URL: sub.FeedURL,
182+ Title: title,
183+ })
184+ }
185+
186+ data, err := feed.GenerateOPML(feedURLs, "Glean Subscriptions")
187+ if err != nil {
188+ http.Error(w, err.Error(), http.StatusInternalServerError)
189+ return
190+ }
191+
192+ w.Header().Set("Content-Type", "text/xml")
193+ w.Header().Set("Content-Disposition", "attachment; filename=glean-subscriptions.xml")
194+ _, _ = w.Write(data)
195+}
196+
197+func (s *Server) handleFeedList(w http.ResponseWriter, r *http.Request) {
198+ user := currentUser(r)
199+ subs, _ := s.db.ListSubscriptions(r.Context(), user.DID, "", 100, 0)
200+ s.render(w, r, "feeds.html", map[string]any{
201+ "User": user,
202+ "Subscriptions": subs,
203+ })
204+}
205+
206+func (s *Server) handleRefreshFeeds(w http.ResponseWriter, r *http.Request) {
207+ user := currentUser(r)
208+ store := db.NewFeedStoreAdapter(s.db)
209+ scheduler := feed.NewScheduler(store, slog.Default())
210+
211+ subs, _ := s.db.ListSubscriptions(r.Context(), user.DID, "", 100, 0)
212+ for _, sub := range subs {
213+ f, err := s.db.GetFeed(r.Context(), sub.FeedURL)
214+ if err != nil {
215+ continue
216+ }
217+ ff := &feed.Feed{
218+ URL: f.FeedURL,
219+ Title: f.Title.String,
220+ SiteURL: f.SiteURL.String,
221+ Description: f.Description.String,
222+ Type: f.FeedType.String,
223+ ETag: f.Etag.String,
224+ LastModified: f.LastModified.String,
225+ }
226+ scheduler.FetchFeed(r.Context(), ff)
227+ }
228+
229+ subs, _ = s.db.ListSubscriptions(r.Context(), user.DID, "", 100, 0)
230+ s.render(w, r, "feeds.html", map[string]any{
231+ "User": user,
232+ "Subscriptions": subs,
233+ })
234+}
235+
236+func nullString(s string) sql.NullString {
237+ return sql.NullString{String: s, Valid: s != ""}
238+}
added internal/server/index_handler.go +12 -0
new file mode 100644
@@ -0,0 +1,12 @@
1+package server
2+
3+import "net/http"
4+
5+func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) {
6+ user := s.getUserFromSession(r)
7+ if user != nil {
8+ http.Redirect(w, r, "/dashboard", http.StatusSeeOther)
9+ return
10+ }
11+ s.render(w, r, "index.html", map[string]any{})
12+}
new file mode 100644
@@ -0,0 +1,12 @@
1+package server
2+
3+import "net/http"
4+
5+func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) {
6+ user := s.getUserFromSession(r)
7+ if user != nil {
8+ http.Redirect(w, r, "/dashboard", http.StatusSeeOther)
9+ return
10+ }
11+ s.render(w, r, "index.html", map[string]any{})
12+}
added internal/server/middleware.go +83 -0
new file mode 100644
@@ -0,0 +1,83 @@
1+package server
2+
3+import (
4+ "crypto/rand"
5+ "encoding/hex"
6+ "net/http"
7+ "strings"
8+)
9+
10+func (s *Server) sessionMiddleware(next http.Handler) http.Handler {
11+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
12+ user := s.getUserFromSession(r)
13+ if user != nil {
14+ ctx := contextWithUser(r.Context(), user)
15+ r = r.WithContext(ctx)
16+ }
17+ next.ServeHTTP(w, r)
18+ })
19+}
20+
21+func (s *Server) requireAuth(next http.Handler) http.Handler {
22+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
23+ if currentUser(r) == nil {
24+ http.Redirect(w, r, "/auth/login", http.StatusSeeOther)
25+ return
26+ }
27+ next.ServeHTTP(w, r)
28+ })
29+}
30+
31+func csrfToken() string {
32+ b := make([]byte, 32)
33+ rand.Read(b)
34+ return hex.EncodeToString(b)
35+}
36+
37+func (s *Server) csrfMiddleware(next http.Handler) http.Handler {
38+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
39+ if r.Method == http.MethodGet || r.Method == http.MethodHead || r.Method == http.MethodOptions {
40+ if _, err := r.Cookie("glean_csrf"); err != nil {
41+ http.SetCookie(w, &http.Cookie{
42+ Name: "glean_csrf",
43+ Value: csrfToken(),
44+ Path: "/",
45+ MaxAge: 86400,
46+ HttpOnly: false,
47+ SameSite: http.SameSiteLaxMode,
48+ })
49+ }
50+ next.ServeHTTP(w, r)
51+ return
52+ }
53+
54+ if r.Header.Get("HX-Request") == "true" {
55+ origin := r.Header.Get("Origin")
56+ if origin != "" && !sameOrigin(origin, r.Host) {
57+ http.Error(w, "forbidden", http.StatusForbidden)
58+ return
59+ }
60+ next.ServeHTTP(w, r)
61+ return
62+ }
63+
64+ cookie, err := r.Cookie("glean_csrf")
65+ if err != nil {
66+ http.Error(w, "missing csrf token", http.StatusForbidden)
67+ return
68+ }
69+ formToken := r.FormValue("csrf_token")
70+ if formToken == "" {
71+ formToken = r.Header.Get("X-CSRF-Token")
72+ }
73+ if formToken == "" || formToken != cookie.Value {
74+ http.Error(w, "csrf mismatch", http.StatusForbidden)
75+ return
76+ }
77+ next.ServeHTTP(w, r)
78+ })
79+}
80+
81+func sameOrigin(origin, host string) bool {
82+ return strings.HasPrefix(origin, "http://"+host) || strings.HasPrefix(origin, "https://"+host)
83+}
new file mode 100644
@@ -0,0 +1,83 @@
1+package server
2+
3+import (
4+ "crypto/rand"
5+ "encoding/hex"
6+ "net/http"
7+ "strings"
8+)
9+
10+func (s *Server) sessionMiddleware(next http.Handler) http.Handler {
11+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
12+ user := s.getUserFromSession(r)
13+ if user != nil {
14+ ctx := contextWithUser(r.Context(), user)
15+ r = r.WithContext(ctx)
16+ }
17+ next.ServeHTTP(w, r)
18+ })
19+}
20+
21+func (s *Server) requireAuth(next http.Handler) http.Handler {
22+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
23+ if currentUser(r) == nil {
24+ http.Redirect(w, r, "/auth/login", http.StatusSeeOther)
25+ return
26+ }
27+ next.ServeHTTP(w, r)
28+ })
29+}
30+
31+func csrfToken() string {
32+ b := make([]byte, 32)
33+ rand.Read(b)
34+ return hex.EncodeToString(b)
35+}
36+
37+func (s *Server) csrfMiddleware(next http.Handler) http.Handler {
38+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
39+ if r.Method == http.MethodGet || r.Method == http.MethodHead || r.Method == http.MethodOptions {
40+ if _, err := r.Cookie("glean_csrf"); err != nil {
41+ http.SetCookie(w, &http.Cookie{
42+ Name: "glean_csrf",
43+ Value: csrfToken(),
44+ Path: "/",
45+ MaxAge: 86400,
46+ HttpOnly: false,
47+ SameSite: http.SameSiteLaxMode,
48+ })
49+ }
50+ next.ServeHTTP(w, r)
51+ return
52+ }
53+
54+ if r.Header.Get("HX-Request") == "true" {
55+ origin := r.Header.Get("Origin")
56+ if origin != "" && !sameOrigin(origin, r.Host) {
57+ http.Error(w, "forbidden", http.StatusForbidden)
58+ return
59+ }
60+ next.ServeHTTP(w, r)
61+ return
62+ }
63+
64+ cookie, err := r.Cookie("glean_csrf")
65+ if err != nil {
66+ http.Error(w, "missing csrf token", http.StatusForbidden)
67+ return
68+ }
69+ formToken := r.FormValue("csrf_token")
70+ if formToken == "" {
71+ formToken = r.Header.Get("X-CSRF-Token")
72+ }
73+ if formToken == "" || formToken != cookie.Value {
74+ http.Error(w, "csrf mismatch", http.StatusForbidden)
75+ return
76+ }
77+ next.ServeHTTP(w, r)
78+ })
79+}
80+
81+func sameOrigin(origin, host string) bool {
82+ return strings.HasPrefix(origin, "http://"+host) || strings.HasPrefix(origin, "https://"+host)
83+}
added internal/server/profile_handler.go +29 -0
new file mode 100644
@@ -0,0 +1,29 @@
1+package server
2+
3+import (
4+ "net/http"
5+
6+ "github.com/go-chi/chi/v5"
7+)
8+
9+func (s *Server) handleProfile(w http.ResponseWriter, r *http.Request) {
10+ did := chi.URLParam(r, "did")
11+ profileUser, err := s.db.GetUser(r.Context(), did)
12+ if err != nil {
13+ http.Error(w, err.Error(), http.StatusNotFound)
14+ return
15+ }
16+
17+ subs, _ := s.db.ListSubscriptions(r.Context(), did, "", 50, 0)
18+ annotations, _ := s.db.ListAnnotations(r.Context(), "", "", did, 50, 0)
19+ subCount, _ := s.db.GetSubscriptionCount(r.Context(), did)
20+
21+ s.render(w, r, "profile.html", map[string]any{
22+ "User": s.getUserFromSession(r),
23+ "ProfileUser": profileUser,
24+ "Subscriptions": subs,
25+ "Annotations": annotations,
26+ "SubscriptionCount": subCount,
27+ "AnnotationCount": len(annotations),
28+ })
29+}
new file mode 100644
@@ -0,0 +1,29 @@
1+package server
2+
3+import (
4+ "net/http"
5+
6+ "github.com/go-chi/chi/v5"
7+)
8+
9+func (s *Server) handleProfile(w http.ResponseWriter, r *http.Request) {
10+ did := chi.URLParam(r, "did")
11+ profileUser, err := s.db.GetUser(r.Context(), did)
12+ if err != nil {
13+ http.Error(w, err.Error(), http.StatusNotFound)
14+ return
15+ }
16+
17+ subs, _ := s.db.ListSubscriptions(r.Context(), did, "", 50, 0)
18+ annotations, _ := s.db.ListAnnotations(r.Context(), "", "", did, 50, 0)
19+ subCount, _ := s.db.GetSubscriptionCount(r.Context(), did)
20+
21+ s.render(w, r, "profile.html", map[string]any{
22+ "User": s.getUserFromSession(r),
23+ "ProfileUser": profileUser,
24+ "Subscriptions": subs,
25+ "Annotations": annotations,
26+ "SubscriptionCount": subCount,
27+ "AnnotationCount": len(annotations),
28+ })
29+}
added internal/server/server.go +258 -0
new file mode 100644
@@ -0,0 +1,258 @@
1+package server
2+
3+import (
4+ "bytes"
5+ "html/template"
6+ "log/slog"
7+ "net/http"
8+ "path/filepath"
9+ "strings"
10+ "time"
11+
12+ "github.com/go-chi/chi/v5"
13+ "github.com/go-chi/chi/v5/middleware"
14+ "github.com/go-chi/cors"
15+
16+ "pkg.rbrt.fr/glean/internal/atproto"
17+ "pkg.rbrt.fr/glean/internal/db"
18+ "pkg.rbrt.fr/glean/internal/feed"
19+ "pkg.rbrt.fr/glean/internal/sanitize"
20+)
21+
22+func splitString(s, sep string) []string {
23+ return strings.Split(s, sep)
24+}
25+
26+type Server struct {
27+ db *db.DB
28+ router *chi.Mux
29+ templates *template.Template
30+ logger *slog.Logger
31+ oauth *atproto.OAuthConfig
32+ fetcher *feed.Fetcher
33+}
34+
35+func New(database *db.DB, oauth *atproto.OAuthConfig, logger *slog.Logger) *Server {
36+ s := &Server{
37+ db: database,
38+ router: chi.NewRouter(),
39+ logger: logger,
40+ oauth: oauth,
41+ fetcher: feed.NewFetcher(),
42+ }
43+
44+ s.setupMiddleware()
45+ s.setupRoutes()
46+ s.loadTemplates()
47+
48+ return s
49+}
50+
51+func (s *Server) setupMiddleware() {
52+ s.router.Use(middleware.Logger)
53+ s.router.Use(middleware.Recoverer)
54+ s.router.Use(middleware.Compress(5))
55+ s.router.Use(cors.Handler(cors.Options{
56+ AllowedOrigins: []string{"*"},
57+ AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"},
58+ AllowedHeaders: []string{"Accept", "Authorization", "Content-Type"},
59+ MaxAge: 300,
60+ }))
61+ s.router.Use(s.sessionMiddleware)
62+ s.router.Use(s.csrfMiddleware)
63+}
64+
65+func (s *Server) setupRoutes() {
66+ s.router.Get("/", s.handleIndex)
67+
68+ s.router.Route("/dashboard", func(r chi.Router) {
69+ r.Use(s.requireAuth)
70+ r.Get("/", s.handleDashboard)
71+ })
72+
73+ s.router.Route("/feeds", func(r chi.Router) {
74+ r.Use(s.requireAuth)
75+ r.Get("/", s.handleFeeds)
76+ r.Post("/add", s.handleAddFeed)
77+ r.Delete("/remove", s.handleRemoveFeed)
78+ r.Post("/opml/upload", s.handleOPMLUpload)
79+ r.Get("/opml/download", s.handleOPMLDownload)
80+ r.Post("/refresh", s.handleRefreshFeeds)
81+ r.Get("/list", s.handleFeedList)
82+ })
83+
84+ s.router.Route("/articles", func(r chi.Router) {
85+ r.Use(s.requireAuth)
86+ r.Get("/", s.handleArticles)
87+ r.Get("/{id}", s.handleArticleDetail)
88+ r.Post("/{id}/read", s.handleMarkRead)
89+ r.Post("/{id}/unread", s.handleMarkUnread)
90+ r.Post("/{id}/star", s.handleStar)
91+ r.Post("/{id}/unstar", s.handleUnstar)
92+ r.Post("/{id}/like", s.handleLikeArticle)
93+ r.Post("/mark-all-read", s.handleMarkAllRead)
94+ })
95+
96+ s.router.Route("/trending", func(r chi.Router) {
97+ r.Use(s.requireAuth)
98+ r.Get("/", s.handleTrending)
99+ })
100+
101+ s.router.Route("/discover", func(r chi.Router) {
102+ r.Use(s.requireAuth)
103+ r.Get("/", s.handleDiscover)
104+ r.Get("/feeds", s.handleDiscoverFeeds)
105+ r.Get("/people", s.handleDiscoverPeople)
106+ })
107+
108+ s.router.Get("/profile/{did}", s.handleProfile)
109+
110+ s.router.Route("/annotations", func(r chi.Router) {
111+ r.Use(s.requireAuth)
112+ r.Get("/", s.handleAnnotations)
113+ r.Post("/create", s.handleCreateAnnotation)
114+ })
115+
116+ s.router.Get("/auth/login", s.handleAuthLogin)
117+ s.router.Get("/auth/callback", s.handleAuthCallback)
118+ s.router.Post("/auth/logout", s.handleAuthLogout)
119+
120+ xrpc := atproto.NewXRPCHandler(s.db.DB)
121+ s.router.Get("/xrpc/at.glean.listSubscriptions", xrpc.ListSubscriptions)
122+ s.router.Get("/xrpc/at.glean.listAnnotations", xrpc.ListAnnotations)
123+ s.router.Get("/xrpc/at.glean.listLikes", xrpc.ListLikes)
124+ s.router.Get("/xrpc/at.glean.getTrending", xrpc.GetTrending)
125+ s.router.Get("/xrpc/at.glean.getRecommendations", xrpc.GetRecommendations)
126+ s.router.Get("/xrpc/at.glean.listFeedLists", xrpc.ListFeedLists)
127+
128+ s.router.Handle("/static/*", http.StripPrefix("/static/", http.FileServer(http.Dir("static"))))
129+}
130+
131+func (s *Server) loadTemplates() {
132+ fm := template.FuncMap{
133+ "formatDate": func(t time.Time) string {
134+ return t.Format("Jan 02, 2006")
135+ },
136+ "formatDateTime": func(t time.Time) string {
137+ return t.Format("Jan 02, 2006 15:04")
138+ },
139+ "split": func(sv, sep string) []string {
140+ if sv == "" {
141+ return nil
142+ }
143+ var result []string
144+ for _, p := range splitString(sv, sep) {
145+ if p != "" {
146+ result = append(result, p)
147+ }
148+ }
149+ return result
150+ },
151+ "repeat": func(str string, n int) string {
152+ b := strings.Builder{}
153+ for range n {
154+ b.WriteString(str)
155+ }
156+ return b.String()
157+ },
158+ "int": func(n int64) int {
159+ return int(n)
160+ },
161+ "add": func(a, b int) int {
162+ return a + b
163+ },
164+ "sanitizeHTML": func(input string) template.HTML {
165+ return template.HTML(sanitize.HTML(input))
166+ },
167+ "plainText": func(input string) string {
168+ return sanitize.PlainText(input)
169+ },
170+ "now": func() time.Time {
171+ return time.Now()
172+ },
173+ "activeClass": func(activePath, linkPath string) string {
174+ if activePath == linkPath || (len(activePath) > len(linkPath) && activePath[:len(linkPath)+1] == linkPath+"/") {
175+ return "bg-spot-hover text-spot-text font-bold"
176+ }
177+ return "text-spot-secondary"
178+ },
179+ "csrfInput": func(token any) template.HTML {
180+ s, ok := token.(string)
181+ if !ok || s == "" {
182+ return ""
183+ }
184+ return template.HTML(`<input type="hidden" name="csrf_token" value="` + s + `">`)
185+ },
186+ }
187+
188+ var allFiles []string
189+ matches, _ := filepath.Glob("internal/tmpl/*.html")
190+ allFiles = append(allFiles, matches...)
191+ partials, _ := filepath.Glob("internal/tmpl/partials/*.html")
192+ allFiles = append(allFiles, partials...)
193+
194+ var err error
195+ s.templates, err = template.New("").Funcs(fm).ParseFiles(allFiles...)
196+ if err != nil {
197+ s.logger.Error("failed to load templates", "error", err)
198+ }
199+}
200+
201+func (s *Server) pdsClientForUser(r *http.Request) *atproto.Client {
202+ session := s.getSessionData(r)
203+ if session == nil || session.AccessToken == "" || session.PDSURL == "" {
204+ return nil
205+ }
206+ return atproto.NewClient(session.PDSURL, session.AccessToken)
207+}
208+
209+func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
210+ s.router.ServeHTTP(w, r)
211+}
212+
213+func (s *Server) render(w http.ResponseWriter, r *http.Request, name string, data map[string]any) {
214+ if data == nil {
215+ data = map[string]any{}
216+ }
217+
218+ if cookie, err := r.Cookie("glean_csrf"); err == nil {
219+ data["CSRFToken"] = cookie.Value
220+ }
221+
222+ if r.Header.Get("HX-Request") == "true" {
223+ if err := s.templates.ExecuteTemplate(w, name, data); err != nil {
224+ s.logger.Error("template error", "error", err, "template", name)
225+ http.Error(w, err.Error(), http.StatusInternalServerError)
226+ }
227+ return
228+ }
229+
230+ var buf bytes.Buffer
231+ if err := s.templates.ExecuteTemplate(&buf, name, data); err != nil {
232+ s.logger.Error("template error", "error", err, "template", name)
233+ http.Error(w, err.Error(), http.StatusInternalServerError)
234+ return
235+ }
236+
237+ path := r.URL.Path
238+ if len(path) > 1 {
239+ path = strings.TrimRight(path, "/")
240+ }
241+ baseData := map[string]any{
242+ "Content": template.HTML(buf.String()),
243+ "ActivePath": path,
244+ }
245+ if data != nil {
246+ if u, ok := data["User"]; ok {
247+ baseData["User"] = u
248+ }
249+ if csrf, ok := data["CSRFToken"]; ok {
250+ baseData["CSRFToken"] = csrf
251+ }
252+ }
253+
254+ if err := s.templates.ExecuteTemplate(w, "base.html", baseData); err != nil {
255+ s.logger.Error("template error", "error", err, "template", "base.html")
256+ http.Error(w, err.Error(), http.StatusInternalServerError)
257+ }
258+}
new file mode 100644
@@ -0,0 +1,258 @@
1+package server
2+
3+import (
4+ "bytes"
5+ "html/template"
6+ "log/slog"
7+ "net/http"
8+ "path/filepath"
9+ "strings"
10+ "time"
11+
12+ "github.com/go-chi/chi/v5"
13+ "github.com/go-chi/chi/v5/middleware"
14+ "github.com/go-chi/cors"
15+
16+ "pkg.rbrt.fr/glean/internal/atproto"
17+ "pkg.rbrt.fr/glean/internal/db"
18+ "pkg.rbrt.fr/glean/internal/feed"
19+ "pkg.rbrt.fr/glean/internal/sanitize"
20+)
21+
22+func splitString(s, sep string) []string {
23+ return strings.Split(s, sep)
24+}
25+
26+type Server struct {
27+ db *db.DB
28+ router *chi.Mux
29+ templates *template.Template
30+ logger *slog.Logger
31+ oauth *atproto.OAuthConfig
32+ fetcher *feed.Fetcher
33+}
34+
35+func New(database *db.DB, oauth *atproto.OAuthConfig, logger *slog.Logger) *Server {
36+ s := &Server{
37+ db: database,
38+ router: chi.NewRouter(),
39+ logger: logger,
40+ oauth: oauth,
41+ fetcher: feed.NewFetcher(),
42+ }
43+
44+ s.setupMiddleware()
45+ s.setupRoutes()
46+ s.loadTemplates()
47+
48+ return s
49+}
50+
51+func (s *Server) setupMiddleware() {
52+ s.router.Use(middleware.Logger)
53+ s.router.Use(middleware.Recoverer)
54+ s.router.Use(middleware.Compress(5))
55+ s.router.Use(cors.Handler(cors.Options{
56+ AllowedOrigins: []string{"*"},
57+ AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"},
58+ AllowedHeaders: []string{"Accept", "Authorization", "Content-Type"},
59+ MaxAge: 300,
60+ }))
61+ s.router.Use(s.sessionMiddleware)
62+ s.router.Use(s.csrfMiddleware)
63+}
64+
65+func (s *Server) setupRoutes() {
66+ s.router.Get("/", s.handleIndex)
67+
68+ s.router.Route("/dashboard", func(r chi.Router) {
69+ r.Use(s.requireAuth)
70+ r.Get("/", s.handleDashboard)
71+ })
72+
73+ s.router.Route("/feeds", func(r chi.Router) {
74+ r.Use(s.requireAuth)
75+ r.Get("/", s.handleFeeds)
76+ r.Post("/add", s.handleAddFeed)
77+ r.Delete("/remove", s.handleRemoveFeed)
78+ r.Post("/opml/upload", s.handleOPMLUpload)
79+ r.Get("/opml/download", s.handleOPMLDownload)
80+ r.Post("/refresh", s.handleRefreshFeeds)
81+ r.Get("/list", s.handleFeedList)
82+ })
83+
84+ s.router.Route("/articles", func(r chi.Router) {
85+ r.Use(s.requireAuth)
86+ r.Get("/", s.handleArticles)
87+ r.Get("/{id}", s.handleArticleDetail)
88+ r.Post("/{id}/read", s.handleMarkRead)
89+ r.Post("/{id}/unread", s.handleMarkUnread)
90+ r.Post("/{id}/star", s.handleStar)
91+ r.Post("/{id}/unstar", s.handleUnstar)
92+ r.Post("/{id}/like", s.handleLikeArticle)
93+ r.Post("/mark-all-read", s.handleMarkAllRead)
94+ })
95+
96+ s.router.Route("/trending", func(r chi.Router) {
97+ r.Use(s.requireAuth)
98+ r.Get("/", s.handleTrending)
99+ })
100+
101+ s.router.Route("/discover", func(r chi.Router) {
102+ r.Use(s.requireAuth)
103+ r.Get("/", s.handleDiscover)
104+ r.Get("/feeds", s.handleDiscoverFeeds)
105+ r.Get("/people", s.handleDiscoverPeople)
106+ })
107+
108+ s.router.Get("/profile/{did}", s.handleProfile)
109+
110+ s.router.Route("/annotations", func(r chi.Router) {
111+ r.Use(s.requireAuth)
112+ r.Get("/", s.handleAnnotations)
113+ r.Post("/create", s.handleCreateAnnotation)
114+ })
115+
116+ s.router.Get("/auth/login", s.handleAuthLogin)
117+ s.router.Get("/auth/callback", s.handleAuthCallback)
118+ s.router.Post("/auth/logout", s.handleAuthLogout)
119+
120+ xrpc := atproto.NewXRPCHandler(s.db.DB)
121+ s.router.Get("/xrpc/at.glean.listSubscriptions", xrpc.ListSubscriptions)
122+ s.router.Get("/xrpc/at.glean.listAnnotations", xrpc.ListAnnotations)
123+ s.router.Get("/xrpc/at.glean.listLikes", xrpc.ListLikes)
124+ s.router.Get("/xrpc/at.glean.getTrending", xrpc.GetTrending)
125+ s.router.Get("/xrpc/at.glean.getRecommendations", xrpc.GetRecommendations)
126+ s.router.Get("/xrpc/at.glean.listFeedLists", xrpc.ListFeedLists)
127+
128+ s.router.Handle("/static/*", http.StripPrefix("/static/", http.FileServer(http.Dir("static"))))
129+}
130+
131+func (s *Server) loadTemplates() {
132+ fm := template.FuncMap{
133+ "formatDate": func(t time.Time) string {
134+ return t.Format("Jan 02, 2006")
135+ },
136+ "formatDateTime": func(t time.Time) string {
137+ return t.Format("Jan 02, 2006 15:04")
138+ },
139+ "split": func(sv, sep string) []string {
140+ if sv == "" {
141+ return nil
142+ }
143+ var result []string
144+ for _, p := range splitString(sv, sep) {
145+ if p != "" {
146+ result = append(result, p)
147+ }
148+ }
149+ return result
150+ },
151+ "repeat": func(str string, n int) string {
152+ b := strings.Builder{}
153+ for range n {
154+ b.WriteString(str)
155+ }
156+ return b.String()
157+ },
158+ "int": func(n int64) int {
159+ return int(n)
160+ },
161+ "add": func(a, b int) int {
162+ return a + b
163+ },
164+ "sanitizeHTML": func(input string) template.HTML {
165+ return template.HTML(sanitize.HTML(input))
166+ },
167+ "plainText": func(input string) string {
168+ return sanitize.PlainText(input)
169+ },
170+ "now": func() time.Time {
171+ return time.Now()
172+ },
173+ "activeClass": func(activePath, linkPath string) string {
174+ if activePath == linkPath || (len(activePath) > len(linkPath) && activePath[:len(linkPath)+1] == linkPath+"/") {
175+ return "bg-spot-hover text-spot-text font-bold"
176+ }
177+ return "text-spot-secondary"
178+ },
179+ "csrfInput": func(token any) template.HTML {
180+ s, ok := token.(string)
181+ if !ok || s == "" {
182+ return ""
183+ }
184+ return template.HTML(`<input type="hidden" name="csrf_token" value="` + s + `">`)
185+ },
186+ }
187+
188+ var allFiles []string
189+ matches, _ := filepath.Glob("internal/tmpl/*.html")
190+ allFiles = append(allFiles, matches...)
191+ partials, _ := filepath.Glob("internal/tmpl/partials/*.html")
192+ allFiles = append(allFiles, partials...)
193+
194+ var err error
195+ s.templates, err = template.New("").Funcs(fm).ParseFiles(allFiles...)
196+ if err != nil {
197+ s.logger.Error("failed to load templates", "error", err)
198+ }
199+}
200+
201+func (s *Server) pdsClientForUser(r *http.Request) *atproto.Client {
202+ session := s.getSessionData(r)
203+ if session == nil || session.AccessToken == "" || session.PDSURL == "" {
204+ return nil
205+ }
206+ return atproto.NewClient(session.PDSURL, session.AccessToken)
207+}
208+
209+func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
210+ s.router.ServeHTTP(w, r)
211+}
212+
213+func (s *Server) render(w http.ResponseWriter, r *http.Request, name string, data map[string]any) {
214+ if data == nil {
215+ data = map[string]any{}
216+ }
217+
218+ if cookie, err := r.Cookie("glean_csrf"); err == nil {
219+ data["CSRFToken"] = cookie.Value
220+ }
221+
222+ if r.Header.Get("HX-Request") == "true" {
223+ if err := s.templates.ExecuteTemplate(w, name, data); err != nil {
224+ s.logger.Error("template error", "error", err, "template", name)
225+ http.Error(w, err.Error(), http.StatusInternalServerError)
226+ }
227+ return
228+ }
229+
230+ var buf bytes.Buffer
231+ if err := s.templates.ExecuteTemplate(&buf, name, data); err != nil {
232+ s.logger.Error("template error", "error", err, "template", name)
233+ http.Error(w, err.Error(), http.StatusInternalServerError)
234+ return
235+ }
236+
237+ path := r.URL.Path
238+ if len(path) > 1 {
239+ path = strings.TrimRight(path, "/")
240+ }
241+ baseData := map[string]any{
242+ "Content": template.HTML(buf.String()),
243+ "ActivePath": path,
244+ }
245+ if data != nil {
246+ if u, ok := data["User"]; ok {
247+ baseData["User"] = u
248+ }
249+ if csrf, ok := data["CSRFToken"]; ok {
250+ baseData["CSRFToken"] = csrf
251+ }
252+ }
253+
254+ if err := s.templates.ExecuteTemplate(w, "base.html", baseData); err != nil {
255+ s.logger.Error("template error", "error", err, "template", "base.html")
256+ http.Error(w, err.Error(), http.StatusInternalServerError)
257+ }
258+}
added internal/server/session.go +147 -0
new file mode 100644
@@ -0,0 +1,147 @@
1+package server
2+
3+import (
4+ "context"
5+ "crypto/hmac"
6+ "crypto/sha256"
7+ "encoding/base64"
8+ "encoding/json"
9+ "net/http"
10+ "os"
11+
12+ "pkg.rbrt.fr/glean/internal/db"
13+)
14+
15+type ctxKey struct{}
16+
17+func contextWithUser(ctx context.Context, user *db.User) context.Context {
18+ return context.WithValue(ctx, ctxKey{}, user)
19+}
20+
21+func currentUser(r *http.Request) *db.User {
22+ user, _ := r.Context().Value(ctxKey{}).(*db.User)
23+ return user
24+}
25+
26+func (s *Server) getUserFromSession(r *http.Request) *db.User {
27+ cookie, err := r.Cookie("glean_session")
28+ if err != nil {
29+ return nil
30+ }
31+
32+ data, err := decodeSession(cookie.Value)
33+ if err != nil {
34+ return nil
35+ }
36+
37+ user, err := s.db.GetUser(r.Context(), data.DID)
38+ if err != nil {
39+ return nil
40+ }
41+
42+ return user
43+}
44+
45+func (s *Server) setUserSession(w http.ResponseWriter, user *db.User) {
46+ data := sessionData{DID: user.DID}
47+ encoded, err := encodeSession(data)
48+ if err != nil {
49+ s.logger.Error("failed to encode session", "error", err)
50+ return
51+ }
52+
53+ http.SetCookie(w, &http.Cookie{
54+ Name: "glean_session",
55+ Value: encoded,
56+ Path: "/",
57+ MaxAge: 86400 * 30,
58+ HttpOnly: true,
59+ SameSite: http.SameSiteLaxMode,
60+ })
61+}
62+
63+func (s *Server) clearUserSession(w http.ResponseWriter) {
64+ http.SetCookie(w, &http.Cookie{
65+ Name: "glean_session",
66+ Value: "",
67+ Path: "/",
68+ MaxAge: -1,
69+ HttpOnly: true,
70+ SameSite: http.SameSiteLaxMode,
71+ })
72+}
73+
74+type sessionData struct {
75+ DID string `json:"did"`
76+ PDSURL string `json:"pds_url,omitempty"`
77+ AccessToken string `json:"access_token,omitempty"`
78+}
79+
80+func (s *Server) getSessionData(r *http.Request) *sessionData {
81+ cookie, err := r.Cookie("glean_session")
82+ if err != nil {
83+ return nil
84+ }
85+ data, err := decodeSession(cookie.Value)
86+ if err != nil {
87+ return nil
88+ }
89+ return data
90+}
91+
92+func sessionKey() []byte {
93+ key := os.Getenv("GLEAN_SESSION_KEY")
94+ if key == "" {
95+ key = "default-dev-key-change-in-production"
96+ }
97+ return []byte(key)
98+}
99+
100+func encodeSession(data sessionData) (string, error) {
101+ payload, err := json.Marshal(data)
102+ if err != nil {
103+ return "", err
104+ }
105+
106+ mac := hmac.New(sha256.New, sessionKey())
107+ mac.Write(payload)
108+ sig := mac.Sum(nil)
109+
110+ raw := append(payload, sig...)
111+ return base64.URLEncoding.EncodeToString(raw), nil
112+}
113+
114+func decodeSession(encoded string) (*sessionData, error) {
115+ raw, err := base64.URLEncoding.DecodeString(encoded)
116+ if err != nil {
117+ return nil, errInvalidSession
118+ }
119+
120+ if len(raw) < sha256.Size {
121+ return nil, errInvalidSession
122+ }
123+
124+ payload := raw[:len(raw)-sha256.Size]
125+ sig := raw[len(raw)-sha256.Size:]
126+
127+ mac := hmac.New(sha256.New, sessionKey())
128+ mac.Write(payload)
129+ expectedSig := mac.Sum(nil)
130+
131+ if !hmac.Equal(sig, expectedSig) {
132+ return nil, errInvalidSession
133+ }
134+
135+ var data sessionData
136+ if err := json.Unmarshal(payload, &data); err != nil {
137+ return nil, errInvalidSession
138+ }
139+
140+ return &data, nil
141+}
142+
143+var errInvalidSession = &invalidSessionError{}
144+
145+type invalidSessionError struct{}
146+
147+func (e *invalidSessionError) Error() string { return "invalid session" }
new file mode 100644
@@ -0,0 +1,147 @@
1+package server
2+
3+import (
4+ "context"
5+ "crypto/hmac"
6+ "crypto/sha256"
7+ "encoding/base64"
8+ "encoding/json"
9+ "net/http"
10+ "os"
11+
12+ "pkg.rbrt.fr/glean/internal/db"
13+)
14+
15+type ctxKey struct{}
16+
17+func contextWithUser(ctx context.Context, user *db.User) context.Context {
18+ return context.WithValue(ctx, ctxKey{}, user)
19+}
20+
21+func currentUser(r *http.Request) *db.User {
22+ user, _ := r.Context().Value(ctxKey{}).(*db.User)
23+ return user
24+}
25+
26+func (s *Server) getUserFromSession(r *http.Request) *db.User {
27+ cookie, err := r.Cookie("glean_session")
28+ if err != nil {
29+ return nil
30+ }
31+
32+ data, err := decodeSession(cookie.Value)
33+ if err != nil {
34+ return nil
35+ }
36+
37+ user, err := s.db.GetUser(r.Context(), data.DID)
38+ if err != nil {
39+ return nil
40+ }
41+
42+ return user
43+}
44+
45+func (s *Server) setUserSession(w http.ResponseWriter, user *db.User) {
46+ data := sessionData{DID: user.DID}
47+ encoded, err := encodeSession(data)
48+ if err != nil {
49+ s.logger.Error("failed to encode session", "error", err)
50+ return
51+ }
52+
53+ http.SetCookie(w, &http.Cookie{
54+ Name: "glean_session",
55+ Value: encoded,
56+ Path: "/",
57+ MaxAge: 86400 * 30,
58+ HttpOnly: true,
59+ SameSite: http.SameSiteLaxMode,
60+ })
61+}
62+
63+func (s *Server) clearUserSession(w http.ResponseWriter) {
64+ http.SetCookie(w, &http.Cookie{
65+ Name: "glean_session",
66+ Value: "",
67+ Path: "/",
68+ MaxAge: -1,
69+ HttpOnly: true,
70+ SameSite: http.SameSiteLaxMode,
71+ })
72+}
73+
74+type sessionData struct {
75+ DID string `json:"did"`
76+ PDSURL string `json:"pds_url,omitempty"`
77+ AccessToken string `json:"access_token,omitempty"`
78+}
79+
80+func (s *Server) getSessionData(r *http.Request) *sessionData {
81+ cookie, err := r.Cookie("glean_session")
82+ if err != nil {
83+ return nil
84+ }
85+ data, err := decodeSession(cookie.Value)
86+ if err != nil {
87+ return nil
88+ }
89+ return data
90+}
91+
92+func sessionKey() []byte {
93+ key := os.Getenv("GLEAN_SESSION_KEY")
94+ if key == "" {
95+ key = "default-dev-key-change-in-production"
96+ }
97+ return []byte(key)
98+}
99+
100+func encodeSession(data sessionData) (string, error) {
101+ payload, err := json.Marshal(data)
102+ if err != nil {
103+ return "", err
104+ }
105+
106+ mac := hmac.New(sha256.New, sessionKey())
107+ mac.Write(payload)
108+ sig := mac.Sum(nil)
109+
110+ raw := append(payload, sig...)
111+ return base64.URLEncoding.EncodeToString(raw), nil
112+}
113+
114+func decodeSession(encoded string) (*sessionData, error) {
115+ raw, err := base64.URLEncoding.DecodeString(encoded)
116+ if err != nil {
117+ return nil, errInvalidSession
118+ }
119+
120+ if len(raw) < sha256.Size {
121+ return nil, errInvalidSession
122+ }
123+
124+ payload := raw[:len(raw)-sha256.Size]
125+ sig := raw[len(raw)-sha256.Size:]
126+
127+ mac := hmac.New(sha256.New, sessionKey())
128+ mac.Write(payload)
129+ expectedSig := mac.Sum(nil)
130+
131+ if !hmac.Equal(sig, expectedSig) {
132+ return nil, errInvalidSession
133+ }
134+
135+ var data sessionData
136+ if err := json.Unmarshal(payload, &data); err != nil {
137+ return nil, errInvalidSession
138+ }
139+
140+ return &data, nil
141+}
142+
143+var errInvalidSession = &invalidSessionError{}
144+
145+type invalidSessionError struct{}
146+
147+func (e *invalidSessionError) Error() string { return "invalid session" }
added internal/tmpl/annotations.html +33 -0
new file mode 100644
@@ -0,0 +1,33 @@
1+{{define "annotations.html"}}
2+<div class="mb-6">
3+ <h1 class="text-2xl font-bold font-title text-spot-text">Annotations</h1>
4+ <p class="text-spot-secondary text-sm mt-1">Your notes and highlights on articles.</p>
5+</div>
6+
7+{{if .ArticleURL}}
8+<div class="mb-4 text-sm text-spot-secondary">
9+ Filtering by: <strong class="text-spot-text">{{.ArticleURL}}</strong>
10+ <a href="/annotations" class="text-spot-purple ml-2 hover:brightness-110">Clear</a>
11+</div>
12+{{end}}
13+
14+<div class="space-y-4" id="annotations-list">
15+ {{range .Annotations}}
16+ {{template "annotation-card.html" .}}
17+ {{else}}
18+ <div class="text-center text-spot-secondary py-12">
19+ <p>No annotations yet.</p>
20+ <p class="text-sm mt-1">Highlight and annotate articles as you read.</p>
21+ </div>
22+ {{end}}
23+</div>
24+
25+{{if .HasMore}}
26+<div class="text-center py-4">
27+ <button hx-get="/annotations?offset={{.NextOffset}}{{if .ArticleURL}}&article={{.ArticleURL}}{{end}}"
28+ hx-target="#annotations-list" hx-swap="beforeend"
29+ hx-select=".annotation-card"
30+ class="text-sm text-spot-secondary hover:text-spot-purple transition">Load more</button>
31+</div>
32+{{end}}
33+{{end}}
new file mode 100644
@@ -0,0 +1,33 @@
1+{{define "annotations.html"}}
2+<div class="mb-6">
3+ <h1 class="text-2xl font-bold font-title text-spot-text">Annotations</h1>
4+ <p class="text-spot-secondary text-sm mt-1">Your notes and highlights on articles.</p>
5+</div>
6+
7+{{if .ArticleURL}}
8+<div class="mb-4 text-sm text-spot-secondary">
9+ Filtering by: <strong class="text-spot-text">{{.ArticleURL}}</strong>
10+ <a href="/annotations" class="text-spot-purple ml-2 hover:brightness-110">Clear</a>
11+</div>
12+{{end}}
13+
14+<div class="space-y-4" id="annotations-list">
15+ {{range .Annotations}}
16+ {{template "annotation-card.html" .}}
17+ {{else}}
18+ <div class="text-center text-spot-secondary py-12">
19+ <p>No annotations yet.</p>
20+ <p class="text-sm mt-1">Highlight and annotate articles as you read.</p>
21+ </div>
22+ {{end}}
23+</div>
24+
25+{{if .HasMore}}
26+<div class="text-center py-4">
27+ <button hx-get="/annotations?offset={{.NextOffset}}{{if .ArticleURL}}&article={{.ArticleURL}}{{end}}"
28+ hx-target="#annotations-list" hx-swap="beforeend"
29+ hx-select=".annotation-card"
30+ class="text-sm text-spot-secondary hover:text-spot-purple transition">Load more</button>
31+</div>
32+{{end}}
33+{{end}}
added internal/tmpl/article_detail.html +102 -0
new file mode 100644
@@ -0,0 +1,102 @@
1+{{define "article_detail.html"}}
2+<div class="max-w-3xl mx-auto">
3+ <a href="/articles" class="text-sm text-spot-secondary hover:text-spot-text mb-4 inline-block transition">&larr; Back to articles</a>
4+
5+ <article>
6+ <h1 class="text-3xl font-bold font-title leading-tight">
7+ {{if .Article.URL.Valid}}<a href="{{.Article.URL.String}}" target="_blank" rel="noopener noreferrer" class="text-spot-text hover:text-spot-purple transition">{{.Article.Title}}</a>
8+ {{else}}<span class="text-spot-text">{{.Article.Title}}</span>{{end}}
9+ </h1>
10+
11+ <div class="flex items-center gap-3 mt-3 text-sm text-spot-secondary">
12+ {{if .Article.Author.Valid}}<span>{{.Article.Author.String}}</span>{{end}}
13+ {{if .Article.Published.Valid}}<span>{{.Article.Published.Time.Format "Jan 2, 2006 15:04"}}</span>{{end}}
14+ {{if .Feed}}
15+ <a href="/articles?feed={{.Feed.FeedURL}}" class="hover:text-spot-purple transition">{{if .Feed.Title.Valid}}{{.Feed.Title.String}}{{else}}{{.Feed.FeedURL}}{{end}}</a>
16+ {{end}}
17+ </div>
18+
19+ <div class="flex items-center gap-3 mt-4">
20+ <button hx-post="/articles/{{.Article.ID}}/like" hx-target="#like-btn" hx-swap="outerHTML"
21+ id="like-btn"
22+ class="text-lg {{if .HasLiked}}text-spot-red{{else}}text-spot-muted hover:text-spot-red{{end}} transition">
23+ &#9829; <span class="text-sm text-spot-secondary">{{.LikeCount}}</span>
24+ </button>
25+
26+ <a href="https://bsky.app/intent/compose?text={{.Article.Title}}%20{{if .Article.URL.Valid}}{{.Article.URL.String}}{{end}}"
27+ target="_blank" rel="noopener noreferrer"
28+ class="text-xs border border-spot-outline text-spot-text rounded-pill px-3 py-1 hover:border-spot-text uppercase tracking-button transition inline-flex items-center gap-1">
29+ 🦋
30+ Share
31+ </a>
32+
33+ <button hx-post="/articles/{{.Article.ID}}/{{if .ReadState.IsRead}}unread{{else}}read{{end}}"
34+ hx-target="#read-btn" hx-swap="outerHTML"
35+ id="read-btn"
36+ class="text-xs border border-spot-outline text-spot-text rounded-pill px-3 py-1 hover:border-spot-text uppercase tracking-button transition">
37+ {{if .ReadState.IsRead}}Mark unread{{else}}Mark read{{end}}
38+ </button>
39+ </div>
40+
41+ <hr class="my-6 border-spot-divider-30">
42+
43+ {{if .Article.Content.Valid}}
44+ <div class="article-body">
45+ {{sanitizeHTML .Article.Content.String}}
46+ </div>
47+ {{else if .Article.Summary.Valid}}
48+ <div class="article-body">
49+ {{sanitizeHTML .Article.Summary.String}}
50+ </div>
51+ {{else}}
52+ <p class="text-spot-secondary">No content available. <a href="{{.Article.URL.String}}" class="text-spot-purple hover:underline">Read the original article.</a></p>
53+ {{end}}
54+ </article>
55+
56+ <hr class="my-8 border-spot-divider-30">
57+
58+ <section>
59+ <h2 class="text-lg font-semibold text-spot-text mb-4">Annotations</h2>
60+
61+ <form hx-post="/annotations/create" hx-target="#annotations-list" hx-swap="beforeend"
62+ hx-on::after-request="this.reset()"
63+ class="bg-spot-surface rounded-lg shadow-spot p-4 mb-4 space-y-3">
64+ {{csrfInput .CSRFToken}}
65+ <input type="hidden" name="feed_url" value="{{.Article.FeedURL}}">
66+ <input type="hidden" name="article_url" value="{{if .Article.URL.Valid}}{{.Article.URL.String}}{{end}}">
67+ <div>
68+ <textarea name="note" rows="2" placeholder="Add a note..."
69+ class="w-full bg-spot-hover text-spot-text rounded-lg px-4 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-spot-purple placeholder:text-spot-placeholder resize-none"></textarea>
70+ </div>
71+ <div class="flex gap-2">
72+ <input type="text" name="quote" placeholder="Quote (optional)"
73+ class="flex-1 bg-spot-hover text-spot-text rounded-lg px-4 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-spot-purple placeholder:text-spot-placeholder">
74+ <input type="text" name="tags" placeholder="Tags (comma separated)"
75+ class="flex-1 bg-spot-hover text-spot-text rounded-lg px-4 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-spot-purple placeholder:text-spot-placeholder">
76+ </div>
77+ <button type="submit" class="bg-spot-purple text-spot-bg rounded-pill px-5 py-2 text-sm font-bold uppercase tracking-button hover:brightness-110 transition">Annotate</button>
78+ </form>
79+
80+ <div id="annotations-list" class="space-y-3">
81+ {{range .Annotations}}
82+ {{template "annotation-card.html" .}}
83+ {{else}}
84+ <p class="text-sm text-spot-secondary">No annotations yet. Be the first to add one.</p>
85+ {{end}}
86+ </div>
87+ </section>
88+</div>
89+
90+<script>
91+document.addEventListener('keydown', function(e) {
92+ if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
93+ if (e.key === 'l') {
94+ document.getElementById('like-btn') && document.getElementById('like-btn').click();
95+ } else if (e.key === 'm') {
96+ document.getElementById('read-btn') && document.getElementById('read-btn').click();
97+ } else if (e.key === 'Escape') {
98+ window.location.href = '/articles';
99+ }
100+});
101+</script>
102+{{end}}
new file mode 100644
@@ -0,0 +1,102 @@
1+{{define "article_detail.html"}}
2+<div class="max-w-3xl mx-auto">
3+ <a href="/articles" class="text-sm text-spot-secondary hover:text-spot-text mb-4 inline-block transition">&larr; Back to articles</a>
4+
5+ <article>
6+ <h1 class="text-3xl font-bold font-title leading-tight">
7+ {{if .Article.URL.Valid}}<a href="{{.Article.URL.String}}" target="_blank" rel="noopener noreferrer" class="text-spot-text hover:text-spot-purple transition">{{.Article.Title}}</a>
8+ {{else}}<span class="text-spot-text">{{.Article.Title}}</span>{{end}}
9+ </h1>
10+
11+ <div class="flex items-center gap-3 mt-3 text-sm text-spot-secondary">
12+ {{if .Article.Author.Valid}}<span>{{.Article.Author.String}}</span>{{end}}
13+ {{if .Article.Published.Valid}}<span>{{.Article.Published.Time.Format "Jan 2, 2006 15:04"}}</span>{{end}}
14+ {{if .Feed}}
15+ <a href="/articles?feed={{.Feed.FeedURL}}" class="hover:text-spot-purple transition">{{if .Feed.Title.Valid}}{{.Feed.Title.String}}{{else}}{{.Feed.FeedURL}}{{end}}</a>
16+ {{end}}
17+ </div>
18+
19+ <div class="flex items-center gap-3 mt-4">
20+ <button hx-post="/articles/{{.Article.ID}}/like" hx-target="#like-btn" hx-swap="outerHTML"
21+ id="like-btn"
22+ class="text-lg {{if .HasLiked}}text-spot-red{{else}}text-spot-muted hover:text-spot-red{{end}} transition">
23+ &#9829; <span class="text-sm text-spot-secondary">{{.LikeCount}}</span>
24+ </button>
25+
26+ <a href="https://bsky.app/intent/compose?text={{.Article.Title}}%20{{if .Article.URL.Valid}}{{.Article.URL.String}}{{end}}"
27+ target="_blank" rel="noopener noreferrer"
28+ class="text-xs border border-spot-outline text-spot-text rounded-pill px-3 py-1 hover:border-spot-text uppercase tracking-button transition inline-flex items-center gap-1">
29+ 🦋
30+ Share
31+ </a>
32+
33+ <button hx-post="/articles/{{.Article.ID}}/{{if .ReadState.IsRead}}unread{{else}}read{{end}}"
34+ hx-target="#read-btn" hx-swap="outerHTML"
35+ id="read-btn"
36+ class="text-xs border border-spot-outline text-spot-text rounded-pill px-3 py-1 hover:border-spot-text uppercase tracking-button transition">
37+ {{if .ReadState.IsRead}}Mark unread{{else}}Mark read{{end}}
38+ </button>
39+ </div>
40+
41+ <hr class="my-6 border-spot-divider-30">
42+
43+ {{if .Article.Content.Valid}}
44+ <div class="article-body">
45+ {{sanitizeHTML .Article.Content.String}}
46+ </div>
47+ {{else if .Article.Summary.Valid}}
48+ <div class="article-body">
49+ {{sanitizeHTML .Article.Summary.String}}
50+ </div>
51+ {{else}}
52+ <p class="text-spot-secondary">No content available. <a href="{{.Article.URL.String}}" class="text-spot-purple hover:underline">Read the original article.</a></p>
53+ {{end}}
54+ </article>
55+
56+ <hr class="my-8 border-spot-divider-30">
57+
58+ <section>
59+ <h2 class="text-lg font-semibold text-spot-text mb-4">Annotations</h2>
60+
61+ <form hx-post="/annotations/create" hx-target="#annotations-list" hx-swap="beforeend"
62+ hx-on::after-request="this.reset()"
63+ class="bg-spot-surface rounded-lg shadow-spot p-4 mb-4 space-y-3">
64+ {{csrfInput .CSRFToken}}
65+ <input type="hidden" name="feed_url" value="{{.Article.FeedURL}}">
66+ <input type="hidden" name="article_url" value="{{if .Article.URL.Valid}}{{.Article.URL.String}}{{end}}">
67+ <div>
68+ <textarea name="note" rows="2" placeholder="Add a note..."
69+ class="w-full bg-spot-hover text-spot-text rounded-lg px-4 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-spot-purple placeholder:text-spot-placeholder resize-none"></textarea>
70+ </div>
71+ <div class="flex gap-2">
72+ <input type="text" name="quote" placeholder="Quote (optional)"
73+ class="flex-1 bg-spot-hover text-spot-text rounded-lg px-4 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-spot-purple placeholder:text-spot-placeholder">
74+ <input type="text" name="tags" placeholder="Tags (comma separated)"
75+ class="flex-1 bg-spot-hover text-spot-text rounded-lg px-4 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-spot-purple placeholder:text-spot-placeholder">
76+ </div>
77+ <button type="submit" class="bg-spot-purple text-spot-bg rounded-pill px-5 py-2 text-sm font-bold uppercase tracking-button hover:brightness-110 transition">Annotate</button>
78+ </form>
79+
80+ <div id="annotations-list" class="space-y-3">
81+ {{range .Annotations}}
82+ {{template "annotation-card.html" .}}
83+ {{else}}
84+ <p class="text-sm text-spot-secondary">No annotations yet. Be the first to add one.</p>
85+ {{end}}
86+ </div>
87+ </section>
88+</div>
89+
90+<script>
91+document.addEventListener('keydown', function(e) {
92+ if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
93+ if (e.key === 'l') {
94+ document.getElementById('like-btn') && document.getElementById('like-btn').click();
95+ } else if (e.key === 'm') {
96+ document.getElementById('read-btn') && document.getElementById('read-btn').click();
97+ } else if (e.key === 'Escape') {
98+ window.location.href = '/articles';
99+ }
100+});
101+</script>
102+{{end}}
added internal/tmpl/articles.html +29 -0
new file mode 100644
@@ -0,0 +1,29 @@
1+{{define "articles.html"}}
2+ <div class="flex items-center justify-between mb-6">
3+ <h1 class="text-2xl font-bold font-title text-spot-text">Articles</h1>
4+ <form hx-post="/articles/mark-all-read" hx-confirm="Mark all articles as read?">
5+ {{csrfInput .CSRFToken}}
6+ {{if .FeedURL}}<input type="hidden" name="feed" value="{{.FeedURL}}">{{end}}
7+ <button type="submit" class="border border-spot-outline text-spot-text rounded-pill px-4 py-1.5 text-xs font-bold uppercase tracking-button hover:border-spot-text transition">Mark all read</button>
8+ </form>
9+ </div>
10+
11+<div id="article-list" class="space-y-3">
12+ {{range .Articles}}
13+ {{template "article-card.html" .}}
14+ {{else}}
15+ <div class="text-center text-spot-secondary py-12">No articles found.</div>
16+ {{end}}
17+</div>
18+
19+{{if .HasMore}}
20+<div class="text-center py-4">
21+ <button hx-get="/articles?offset={{.NextOffset}}{{if .FeedURL}}&feed={{.FeedURL}}{{end}}{{if .Starred}}&starred=1{{end}}"
22+ hx-target="#article-list" hx-swap="beforeend"
23+ hx-select="article"
24+ class="text-sm text-spot-secondary hover:text-spot-purple transition">
25+ Load more
26+ </button>
27+</div>
28+{{end}}
29+{{end}}
new file mode 100644
@@ -0,0 +1,29 @@
1+{{define "articles.html"}}
2+ <div class="flex items-center justify-between mb-6">
3+ <h1 class="text-2xl font-bold font-title text-spot-text">Articles</h1>
4+ <form hx-post="/articles/mark-all-read" hx-confirm="Mark all articles as read?">
5+ {{csrfInput .CSRFToken}}
6+ {{if .FeedURL}}<input type="hidden" name="feed" value="{{.FeedURL}}">{{end}}
7+ <button type="submit" class="border border-spot-outline text-spot-text rounded-pill px-4 py-1.5 text-xs font-bold uppercase tracking-button hover:border-spot-text transition">Mark all read</button>
8+ </form>
9+ </div>
10+
11+<div id="article-list" class="space-y-3">
12+ {{range .Articles}}
13+ {{template "article-card.html" .}}
14+ {{else}}
15+ <div class="text-center text-spot-secondary py-12">No articles found.</div>
16+ {{end}}
17+</div>
18+
19+{{if .HasMore}}
20+<div class="text-center py-4">
21+ <button hx-get="/articles?offset={{.NextOffset}}{{if .FeedURL}}&feed={{.FeedURL}}{{end}}{{if .Starred}}&starred=1{{end}}"
22+ hx-target="#article-list" hx-swap="beforeend"
23+ hx-select="article"
24+ class="text-sm text-spot-secondary hover:text-spot-purple transition">
25+ Load more
26+ </button>
27+</div>
28+{{end}}
29+{{end}}
added internal/tmpl/base.html +341 -0
new file mode 100644
@@ -0,0 +1,341 @@
1+{{define "base.html"}}
2+<!DOCTYPE html>
3+<html lang="en">
4+<head>
5+ <meta charset="utf-8">
6+ <meta name="viewport" content="width=device-width, initial-scale=1">
7+ <title>Glean</title>
8+ <script>
9+ (function(){var t=localStorage.getItem('theme')||'dark';document.documentElement.setAttribute('data-theme',t)})();
10+ </script>
11+ <script src="https://cdn.tailwindcss.com"></script>
12+ <script>
13+ tailwind.config = {
14+ theme: {
15+ extend: {
16+ colors: {
17+ spot: {
18+ purple: '#a855f7',
19+ 'purple-border': '#9333ea',
20+ bg: 'var(--spot-bg)',
21+ surface: 'var(--spot-surface)',
22+ hover: 'var(--spot-hover)',
23+ 'hover-50': 'var(--spot-hover-50)',
24+ text: 'var(--spot-text)',
25+ secondary: 'var(--spot-secondary)',
26+ body: 'var(--spot-body)',
27+ muted: 'var(--spot-muted)',
28+ divider: 'var(--spot-divider)',
29+ 'divider-30': 'var(--spot-divider-30)',
30+ outline: 'var(--spot-outline)',
31+ placeholder: 'var(--spot-placeholder)',
32+ 'active-pill-bg': 'var(--spot-active-bg)',
33+ 'active-pill-text': 'var(--spot-active-text)',
34+ red: '#f3727f',
35+ orange: '#ffa42b',
36+ blue: '#539df5',
37+ }
38+ },
39+ borderRadius: {
40+ pill: '9999px',
41+ 'pill-lg': '500px',
42+ },
43+ boxShadow: {
44+ 'spot': 'var(--spot-shadow)',
45+ 'spot-heavy': 'var(--spot-shadow-heavy)',
46+ },
47+ letterSpacing: {
48+ button: '1.4px',
49+ },
50+ }
51+ }
52+ }
53+ </script>
54+ <script src="https://unpkg.com/htmx.org@2"></script>
55+ <style>
56+ :root {
57+ --spot-bg: #121212;
58+ --spot-surface: #181818;
59+ --spot-hover: #1f1f1f;
60+ --spot-hover-50: rgba(31,31,31,0.5);
61+ --spot-text: #ffffff;
62+ --spot-secondary: #b3b3b3;
63+ --spot-body: #cbcbcb;
64+ --spot-muted: #4d4d4d;
65+ --spot-divider: rgba(77,77,77,0.2);
66+ --spot-divider-30: rgba(77,77,77,0.3);
67+ --spot-outline: #7c7c7c;
68+ --spot-placeholder: #4d4d4d;
69+ --spot-active-bg: #ffffff;
70+ --spot-active-text: #121212;
71+ --spot-shadow: rgba(0,0,0,0.3) 0px 8px 8px;
72+ --spot-shadow-heavy: rgba(0,0,0,0.5) 0px 8px 24px;
73+ }
74+ [data-theme="light"] {
75+ --spot-bg: #f5f5f5;
76+ --spot-surface: #ffffff;
77+ --spot-hover: #f3f4f6;
78+ --spot-hover-50: rgba(243,244,246,0.5);
79+ --spot-text: #111827;
80+ --spot-secondary: #6b7280;
81+ --spot-body: #374151;
82+ --spot-muted: #9ca3af;
83+ --spot-divider: #e5e7eb;
84+ --spot-divider-30: #d1d5db;
85+ --spot-outline: #d1d5db;
86+ --spot-placeholder: #9ca3af;
87+ --spot-active-bg: #111827;
88+ --spot-active-text: #ffffff;
89+ --spot-shadow: rgba(0,0,0,0.08) 0px 2px 8px;
90+ --spot-shadow-heavy: rgba(0,0,0,0.1) 0px 4px 16px;
91+ }
92+ </style>
93+ <style type="text/tailwindcss">
94+ @layer components {
95+ .article-body h1 { @apply text-2xl font-bold mt-8 mb-3 text-spot-text }
96+ .article-body h2 { @apply text-xl font-semibold mt-6 mb-2 text-spot-text }
97+ .article-body h3 { @apply text-lg font-semibold mt-5 mb-2 text-spot-text }
98+ .article-body h4 { @apply text-base font-semibold mt-4 mb-1 text-spot-text }
99+ .article-body p { @apply my-3 leading-7 text-spot-body }
100+ .article-body ul { @apply list-disc pl-6 my-3 text-spot-body }
101+ .article-body ol { @apply list-decimal pl-6 my-3 text-spot-body }
102+ .article-body li { @apply my-1 leading-7 }
103+ .article-body blockquote { @apply border-l-4 border-spot-divider pl-4 italic text-spot-secondary my-4 }
104+ .article-body pre { @apply bg-spot-bg text-spot-body rounded-lg p-4 overflow-x-auto my-4 text-sm leading-6 }
105+ .article-body code { @apply bg-spot-surface text-spot-body px-1.5 py-0.5 rounded text-sm font-mono }
106+ .article-body pre code { @apply bg-transparent text-spot-body p-0 }
107+ .article-body img { @apply rounded-lg max-w-full h-auto my-4 }
108+ .article-body figure { @apply my-4 }
109+ .article-body figcaption { @apply text-sm text-spot-secondary text-center mt-2 }
110+ .article-body a { @apply text-spot-purple underline hover:brightness-110 }
111+ .article-body table { @apply w-full border-collapse my-4 }
112+ .article-body th { @apply border border-spot-divider px-3 py-2 bg-spot-surface font-semibold text-left text-spot-text }
113+ .article-body td { @apply border border-spot-divider px-3 py-2 text-spot-body }
114+ .article-body hr { @apply border-spot-divider my-6 }
115+ .article-body iframe, .article-body video { @apply rounded-lg my-4 max-w-full }
116+ .article-body iframe[src*="youtube.com"], .article-body iframe[src*="youtube-nocookie.com"], .article-body iframe[src*="vimeo.com"], .article-body iframe[src*="spotify.com"], .article-body iframe[src*="soundcloud.com"], .article-body iframe[src*="bandcamp.com"] { @apply w-full aspect-video }
117+ .article-body del { @apply line-through text-spot-secondary }
118+ .article-body mark { @apply bg-spot-orange/30 px-1 rounded }
119+ }
120+ </style>
121+ <link rel="preconnect" href="https://fonts.googleapis.com">
122+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
123+ <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700&display=swap" rel="stylesheet">
124+ <style>
125+ body { font-family: 'Inter', 'Helvetica Neue', helvetica, arial, sans-serif; }
126+ .sidebar-link { transition: color 0.15s; }
127+ .sidebar-link:hover { color: var(--spot-text); }
128+ .line-clamp-2 { display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
129+ ::-webkit-scrollbar { width: 8px; }
130+ ::-webkit-scrollbar-track { background: var(--spot-bg); }
131+ ::-webkit-scrollbar-thumb { background: var(--spot-muted); border-radius: 4px; }
132+ ::-webkit-scrollbar-thumb:hover { background: var(--spot-outline); }
133+ </style>
134+</head>
135+<body class="bg-spot-bg text-spot-text min-h-screen flex">
136+ {{if .User}}
137+ <aside class="hidden lg:flex flex-col w-60 bg-spot-bg h-screen fixed left-0 top-0 px-3 py-4 z-20">
138+ <a href="/" class="text-spot-purple font-bold text-xl font-title tracking-tight mb-8 px-3">Glean</a>
139+ <nav class="flex flex-col gap-1 text-sm">
140+ <a href="/dashboard" class="sidebar-link flex items-center gap-3 px-3 py-2 rounded-md {{activeClass .ActivePath "/dashboard"}}">
141+ <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-4 0a1 1 0 01-1-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 01-1 1"/></svg>
142+ Dashboard
143+ </a>
144+ <a href="/articles" class="sidebar-link flex items-center gap-3 px-3 py-2 rounded-md {{activeClass .ActivePath "/articles"}}">
145+ <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 20H5a2 2 0 01-2-2V6a2 2 0 012-2h10a2 2 0 012 2v1m2 13a2 2 0 01-2-2V7m2 13a2 2 0 002-2V9a2 2 0 00-2-2h-2"/></svg>
146+ Articles
147+ </a>
148+ <a href="/feeds" class="sidebar-link flex items-center gap-3 px-3 py-2 rounded-md {{activeClass .ActivePath "/feeds"}}">
149+ <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 5c7.18 0 13 5.82 13 13M6 11a7 7 0 017 7m-7-1a1 1 0 11-2 0 1 1 0 012 0z"/></svg>
150+ Feeds
151+ </a>
152+ <a href="/trending" class="sidebar-link flex items-center gap-3 px-3 py-2 rounded-md {{activeClass .ActivePath "/trending"}}">
153+ <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6"/></svg>
154+ Trending
155+ </a>
156+ <a href="/discover" class="sidebar-link flex items-center gap-3 px-3 py-2 rounded-md {{activeClass .ActivePath "/discover"}}">
157+ <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/></svg>
158+ Discover
159+ </a>
160+ <a href="/annotations" class="sidebar-link flex items-center gap-3 px-3 py-2 rounded-md {{activeClass .ActivePath "/annotations"}}">
161+ <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 8h10M7 12h4m1 8l-4-4H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-3l-4 4z"/></svg>
162+ Annotations
163+ </a>
164+ </nav>
165+ <div class="mt-auto pt-4 border-t border-spot-divider-30">
166+ <div class="flex items-center gap-3 px-3 py-2">
167+ {{if .User.AvatarURL.Valid}}<img src="{{.User.AvatarURL.String}}" class="w-8 h-8 rounded-full">{{end}}
168+ <div class="min-w-0 flex-1">
169+ <div class="text-sm font-bold truncate text-spot-text">@{{.User.Handle}}</div>
170+ </div>
171+ <form method="POST" action="/auth/logout">
172+ {{csrfInput .CSRFToken}}
173+ <button type="submit" class="text-spot-secondary hover:text-spot-red transition" title="Logout">
174+ <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1"/></svg>
175+ </button>
176+ </form>
177+ </div>
178+ </div>
179+ </aside>
180+
181+ <nav class="lg:hidden fixed bottom-0 left-0 right-0 bg-spot-surface border-t border-spot-divider z-30 px-2 py-2">
182+ <div class="flex items-center justify-around">
183+ <a href="/dashboard" class="flex flex-col items-center gap-0.5 {{activeClass .ActivePath "/dashboard"}} text-[10px] px-2 py-1">
184+ <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-4 0a1 1 0 01-1-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 01-1 1"/></svg>
185+ Home
186+ </a>
187+ <a href="/articles" class="flex flex-col items-center gap-0.5 {{activeClass .ActivePath "/articles"}} text-[10px] px-2 py-1">
188+ <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 20H5a2 2 0 01-2-2V6a2 2 0 012-2h10a2 2 0 012 2v1m2 13a2 2 0 01-2-2V7m2 13a2 2 0 002-2V9a2 2 0 00-2-2h-2"/></svg>
189+ Articles
190+ </a>
191+ <a href="/feeds" class="flex flex-col items-center gap-0.5 {{activeClass .ActivePath "/feeds"}} text-[10px] px-2 py-1">
192+ <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 5c7.18 0 13 5.82 13 13M6 11a7 7 0 017 7m-7-1a1 1 0 11-2 0 1 1 0 012 0z"/></svg>
193+ Feeds
194+ </a>
195+ <a href="/trending" class="flex flex-col items-center gap-0.5 {{activeClass .ActivePath "/trending"}} text-[10px] px-2 py-1">
196+ <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6"/></svg>
197+ Trending
198+ </a>
199+ <a href="/discover" class="flex flex-col items-center gap-0.5 {{activeClass .ActivePath "/discover"}} text-[10px] px-2 py-1">
200+ <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/></svg>
201+ Discover
202+ </a>
203+ </div>
204+ </nav>
205+ {{end}}
206+
207+ <main id="main-content" class="{{if .User}}lg:ml-60 pb-20 lg:pb-0{{end}} flex-1 min-h-screen flex flex-col">
208+ {{if .User}}
209+ <div class="lg:hidden bg-spot-surface border-b border-spot-divider px-4 py-3 flex items-center justify-between sticky top-0 z-20">
210+ <a href="/" class="text-spot-purple font-bold text-lg font-title">Glean</a>
211+ <div class="flex items-center gap-2">
212+ {{if .User.AvatarURL.Valid}}<img src="{{.User.AvatarURL.String}}" class="w-7 h-7 rounded-full">{{end}}
213+ <span class="text-xs text-spot-secondary">@{{.User.Handle}}</span>
214+ </div>
215+ </div>
216+ {{end}}
217+ <div class="max-w-6xl mx-auto px-4 lg:px-8 py-6 flex-1">
218+ {{.Content}}
219+ </div>
220+ <footer class="mt-auto">
221+ <div class="w-full bg-spot-surface border-t border-spot-divider">
222+ <div class="max-w-6xl mx-auto px-4 lg:px-8 py-8">
223+ <div class="hidden md:grid md:grid-cols-[auto_1fr_auto] md:gap-12 md:items-start">
224+ <div>
225+ <a href="/" class="text-spot-purple font-bold text-lg font-title tracking-tight">Glean</a>
226+ <p class="text-xs text-spot-secondary mt-1 max-w-[200px]">A social RSS reader<br>on the AT Protocol.</p>
227+ </div>
228+ <div class="grid grid-cols-3 gap-8">
229+ <div class="flex flex-col gap-1.5">
230+ <div class="text-spot-text font-bold text-xs uppercase tracking-wide mb-1">Browse</div>
231+ <a href="/dashboard" class="text-xs text-spot-secondary hover:text-spot-text hover:underline inline-flex gap-1.5 items-center transition">
232+ <svg class="w-3.5 h-3.5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-4 0a1 1 0 01-1-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 01-1 1"/></svg>
233+ Dashboard
234+ </a>
235+ <a href="/trending" class="text-xs text-spot-secondary hover:text-spot-text hover:underline inline-flex gap-1.5 items-center transition">
236+ <svg class="w-3.5 h-3.5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6"/></svg>
237+ Trending
238+ </a>
239+ <a href="/discover" class="text-xs text-spot-secondary hover:text-spot-text hover:underline inline-flex gap-1.5 items-center transition">
240+ <svg class="w-3.5 h-3.5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/></svg>
241+ Discover
242+ </a>
243+ </div>
244+ <div class="flex flex-col gap-1.5">
245+ <div class="text-spot-text font-bold text-xs uppercase tracking-wide mb-1">Library</div>
246+ <a href="/articles" class="text-xs text-spot-secondary hover:text-spot-text hover:underline inline-flex gap-1.5 items-center transition">
247+ <svg class="w-3.5 h-3.5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 20H5a2 2 0 01-2-2V6a2 2 0 012-2h10a2 2 0 012 2v1m2 13a2 2 0 01-2-2V7m2 13a2 2 0 002-2V9a2 2 0 00-2-2h-2"/></svg>
248+ Articles
249+ </a>
250+ <a href="/feeds" class="text-xs text-spot-secondary hover:text-spot-text hover:underline inline-flex gap-1.5 items-center transition">
251+ <svg class="w-3.5 h-3.5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 5c7.18 0 13 5.82 13 13M6 11a7 7 0 017 7m-7-1a1 1 0 11-2 0 1 1 0 012 0z"/></svg>
252+ Feeds
253+ </a>
254+ <a href="/annotations" class="text-xs text-spot-secondary hover:text-spot-text hover:underline inline-flex gap-1.5 items-center transition">
255+ <svg class="w-3.5 h-3.5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 8h10M7 12h4m1 8l-4-4H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-3l-4 4z"/></svg>
256+ Annotations
257+ </a>
258+ </div>
259+ <div class="flex flex-col gap-1.5">
260+ <div class="text-spot-text font-bold text-xs uppercase tracking-wide mb-1">Settings</div>
261+ <button onclick="toggleTheme()" class="text-xs text-spot-secondary hover:text-spot-text hover:underline inline-flex gap-1.5 items-center transition text-left">
262+ <svg class="w-3.5 h-3.5 shrink-0 theme-icon-dark" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z"/></svg>
263+ <svg class="w-3.5 h-3.5 shrink-0 theme-icon-light hidden" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z"/></svg>
264+ <span class="theme-text-dark">Light mode</span>
265+ <span class="theme-text-light hidden">Dark mode</span>
266+ </button>
267+ </div>
268+ </div>
269+ <div class="text-right">
270+ <div class="text-xs text-spot-secondary">&copy; {{now.Format "2006"}} Glean</div>
271+ <div class="text-[10px] text-spot-muted mt-0.5">Built on the AT Protocol</div>
272+ </div>
273+ </div>
274+
275+ <div class="md:hidden flex flex-col gap-6">
276+ <a href="/" class="text-spot-purple font-bold text-lg font-title tracking-tight">Glean</a>
277+ <div class="grid grid-cols-2 gap-6">
278+ <div class="flex flex-col gap-1.5">
279+ <div class="text-spot-text font-bold text-xs uppercase tracking-wide mb-1">Browse</div>
280+ <a href="/dashboard" class="text-xs text-spot-secondary hover:text-spot-text transition">Dashboard</a>
281+ <a href="/trending" class="text-xs text-spot-secondary hover:text-spot-text transition">Trending</a>
282+ <a href="/discover" class="text-xs text-spot-secondary hover:text-spot-text transition">Discover</a>
283+ </div>
284+ <div class="flex flex-col gap-1.5">
285+ <div class="text-spot-text font-bold text-xs uppercase tracking-wide mb-1">Library</div>
286+ <a href="/articles" class="text-xs text-spot-secondary hover:text-spot-text transition">Articles</a>
287+ <a href="/feeds" class="text-xs text-spot-secondary hover:text-spot-text transition">Feeds</a>
288+ <a href="/annotations" class="text-xs text-spot-secondary hover:text-spot-text transition">Annotations</a>
289+ </div>
290+ </div>
291+ <div class="flex items-center justify-between">
292+ <button onclick="toggleTheme()" class="text-xs text-spot-secondary hover:text-spot-text inline-flex gap-1.5 items-center transition">
293+ <svg class="w-3.5 h-3.5 theme-icon-dark" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z"/></svg>
294+ <svg class="w-3.5 h-3.5 theme-icon-light hidden" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z"/></svg>
295+ <span class="theme-text-dark">Light mode</span>
296+ <span class="theme-text-light hidden">Dark mode</span>
297+ </button>
298+ <span class="text-xs text-spot-secondary">&copy; {{now.Format "2006"}} Glean</span>
299+ </div>
300+ </div>
301+ </div>
302+ </div>
303+ </footer>
304+ </main>
305+
306+ <script>
307+ function toggleTheme() {
308+ var current = document.documentElement.getAttribute('data-theme');
309+ var next = current === 'dark' ? 'light' : 'dark';
310+ document.documentElement.setAttribute('data-theme', next);
311+ localStorage.setItem('theme', next);
312+ updateThemeIcons(next);
313+ }
314+ function updateThemeIcons(theme) {
315+ document.querySelectorAll('.theme-icon-dark').forEach(function(el) {
316+ el.classList.toggle('hidden', theme === 'light');
317+ });
318+ document.querySelectorAll('.theme-icon-light').forEach(function(el) {
319+ el.classList.toggle('hidden', theme === 'dark');
320+ });
321+ document.querySelectorAll('.theme-text-dark').forEach(function(el) {
322+ el.classList.toggle('hidden', theme === 'light');
323+ });
324+ document.querySelectorAll('.theme-text-light').forEach(function(el) {
325+ el.classList.toggle('hidden', theme === 'dark');
326+ });
327+ }
328+ updateThemeIcons(document.documentElement.getAttribute('data-theme') || 'dark');
329+
330+ document.addEventListener('keydown', function(e) {
331+ if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
332+ if (e.key === 'g') window.location.href = '/dashboard';
333+ if (e.key === 'a') window.location.href = '/articles';
334+ if (e.key === 'f') window.location.href = '/feeds';
335+ if (e.key === 't') window.location.href = '/trending';
336+ if (e.key === 'd') window.location.href = '/discover';
337+ });
338+ </script>
339+</body>
340+</html>
341+{{end}}
new file mode 100644
@@ -0,0 +1,341 @@
1+{{define "base.html"}}
2+<!DOCTYPE html>
3+<html lang="en">
4+<head>
5+ <meta charset="utf-8">
6+ <meta name="viewport" content="width=device-width, initial-scale=1">
7+ <title>Glean</title>
8+ <script>
9+ (function(){var t=localStorage.getItem('theme')||'dark';document.documentElement.setAttribute('data-theme',t)})();
10+ </script>
11+ <script src="https://cdn.tailwindcss.com"></script>
12+ <script>
13+ tailwind.config = {
14+ theme: {
15+ extend: {
16+ colors: {
17+ spot: {
18+ purple: '#a855f7',
19+ 'purple-border': '#9333ea',
20+ bg: 'var(--spot-bg)',
21+ surface: 'var(--spot-surface)',
22+ hover: 'var(--spot-hover)',
23+ 'hover-50': 'var(--spot-hover-50)',
24+ text: 'var(--spot-text)',
25+ secondary: 'var(--spot-secondary)',
26+ body: 'var(--spot-body)',
27+ muted: 'var(--spot-muted)',
28+ divider: 'var(--spot-divider)',
29+ 'divider-30': 'var(--spot-divider-30)',
30+ outline: 'var(--spot-outline)',
31+ placeholder: 'var(--spot-placeholder)',
32+ 'active-pill-bg': 'var(--spot-active-bg)',
33+ 'active-pill-text': 'var(--spot-active-text)',
34+ red: '#f3727f',
35+ orange: '#ffa42b',
36+ blue: '#539df5',
37+ }
38+ },
39+ borderRadius: {
40+ pill: '9999px',
41+ 'pill-lg': '500px',
42+ },
43+ boxShadow: {
44+ 'spot': 'var(--spot-shadow)',
45+ 'spot-heavy': 'var(--spot-shadow-heavy)',
46+ },
47+ letterSpacing: {
48+ button: '1.4px',
49+ },
50+ }
51+ }
52+ }
53+ </script>
54+ <script src="https://unpkg.com/htmx.org@2"></script>
55+ <style>
56+ :root {
57+ --spot-bg: #121212;
58+ --spot-surface: #181818;
59+ --spot-hover: #1f1f1f;
60+ --spot-hover-50: rgba(31,31,31,0.5);
61+ --spot-text: #ffffff;
62+ --spot-secondary: #b3b3b3;
63+ --spot-body: #cbcbcb;
64+ --spot-muted: #4d4d4d;
65+ --spot-divider: rgba(77,77,77,0.2);
66+ --spot-divider-30: rgba(77,77,77,0.3);
67+ --spot-outline: #7c7c7c;
68+ --spot-placeholder: #4d4d4d;
69+ --spot-active-bg: #ffffff;
70+ --spot-active-text: #121212;
71+ --spot-shadow: rgba(0,0,0,0.3) 0px 8px 8px;
72+ --spot-shadow-heavy: rgba(0,0,0,0.5) 0px 8px 24px;
73+ }
74+ [data-theme="light"] {
75+ --spot-bg: #f5f5f5;
76+ --spot-surface: #ffffff;
77+ --spot-hover: #f3f4f6;
78+ --spot-hover-50: rgba(243,244,246,0.5);
79+ --spot-text: #111827;
80+ --spot-secondary: #6b7280;
81+ --spot-body: #374151;
82+ --spot-muted: #9ca3af;
83+ --spot-divider: #e5e7eb;
84+ --spot-divider-30: #d1d5db;
85+ --spot-outline: #d1d5db;
86+ --spot-placeholder: #9ca3af;
87+ --spot-active-bg: #111827;
88+ --spot-active-text: #ffffff;
89+ --spot-shadow: rgba(0,0,0,0.08) 0px 2px 8px;
90+ --spot-shadow-heavy: rgba(0,0,0,0.1) 0px 4px 16px;
91+ }
92+ </style>
93+ <style type="text/tailwindcss">
94+ @layer components {
95+ .article-body h1 { @apply text-2xl font-bold mt-8 mb-3 text-spot-text }
96+ .article-body h2 { @apply text-xl font-semibold mt-6 mb-2 text-spot-text }
97+ .article-body h3 { @apply text-lg font-semibold mt-5 mb-2 text-spot-text }
98+ .article-body h4 { @apply text-base font-semibold mt-4 mb-1 text-spot-text }
99+ .article-body p { @apply my-3 leading-7 text-spot-body }
100+ .article-body ul { @apply list-disc pl-6 my-3 text-spot-body }
101+ .article-body ol { @apply list-decimal pl-6 my-3 text-spot-body }
102+ .article-body li { @apply my-1 leading-7 }
103+ .article-body blockquote { @apply border-l-4 border-spot-divider pl-4 italic text-spot-secondary my-4 }
104+ .article-body pre { @apply bg-spot-bg text-spot-body rounded-lg p-4 overflow-x-auto my-4 text-sm leading-6 }
105+ .article-body code { @apply bg-spot-surface text-spot-body px-1.5 py-0.5 rounded text-sm font-mono }
106+ .article-body pre code { @apply bg-transparent text-spot-body p-0 }
107+ .article-body img { @apply rounded-lg max-w-full h-auto my-4 }
108+ .article-body figure { @apply my-4 }
109+ .article-body figcaption { @apply text-sm text-spot-secondary text-center mt-2 }
110+ .article-body a { @apply text-spot-purple underline hover:brightness-110 }
111+ .article-body table { @apply w-full border-collapse my-4 }
112+ .article-body th { @apply border border-spot-divider px-3 py-2 bg-spot-surface font-semibold text-left text-spot-text }
113+ .article-body td { @apply border border-spot-divider px-3 py-2 text-spot-body }
114+ .article-body hr { @apply border-spot-divider my-6 }
115+ .article-body iframe, .article-body video { @apply rounded-lg my-4 max-w-full }
116+ .article-body iframe[src*="youtube.com"], .article-body iframe[src*="youtube-nocookie.com"], .article-body iframe[src*="vimeo.com"], .article-body iframe[src*="spotify.com"], .article-body iframe[src*="soundcloud.com"], .article-body iframe[src*="bandcamp.com"] { @apply w-full aspect-video }
117+ .article-body del { @apply line-through text-spot-secondary }
118+ .article-body mark { @apply bg-spot-orange/30 px-1 rounded }
119+ }
120+ </style>
121+ <link rel="preconnect" href="https://fonts.googleapis.com">
122+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
123+ <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700&display=swap" rel="stylesheet">
124+ <style>
125+ body { font-family: 'Inter', 'Helvetica Neue', helvetica, arial, sans-serif; }
126+ .sidebar-link { transition: color 0.15s; }
127+ .sidebar-link:hover { color: var(--spot-text); }
128+ .line-clamp-2 { display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
129+ ::-webkit-scrollbar { width: 8px; }
130+ ::-webkit-scrollbar-track { background: var(--spot-bg); }
131+ ::-webkit-scrollbar-thumb { background: var(--spot-muted); border-radius: 4px; }
132+ ::-webkit-scrollbar-thumb:hover { background: var(--spot-outline); }
133+ </style>
134+</head>
135+<body class="bg-spot-bg text-spot-text min-h-screen flex">
136+ {{if .User}}
137+ <aside class="hidden lg:flex flex-col w-60 bg-spot-bg h-screen fixed left-0 top-0 px-3 py-4 z-20">
138+ <a href="/" class="text-spot-purple font-bold text-xl font-title tracking-tight mb-8 px-3">Glean</a>
139+ <nav class="flex flex-col gap-1 text-sm">
140+ <a href="/dashboard" class="sidebar-link flex items-center gap-3 px-3 py-2 rounded-md {{activeClass .ActivePath "/dashboard"}}">
141+ <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-4 0a1 1 0 01-1-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 01-1 1"/></svg>
142+ Dashboard
143+ </a>
144+ <a href="/articles" class="sidebar-link flex items-center gap-3 px-3 py-2 rounded-md {{activeClass .ActivePath "/articles"}}">
145+ <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 20H5a2 2 0 01-2-2V6a2 2 0 012-2h10a2 2 0 012 2v1m2 13a2 2 0 01-2-2V7m2 13a2 2 0 002-2V9a2 2 0 00-2-2h-2"/></svg>
146+ Articles
147+ </a>
148+ <a href="/feeds" class="sidebar-link flex items-center gap-3 px-3 py-2 rounded-md {{activeClass .ActivePath "/feeds"}}">
149+ <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 5c7.18 0 13 5.82 13 13M6 11a7 7 0 017 7m-7-1a1 1 0 11-2 0 1 1 0 012 0z"/></svg>
150+ Feeds
151+ </a>
152+ <a href="/trending" class="sidebar-link flex items-center gap-3 px-3 py-2 rounded-md {{activeClass .ActivePath "/trending"}}">
153+ <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6"/></svg>
154+ Trending
155+ </a>
156+ <a href="/discover" class="sidebar-link flex items-center gap-3 px-3 py-2 rounded-md {{activeClass .ActivePath "/discover"}}">
157+ <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/></svg>
158+ Discover
159+ </a>
160+ <a href="/annotations" class="sidebar-link flex items-center gap-3 px-3 py-2 rounded-md {{activeClass .ActivePath "/annotations"}}">
161+ <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 8h10M7 12h4m1 8l-4-4H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-3l-4 4z"/></svg>
162+ Annotations
163+ </a>
164+ </nav>
165+ <div class="mt-auto pt-4 border-t border-spot-divider-30">
166+ <div class="flex items-center gap-3 px-3 py-2">
167+ {{if .User.AvatarURL.Valid}}<img src="{{.User.AvatarURL.String}}" class="w-8 h-8 rounded-full">{{end}}
168+ <div class="min-w-0 flex-1">
169+ <div class="text-sm font-bold truncate text-spot-text">@{{.User.Handle}}</div>
170+ </div>
171+ <form method="POST" action="/auth/logout">
172+ {{csrfInput .CSRFToken}}
173+ <button type="submit" class="text-spot-secondary hover:text-spot-red transition" title="Logout">
174+ <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1"/></svg>
175+ </button>
176+ </form>
177+ </div>
178+ </div>
179+ </aside>
180+
181+ <nav class="lg:hidden fixed bottom-0 left-0 right-0 bg-spot-surface border-t border-spot-divider z-30 px-2 py-2">
182+ <div class="flex items-center justify-around">
183+ <a href="/dashboard" class="flex flex-col items-center gap-0.5 {{activeClass .ActivePath "/dashboard"}} text-[10px] px-2 py-1">
184+ <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-4 0a1 1 0 01-1-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 01-1 1"/></svg>
185+ Home
186+ </a>
187+ <a href="/articles" class="flex flex-col items-center gap-0.5 {{activeClass .ActivePath "/articles"}} text-[10px] px-2 py-1">
188+ <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 20H5a2 2 0 01-2-2V6a2 2 0 012-2h10a2 2 0 012 2v1m2 13a2 2 0 01-2-2V7m2 13a2 2 0 002-2V9a2 2 0 00-2-2h-2"/></svg>
189+ Articles
190+ </a>
191+ <a href="/feeds" class="flex flex-col items-center gap-0.5 {{activeClass .ActivePath "/feeds"}} text-[10px] px-2 py-1">
192+ <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 5c7.18 0 13 5.82 13 13M6 11a7 7 0 017 7m-7-1a1 1 0 11-2 0 1 1 0 012 0z"/></svg>
193+ Feeds
194+ </a>
195+ <a href="/trending" class="flex flex-col items-center gap-0.5 {{activeClass .ActivePath "/trending"}} text-[10px] px-2 py-1">
196+ <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6"/></svg>
197+ Trending
198+ </a>
199+ <a href="/discover" class="flex flex-col items-center gap-0.5 {{activeClass .ActivePath "/discover"}} text-[10px] px-2 py-1">
200+ <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/></svg>
201+ Discover
202+ </a>
203+ </div>
204+ </nav>
205+ {{end}}
206+
207+ <main id="main-content" class="{{if .User}}lg:ml-60 pb-20 lg:pb-0{{end}} flex-1 min-h-screen flex flex-col">
208+ {{if .User}}
209+ <div class="lg:hidden bg-spot-surface border-b border-spot-divider px-4 py-3 flex items-center justify-between sticky top-0 z-20">
210+ <a href="/" class="text-spot-purple font-bold text-lg font-title">Glean</a>
211+ <div class="flex items-center gap-2">
212+ {{if .User.AvatarURL.Valid}}<img src="{{.User.AvatarURL.String}}" class="w-7 h-7 rounded-full">{{end}}
213+ <span class="text-xs text-spot-secondary">@{{.User.Handle}}</span>
214+ </div>
215+ </div>
216+ {{end}}
217+ <div class="max-w-6xl mx-auto px-4 lg:px-8 py-6 flex-1">
218+ {{.Content}}
219+ </div>
220+ <footer class="mt-auto">
221+ <div class="w-full bg-spot-surface border-t border-spot-divider">
222+ <div class="max-w-6xl mx-auto px-4 lg:px-8 py-8">
223+ <div class="hidden md:grid md:grid-cols-[auto_1fr_auto] md:gap-12 md:items-start">
224+ <div>
225+ <a href="/" class="text-spot-purple font-bold text-lg font-title tracking-tight">Glean</a>
226+ <p class="text-xs text-spot-secondary mt-1 max-w-[200px]">A social RSS reader<br>on the AT Protocol.</p>
227+ </div>
228+ <div class="grid grid-cols-3 gap-8">
229+ <div class="flex flex-col gap-1.5">
230+ <div class="text-spot-text font-bold text-xs uppercase tracking-wide mb-1">Browse</div>
231+ <a href="/dashboard" class="text-xs text-spot-secondary hover:text-spot-text hover:underline inline-flex gap-1.5 items-center transition">
232+ <svg class="w-3.5 h-3.5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-4 0a1 1 0 01-1-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 01-1 1"/></svg>
233+ Dashboard
234+ </a>
235+ <a href="/trending" class="text-xs text-spot-secondary hover:text-spot-text hover:underline inline-flex gap-1.5 items-center transition">
236+ <svg class="w-3.5 h-3.5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6"/></svg>
237+ Trending
238+ </a>
239+ <a href="/discover" class="text-xs text-spot-secondary hover:text-spot-text hover:underline inline-flex gap-1.5 items-center transition">
240+ <svg class="w-3.5 h-3.5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/></svg>
241+ Discover
242+ </a>
243+ </div>
244+ <div class="flex flex-col gap-1.5">
245+ <div class="text-spot-text font-bold text-xs uppercase tracking-wide mb-1">Library</div>
246+ <a href="/articles" class="text-xs text-spot-secondary hover:text-spot-text hover:underline inline-flex gap-1.5 items-center transition">
247+ <svg class="w-3.5 h-3.5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 20H5a2 2 0 01-2-2V6a2 2 0 012-2h10a2 2 0 012 2v1m2 13a2 2 0 01-2-2V7m2 13a2 2 0 002-2V9a2 2 0 00-2-2h-2"/></svg>
248+ Articles
249+ </a>
250+ <a href="/feeds" class="text-xs text-spot-secondary hover:text-spot-text hover:underline inline-flex gap-1.5 items-center transition">
251+ <svg class="w-3.5 h-3.5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 5c7.18 0 13 5.82 13 13M6 11a7 7 0 017 7m-7-1a1 1 0 11-2 0 1 1 0 012 0z"/></svg>
252+ Feeds
253+ </a>
254+ <a href="/annotations" class="text-xs text-spot-secondary hover:text-spot-text hover:underline inline-flex gap-1.5 items-center transition">
255+ <svg class="w-3.5 h-3.5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 8h10M7 12h4m1 8l-4-4H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-3l-4 4z"/></svg>
256+ Annotations
257+ </a>
258+ </div>
259+ <div class="flex flex-col gap-1.5">
260+ <div class="text-spot-text font-bold text-xs uppercase tracking-wide mb-1">Settings</div>
261+ <button onclick="toggleTheme()" class="text-xs text-spot-secondary hover:text-spot-text hover:underline inline-flex gap-1.5 items-center transition text-left">
262+ <svg class="w-3.5 h-3.5 shrink-0 theme-icon-dark" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z"/></svg>
263+ <svg class="w-3.5 h-3.5 shrink-0 theme-icon-light hidden" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z"/></svg>
264+ <span class="theme-text-dark">Light mode</span>
265+ <span class="theme-text-light hidden">Dark mode</span>
266+ </button>
267+ </div>
268+ </div>
269+ <div class="text-right">
270+ <div class="text-xs text-spot-secondary">&copy; {{now.Format "2006"}} Glean</div>
271+ <div class="text-[10px] text-spot-muted mt-0.5">Built on the AT Protocol</div>
272+ </div>
273+ </div>
274+
275+ <div class="md:hidden flex flex-col gap-6">
276+ <a href="/" class="text-spot-purple font-bold text-lg font-title tracking-tight">Glean</a>
277+ <div class="grid grid-cols-2 gap-6">
278+ <div class="flex flex-col gap-1.5">
279+ <div class="text-spot-text font-bold text-xs uppercase tracking-wide mb-1">Browse</div>
280+ <a href="/dashboard" class="text-xs text-spot-secondary hover:text-spot-text transition">Dashboard</a>
281+ <a href="/trending" class="text-xs text-spot-secondary hover:text-spot-text transition">Trending</a>
282+ <a href="/discover" class="text-xs text-spot-secondary hover:text-spot-text transition">Discover</a>
283+ </div>
284+ <div class="flex flex-col gap-1.5">
285+ <div class="text-spot-text font-bold text-xs uppercase tracking-wide mb-1">Library</div>
286+ <a href="/articles" class="text-xs text-spot-secondary hover:text-spot-text transition">Articles</a>
287+ <a href="/feeds" class="text-xs text-spot-secondary hover:text-spot-text transition">Feeds</a>
288+ <a href="/annotations" class="text-xs text-spot-secondary hover:text-spot-text transition">Annotations</a>
289+ </div>
290+ </div>
291+ <div class="flex items-center justify-between">
292+ <button onclick="toggleTheme()" class="text-xs text-spot-secondary hover:text-spot-text inline-flex gap-1.5 items-center transition">
293+ <svg class="w-3.5 h-3.5 theme-icon-dark" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z"/></svg>
294+ <svg class="w-3.5 h-3.5 theme-icon-light hidden" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z"/></svg>
295+ <span class="theme-text-dark">Light mode</span>
296+ <span class="theme-text-light hidden">Dark mode</span>
297+ </button>
298+ <span class="text-xs text-spot-secondary">&copy; {{now.Format "2006"}} Glean</span>
299+ </div>
300+ </div>
301+ </div>
302+ </div>
303+ </footer>
304+ </main>
305+
306+ <script>
307+ function toggleTheme() {
308+ var current = document.documentElement.getAttribute('data-theme');
309+ var next = current === 'dark' ? 'light' : 'dark';
310+ document.documentElement.setAttribute('data-theme', next);
311+ localStorage.setItem('theme', next);
312+ updateThemeIcons(next);
313+ }
314+ function updateThemeIcons(theme) {
315+ document.querySelectorAll('.theme-icon-dark').forEach(function(el) {
316+ el.classList.toggle('hidden', theme === 'light');
317+ });
318+ document.querySelectorAll('.theme-icon-light').forEach(function(el) {
319+ el.classList.toggle('hidden', theme === 'dark');
320+ });
321+ document.querySelectorAll('.theme-text-dark').forEach(function(el) {
322+ el.classList.toggle('hidden', theme === 'light');
323+ });
324+ document.querySelectorAll('.theme-text-light').forEach(function(el) {
325+ el.classList.toggle('hidden', theme === 'dark');
326+ });
327+ }
328+ updateThemeIcons(document.documentElement.getAttribute('data-theme') || 'dark');
329+
330+ document.addEventListener('keydown', function(e) {
331+ if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
332+ if (e.key === 'g') window.location.href = '/dashboard';
333+ if (e.key === 'a') window.location.href = '/articles';
334+ if (e.key === 'f') window.location.href = '/feeds';
335+ if (e.key === 't') window.location.href = '/trending';
336+ if (e.key === 'd') window.location.href = '/discover';
337+ });
338+ </script>
339+</body>
340+</html>
341+{{end}}
added internal/tmpl/dashboard.html +99 -0
new file mode 100644
@@ -0,0 +1,99 @@
1+{{define "dashboard.html"}}
2+<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
3+ <div class="lg:col-span-2">
4+ <div class="flex items-center justify-between mb-6">
5+ <h1 class="text-2xl font-bold font-title text-spot-text">Dashboard</h1>
6+ <div class="flex gap-4 text-sm">
7+ <span class="text-spot-secondary"><strong class="text-spot-text">{{.UnreadCount}}</strong> unread</span>
8+ <span class="text-spot-secondary"><strong class="text-spot-text">{{.SubscriptionCount}}</strong> feeds</span>
9+ </div>
10+ </div>
11+
12+ <div class="space-y-3">
13+ {{range .Articles}}
14+ {{template "article-card.html" .}}
15+ {{else}}
16+ <div class="text-center text-spot-secondary py-12">No unread articles. You're all caught up!</div>
17+ {{end}}
18+ </div>
19+
20+ {{if .Articles}}
21+ <div class="mt-4 text-center">
22+ <a href="/articles" class="text-sm text-spot-secondary hover:text-spot-purple transition">View all articles &rarr;</a>
23+ </div>
24+ {{end}}
25+ </div>
26+
27+ <div class="space-y-8">
28+ {{if .Trending}}
29+ <div>
30+ <div class="flex items-center justify-between mb-4">
31+ <h2 class="text-lg font-semibold text-spot-text">Trending</h2>
32+ <a href="/trending" class="text-xs text-spot-secondary hover:text-spot-purple uppercase tracking-button transition">See all</a>
33+ </div>
34+ <div class="space-y-3">
35+ {{range .Trending}}
36+ <a href="/articles/{{.ArticleID}}" class="block bg-spot-surface rounded-lg p-4 hover:bg-spot-hover-50 transition">
37+ <div class="font-bold text-sm text-spot-text leading-tight">{{.Title}}</div>
38+ <div class="flex items-center gap-2 mt-1 text-xs text-spot-secondary">
39+ {{if .Author}}<span>{{.Author}}</span>{{end}}
40+ <span class="text-spot-muted">{{if .FeedTitle}}{{.FeedTitle}}{{end}}</span>
41+ </div>
42+ <div class="flex items-center gap-3 mt-2 text-xs text-spot-secondary">
43+ <span>&#9829; {{.LikeCount}}</span>
44+ <span class="text-spot-muted">{{.AnnotationCount}} notes</span>
45+ </div>
46+ </a>
47+ {{end}}
48+ </div>
49+ </div>
50+ {{end}}
51+
52+ {{if .PeopleRecommendations}}
53+ <div>
54+ <div class="flex items-center justify-between mb-4">
55+ <h2 class="text-lg font-semibold text-spot-text">Similar readers</h2>
56+ <a href="/discover" class="text-xs text-spot-secondary hover:text-spot-purple uppercase tracking-button transition">See all</a>
57+ </div>
58+ <div class="space-y-3">
59+ {{range .PeopleRecommendations}}
60+ <div class="bg-spot-surface rounded-lg p-4 flex items-center gap-3 hover:bg-spot-hover-50 transition">
61+ {{if .avatar_url}}<img src="{{.avatar_url}}" class="w-10 h-10 rounded-full">{{end}}
62+ <div class="min-w-0 flex-1">
63+ <a href="/profile/{{.handle}}" class="font-bold text-spot-text hover:text-spot-purple transition">@{{.handle}}</a>
64+ {{if .display_name}}<div class="text-sm text-spot-secondary">{{.display_name}}</div>{{end}}
65+ </div>
66+ <span class="text-xs text-spot-secondary">{{.common_feeds}} shared</span>
67+ </div>
68+ {{end}}
69+ </div>
70+ </div>
71+ {{end}}
72+
73+ {{if .FeedRecommendations}}
74+ <div>
75+ <div class="flex items-center justify-between mb-4">
76+ <h2 class="text-lg font-semibold text-spot-text">Recommended feeds</h2>
77+ <a href="/discover" class="text-xs text-spot-secondary hover:text-spot-purple uppercase tracking-button transition">See all</a>
78+ </div>
79+ <div class="space-y-3">
80+ {{range .FeedRecommendations}}
81+ {{template "recommendation-card.html" .}}
82+ {{end}}
83+ </div>
84+ </div>
85+ {{end}}
86+
87+ <div>
88+ <h2 class="text-lg font-semibold text-spot-text mb-4">Quick add</h2>
89+ <form hx-post="/feeds/add" hx-target="#feed-list" hx-swap="beforeend" class="flex gap-2">
90+ {{csrfInput .CSRFToken}}
91+ <input type="url" name="feed_url" placeholder="Feed URL"
92+ class="flex-1 bg-spot-hover text-spot-text rounded-pill px-5 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-spot-purple placeholder:text-spot-placeholder"
93+ required>
94+ <button type="submit" class="bg-spot-purple text-spot-bg rounded-pill px-5 py-2 text-sm font-bold uppercase tracking-button hover:brightness-110 transition">Add</button>
95+ </form>
96+ </div>
97+ </div>
98+</div>
99+{{end}}
new file mode 100644
@@ -0,0 +1,99 @@
1+{{define "dashboard.html"}}
2+<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
3+ <div class="lg:col-span-2">
4+ <div class="flex items-center justify-between mb-6">
5+ <h1 class="text-2xl font-bold font-title text-spot-text">Dashboard</h1>
6+ <div class="flex gap-4 text-sm">
7+ <span class="text-spot-secondary"><strong class="text-spot-text">{{.UnreadCount}}</strong> unread</span>
8+ <span class="text-spot-secondary"><strong class="text-spot-text">{{.SubscriptionCount}}</strong> feeds</span>
9+ </div>
10+ </div>
11+
12+ <div class="space-y-3">
13+ {{range .Articles}}
14+ {{template "article-card.html" .}}
15+ {{else}}
16+ <div class="text-center text-spot-secondary py-12">No unread articles. You're all caught up!</div>
17+ {{end}}
18+ </div>
19+
20+ {{if .Articles}}
21+ <div class="mt-4 text-center">
22+ <a href="/articles" class="text-sm text-spot-secondary hover:text-spot-purple transition">View all articles &rarr;</a>
23+ </div>
24+ {{end}}
25+ </div>
26+
27+ <div class="space-y-8">
28+ {{if .Trending}}
29+ <div>
30+ <div class="flex items-center justify-between mb-4">
31+ <h2 class="text-lg font-semibold text-spot-text">Trending</h2>
32+ <a href="/trending" class="text-xs text-spot-secondary hover:text-spot-purple uppercase tracking-button transition">See all</a>
33+ </div>
34+ <div class="space-y-3">
35+ {{range .Trending}}
36+ <a href="/articles/{{.ArticleID}}" class="block bg-spot-surface rounded-lg p-4 hover:bg-spot-hover-50 transition">
37+ <div class="font-bold text-sm text-spot-text leading-tight">{{.Title}}</div>
38+ <div class="flex items-center gap-2 mt-1 text-xs text-spot-secondary">
39+ {{if .Author}}<span>{{.Author}}</span>{{end}}
40+ <span class="text-spot-muted">{{if .FeedTitle}}{{.FeedTitle}}{{end}}</span>
41+ </div>
42+ <div class="flex items-center gap-3 mt-2 text-xs text-spot-secondary">
43+ <span>&#9829; {{.LikeCount}}</span>
44+ <span class="text-spot-muted">{{.AnnotationCount}} notes</span>
45+ </div>
46+ </a>
47+ {{end}}
48+ </div>
49+ </div>
50+ {{end}}
51+
52+ {{if .PeopleRecommendations}}
53+ <div>
54+ <div class="flex items-center justify-between mb-4">
55+ <h2 class="text-lg font-semibold text-spot-text">Similar readers</h2>
56+ <a href="/discover" class="text-xs text-spot-secondary hover:text-spot-purple uppercase tracking-button transition">See all</a>
57+ </div>
58+ <div class="space-y-3">
59+ {{range .PeopleRecommendations}}
60+ <div class="bg-spot-surface rounded-lg p-4 flex items-center gap-3 hover:bg-spot-hover-50 transition">
61+ {{if .avatar_url}}<img src="{{.avatar_url}}" class="w-10 h-10 rounded-full">{{end}}
62+ <div class="min-w-0 flex-1">
63+ <a href="/profile/{{.handle}}" class="font-bold text-spot-text hover:text-spot-purple transition">@{{.handle}}</a>
64+ {{if .display_name}}<div class="text-sm text-spot-secondary">{{.display_name}}</div>{{end}}
65+ </div>
66+ <span class="text-xs text-spot-secondary">{{.common_feeds}} shared</span>
67+ </div>
68+ {{end}}
69+ </div>
70+ </div>
71+ {{end}}
72+
73+ {{if .FeedRecommendations}}
74+ <div>
75+ <div class="flex items-center justify-between mb-4">
76+ <h2 class="text-lg font-semibold text-spot-text">Recommended feeds</h2>
77+ <a href="/discover" class="text-xs text-spot-secondary hover:text-spot-purple uppercase tracking-button transition">See all</a>
78+ </div>
79+ <div class="space-y-3">
80+ {{range .FeedRecommendations}}
81+ {{template "recommendation-card.html" .}}
82+ {{end}}
83+ </div>
84+ </div>
85+ {{end}}
86+
87+ <div>
88+ <h2 class="text-lg font-semibold text-spot-text mb-4">Quick add</h2>
89+ <form hx-post="/feeds/add" hx-target="#feed-list" hx-swap="beforeend" class="flex gap-2">
90+ {{csrfInput .CSRFToken}}
91+ <input type="url" name="feed_url" placeholder="Feed URL"
92+ class="flex-1 bg-spot-hover text-spot-text rounded-pill px-5 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-spot-purple placeholder:text-spot-placeholder"
93+ required>
94+ <button type="submit" class="bg-spot-purple text-spot-bg rounded-pill px-5 py-2 text-sm font-bold uppercase tracking-button hover:brightness-110 transition">Add</button>
95+ </form>
96+ </div>
97+ </div>
98+</div>
99+{{end}}
added internal/tmpl/discover.html +58 -0
new file mode 100644
@@ -0,0 +1,58 @@
1+{{define "discover.html"}}
2+<div class="mb-6">
3+ <h1 class="text-2xl font-bold font-title text-spot-text">Discover</h1>
4+ <p class="text-spot-secondary text-sm mt-1">Feeds and readers similar to you.</p>
5+</div>
6+
7+<div class="grid grid-cols-1 lg:grid-cols-2 gap-8">
8+ <div>
9+ <h2 class="text-lg font-semibold text-spot-text mb-4">Recommended feeds</h2>
10+ <div class="space-y-3">
11+ {{range .FeedRecommendations}}
12+ {{template "recommendation-card.html" .}}
13+ {{else}}
14+ <p class="text-sm text-spot-secondary">Subscribe to some feeds first to get recommendations.</p>
15+ {{end}}
16+ </div>
17+ </div>
18+
19+ <div>
20+ <h2 class="text-lg font-semibold text-spot-text mb-4">Similar readers</h2>
21+ <div class="space-y-3">
22+ {{range .PeopleRecommendations}}
23+ <div class="bg-spot-surface rounded-lg p-4 flex items-center gap-3 hover:bg-spot-hover-50 transition">
24+ {{if .avatar_url}}<img src="{{.avatar_url}}" class="w-10 h-10 rounded-full">{{end}}
25+ <div class="min-w-0 flex-1">
26+ <a href="/profile/{{.handle}}" class="font-bold text-spot-text hover:text-spot-purple transition">@{{.handle}}</a>
27+ {{if .display_name}}<div class="text-sm text-spot-secondary">{{.display_name}}</div>{{end}}
28+ </div>
29+ <span class="text-xs text-spot-secondary">{{.common_feeds}} shared feeds</span>
30+ </div>
31+ {{else}}
32+ <p class="text-sm text-spot-secondary">Not enough data yet. Keep reading!</p>
33+ {{end}}
34+ </div>
35+ </div>
36+</div>
37+
38+<div class="mt-8">
39+ <h2 class="text-lg font-semibold text-spot-text mb-4">Browse all feeds</h2>
40+ <div class="bg-spot-surface rounded-lg divide-y divide-spot-divider">
41+ {{range .PopularFeeds}}
42+ <div class="px-5 py-4 flex items-center justify-between hover:bg-spot-hover-50 transition rounded-lg">
43+ <div class="min-w-0">
44+ <div class="font-bold text-spot-text">{{if .Title.Valid}}{{.Title.String}}{{else}}{{.FeedURL}}{{end}}</div>
45+ {{if .Description.Valid}}<p class="text-sm text-spot-secondary truncate">{{.Description.String}}</p>{{end}}
46+ </div>
47+ <div class="flex items-center gap-3 shrink-0">
48+ <span class="text-xs text-spot-secondary">{{.SubscriberCount}} subscribers</span>
49+ <button hx-post="/feeds" hx-vals='{"feed_url": "{{.FeedURL}}"}' hx-target="this" hx-swap="outerHTML"
50+ class="text-xs bg-spot-purple/20 text-spot-purple px-4 py-1.5 rounded-pill font-bold uppercase tracking-button hover:bg-spot-purple hover:text-spot-bg transition">Subscribe</button>
51+ </div>
52+ </div>
53+ {{else}}
54+ <div class="px-4 py-8 text-center text-spot-secondary">No feeds in the directory yet.</div>
55+ {{end}}
56+ </div>
57+</div>
58+{{end}}
new file mode 100644
@@ -0,0 +1,58 @@
1+{{define "discover.html"}}
2+<div class="mb-6">
3+ <h1 class="text-2xl font-bold font-title text-spot-text">Discover</h1>
4+ <p class="text-spot-secondary text-sm mt-1">Feeds and readers similar to you.</p>
5+</div>
6+
7+<div class="grid grid-cols-1 lg:grid-cols-2 gap-8">
8+ <div>
9+ <h2 class="text-lg font-semibold text-spot-text mb-4">Recommended feeds</h2>
10+ <div class="space-y-3">
11+ {{range .FeedRecommendations}}
12+ {{template "recommendation-card.html" .}}
13+ {{else}}
14+ <p class="text-sm text-spot-secondary">Subscribe to some feeds first to get recommendations.</p>
15+ {{end}}
16+ </div>
17+ </div>
18+
19+ <div>
20+ <h2 class="text-lg font-semibold text-spot-text mb-4">Similar readers</h2>
21+ <div class="space-y-3">
22+ {{range .PeopleRecommendations}}
23+ <div class="bg-spot-surface rounded-lg p-4 flex items-center gap-3 hover:bg-spot-hover-50 transition">
24+ {{if .avatar_url}}<img src="{{.avatar_url}}" class="w-10 h-10 rounded-full">{{end}}
25+ <div class="min-w-0 flex-1">
26+ <a href="/profile/{{.handle}}" class="font-bold text-spot-text hover:text-spot-purple transition">@{{.handle}}</a>
27+ {{if .display_name}}<div class="text-sm text-spot-secondary">{{.display_name}}</div>{{end}}
28+ </div>
29+ <span class="text-xs text-spot-secondary">{{.common_feeds}} shared feeds</span>
30+ </div>
31+ {{else}}
32+ <p class="text-sm text-spot-secondary">Not enough data yet. Keep reading!</p>
33+ {{end}}
34+ </div>
35+ </div>
36+</div>
37+
38+<div class="mt-8">
39+ <h2 class="text-lg font-semibold text-spot-text mb-4">Browse all feeds</h2>
40+ <div class="bg-spot-surface rounded-lg divide-y divide-spot-divider">
41+ {{range .PopularFeeds}}
42+ <div class="px-5 py-4 flex items-center justify-between hover:bg-spot-hover-50 transition rounded-lg">
43+ <div class="min-w-0">
44+ <div class="font-bold text-spot-text">{{if .Title.Valid}}{{.Title.String}}{{else}}{{.FeedURL}}{{end}}</div>
45+ {{if .Description.Valid}}<p class="text-sm text-spot-secondary truncate">{{.Description.String}}</p>{{end}}
46+ </div>
47+ <div class="flex items-center gap-3 shrink-0">
48+ <span class="text-xs text-spot-secondary">{{.SubscriberCount}} subscribers</span>
49+ <button hx-post="/feeds" hx-vals='{"feed_url": "{{.FeedURL}}"}' hx-target="this" hx-swap="outerHTML"
50+ class="text-xs bg-spot-purple/20 text-spot-purple px-4 py-1.5 rounded-pill font-bold uppercase tracking-button hover:bg-spot-purple hover:text-spot-bg transition">Subscribe</button>
51+ </div>
52+ </div>
53+ {{else}}
54+ <div class="px-4 py-8 text-center text-spot-secondary">No feeds in the directory yet.</div>
55+ {{end}}
56+ </div>
57+</div>
58+{{end}}
added internal/tmpl/feeds.html +103 -0
new file mode 100644
@@ -0,0 +1,103 @@
1+{{define "feeds.html"}}
2+<div class="flex items-center justify-between mb-6">
3+ <h1 class="text-2xl font-bold font-title text-spot-text">Feeds</h1>
4+ <div class="flex items-center gap-3">
5+ <button hx-post="/feeds/refresh" hx-target="#feed-list" hx-swap="innerHTML"
6+ hx-indicator="#refresh-indicator"
7+ class="border border-spot-outline text-spot-text rounded-pill px-4 py-1.5 text-xs font-bold uppercase tracking-button hover:border-spot-text transition">
8+ Refresh feeds
9+ </button>
10+ <span id="refresh-indicator" class="htmx-indicator text-sm text-spot-secondary">Fetching...</span>
11+ </div>
12+</div>
13+
14+<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
15+ <div class="lg:col-span-2">
16+ {{if .Categories}}
17+ <div class="flex gap-2 mb-6 flex-wrap">
18+ <a href="/feeds" class="text-sm px-4 py-1.5 rounded-full font-bold {{if not .Category}}bg-spot-active-pill-bg text-spot-active-pill-text{{else}}bg-spot-hover text-spot-secondary hover:text-spot-text{{end}} transition">All</a>
19+ {{range .Categories}}
20+ <a href="/feeds?category={{.}}" class="text-sm px-4 py-1.5 rounded-full font-bold {{if eq $.Category .}}bg-spot-active-pill-bg text-spot-active-pill-text{{else}}bg-spot-hover text-spot-secondary hover:text-spot-text{{end}} transition">{{.}}</a>
21+ {{end}}
22+ </div>
23+ {{end}}
24+
25+ <div id="feed-list" class="bg-spot-surface rounded-lg divide-y divide-spot-divider">
26+ {{range .Subscriptions}}
27+ <div class="px-5 py-4 flex items-center justify-between hover:bg-spot-hover-50 transition rounded-lg">
28+ <div class="min-w-0">
29+ <div class="font-bold text-spot-text">{{if .FeedTitle}}{{.FeedTitle}}{{else}}{{.FeedURL}}{{end}}</div>
30+ {{if .Category.Valid}}<span class="text-xs text-spot-secondary">{{.Category.String}}</span>{{end}}
31+ </div>
32+ <div class="flex items-center gap-3">
33+ {{if .UnreadCount}}<span class="text-xs bg-spot-purple/20 text-spot-purple px-2.5 py-0.5 rounded-full font-bold">{{.UnreadCount}}</span>{{end}}
34+ <button hx-delete="/feeds/{{.FeedURL}}" hx-target="closest .px-5" hx-swap="outerHTML swap:0.3s"
35+ hx-confirm="Unsubscribe from this feed?"
36+ class="text-sm text-spot-secondary hover:text-spot-red transition">Unsubscribe</button>
37+ </div>
38+ </div>
39+ {{else}}
40+ <div class="px-4 py-8 text-center text-spot-secondary">
41+ <p>No feeds yet. Add one below!</p>
42+ </div>
43+ {{end}}
44+ </div>
45+ </div>
46+
47+ <div class="space-y-6">
48+ <div class="bg-spot-surface rounded-lg p-4 space-y-4">
49+ <h2 class="text-sm font-bold text-spot-text uppercase tracking-wide">Add feed</h2>
50+ <form hx-post="/feeds/add" hx-target="#feed-list" hx-swap="beforeend" class="space-y-3">
51+ {{csrfInput .CSRFToken}}
52+ <input type="url" name="feed_url" placeholder="https://example.com/feed.xml"
53+ class="w-full bg-spot-hover text-spot-text rounded-pill px-5 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-spot-purple placeholder:text-spot-placeholder"
54+ required>
55+ <input type="text" name="category" placeholder="Category (optional)"
56+ class="w-full bg-spot-hover text-spot-text rounded-pill px-5 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-spot-purple placeholder:text-spot-placeholder">
57+ <button type="submit" class="w-full bg-spot-purple text-spot-bg rounded-pill px-5 py-2 text-sm font-bold uppercase tracking-button hover:brightness-110 transition">Add</button>
58+ </form>
59+ <form hx-post="/feeds/opml/upload" hx-encoding="multipart/form-data" class="flex flex-col gap-2">
60+ {{csrfInput .CSRFToken}}
61+ <label class="text-xs text-spot-secondary font-bold uppercase tracking-wide">Import OPML</label>
62+ <input type="file" name="opml" accept=".opml,.xml" class="text-sm text-spot-secondary file:mr-2 file:py-1 file:px-3 file:rounded-pill file:border-0 file:text-sm file:bg-spot-hover file:text-spot-text hover:file:bg-spot-surface file:cursor-pointer">
63+ <button type="submit" class="w-full border border-spot-outline text-spot-text rounded-pill px-4 py-1.5 text-sm font-bold uppercase tracking-button hover:border-spot-text transition">Import</button>
64+ </form>
65+ </div>
66+
67+ {{if .FeedRecommendations}}
68+ <div>
69+ <div class="flex items-center justify-between mb-4">
70+ <h2 class="text-sm font-bold text-spot-text uppercase tracking-wide">Recommended feeds</h2>
71+ <a href="/discover" class="text-xs text-spot-secondary hover:text-spot-purple uppercase tracking-button transition">See all</a>
72+ </div>
73+ <div class="space-y-3">
74+ {{range .FeedRecommendations}}
75+ {{template "recommendation-card.html" .}}
76+ {{end}}
77+ </div>
78+ </div>
79+ {{end}}
80+
81+ {{if .PeopleRecommendations}}
82+ <div>
83+ <div class="flex items-center justify-between mb-4">
84+ <h2 class="text-sm font-bold text-spot-text uppercase tracking-wide">Similar readers</h2>
85+ <a href="/discover" class="text-xs text-spot-secondary hover:text-spot-purple uppercase tracking-button transition">See all</a>
86+ </div>
87+ <div class="space-y-3">
88+ {{range .PeopleRecommendations}}
89+ <div class="bg-spot-surface rounded-lg p-4 flex items-center gap-3 hover:bg-spot-hover-50 transition">
90+ {{if .avatar_url}}<img src="{{.avatar_url}}" class="w-10 h-10 rounded-full">{{end}}
91+ <div class="min-w-0 flex-1">
92+ <a href="/profile/{{.handle}}" class="font-bold text-spot-text hover:text-spot-purple transition">@{{.handle}}</a>
93+ {{if .display_name}}<div class="text-sm text-spot-secondary">{{.display_name}}</div>{{end}}
94+ </div>
95+ <span class="text-xs text-spot-secondary">{{.common_feeds}} shared</span>
96+ </div>
97+ {{end}}
98+ </div>
99+ </div>
100+ {{end}}
101+ </div>
102+</div>
103+{{end}}
new file mode 100644
@@ -0,0 +1,103 @@
1+{{define "feeds.html"}}
2+<div class="flex items-center justify-between mb-6">
3+ <h1 class="text-2xl font-bold font-title text-spot-text">Feeds</h1>
4+ <div class="flex items-center gap-3">
5+ <button hx-post="/feeds/refresh" hx-target="#feed-list" hx-swap="innerHTML"
6+ hx-indicator="#refresh-indicator"
7+ class="border border-spot-outline text-spot-text rounded-pill px-4 py-1.5 text-xs font-bold uppercase tracking-button hover:border-spot-text transition">
8+ Refresh feeds
9+ </button>
10+ <span id="refresh-indicator" class="htmx-indicator text-sm text-spot-secondary">Fetching...</span>
11+ </div>
12+</div>
13+
14+<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
15+ <div class="lg:col-span-2">
16+ {{if .Categories}}
17+ <div class="flex gap-2 mb-6 flex-wrap">
18+ <a href="/feeds" class="text-sm px-4 py-1.5 rounded-full font-bold {{if not .Category}}bg-spot-active-pill-bg text-spot-active-pill-text{{else}}bg-spot-hover text-spot-secondary hover:text-spot-text{{end}} transition">All</a>
19+ {{range .Categories}}
20+ <a href="/feeds?category={{.}}" class="text-sm px-4 py-1.5 rounded-full font-bold {{if eq $.Category .}}bg-spot-active-pill-bg text-spot-active-pill-text{{else}}bg-spot-hover text-spot-secondary hover:text-spot-text{{end}} transition">{{.}}</a>
21+ {{end}}
22+ </div>
23+ {{end}}
24+
25+ <div id="feed-list" class="bg-spot-surface rounded-lg divide-y divide-spot-divider">
26+ {{range .Subscriptions}}
27+ <div class="px-5 py-4 flex items-center justify-between hover:bg-spot-hover-50 transition rounded-lg">
28+ <div class="min-w-0">
29+ <div class="font-bold text-spot-text">{{if .FeedTitle}}{{.FeedTitle}}{{else}}{{.FeedURL}}{{end}}</div>
30+ {{if .Category.Valid}}<span class="text-xs text-spot-secondary">{{.Category.String}}</span>{{end}}
31+ </div>
32+ <div class="flex items-center gap-3">
33+ {{if .UnreadCount}}<span class="text-xs bg-spot-purple/20 text-spot-purple px-2.5 py-0.5 rounded-full font-bold">{{.UnreadCount}}</span>{{end}}
34+ <button hx-delete="/feeds/{{.FeedURL}}" hx-target="closest .px-5" hx-swap="outerHTML swap:0.3s"
35+ hx-confirm="Unsubscribe from this feed?"
36+ class="text-sm text-spot-secondary hover:text-spot-red transition">Unsubscribe</button>
37+ </div>
38+ </div>
39+ {{else}}
40+ <div class="px-4 py-8 text-center text-spot-secondary">
41+ <p>No feeds yet. Add one below!</p>
42+ </div>
43+ {{end}}
44+ </div>
45+ </div>
46+
47+ <div class="space-y-6">
48+ <div class="bg-spot-surface rounded-lg p-4 space-y-4">
49+ <h2 class="text-sm font-bold text-spot-text uppercase tracking-wide">Add feed</h2>
50+ <form hx-post="/feeds/add" hx-target="#feed-list" hx-swap="beforeend" class="space-y-3">
51+ {{csrfInput .CSRFToken}}
52+ <input type="url" name="feed_url" placeholder="https://example.com/feed.xml"
53+ class="w-full bg-spot-hover text-spot-text rounded-pill px-5 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-spot-purple placeholder:text-spot-placeholder"
54+ required>
55+ <input type="text" name="category" placeholder="Category (optional)"
56+ class="w-full bg-spot-hover text-spot-text rounded-pill px-5 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-spot-purple placeholder:text-spot-placeholder">
57+ <button type="submit" class="w-full bg-spot-purple text-spot-bg rounded-pill px-5 py-2 text-sm font-bold uppercase tracking-button hover:brightness-110 transition">Add</button>
58+ </form>
59+ <form hx-post="/feeds/opml/upload" hx-encoding="multipart/form-data" class="flex flex-col gap-2">
60+ {{csrfInput .CSRFToken}}
61+ <label class="text-xs text-spot-secondary font-bold uppercase tracking-wide">Import OPML</label>
62+ <input type="file" name="opml" accept=".opml,.xml" class="text-sm text-spot-secondary file:mr-2 file:py-1 file:px-3 file:rounded-pill file:border-0 file:text-sm file:bg-spot-hover file:text-spot-text hover:file:bg-spot-surface file:cursor-pointer">
63+ <button type="submit" class="w-full border border-spot-outline text-spot-text rounded-pill px-4 py-1.5 text-sm font-bold uppercase tracking-button hover:border-spot-text transition">Import</button>
64+ </form>
65+ </div>
66+
67+ {{if .FeedRecommendations}}
68+ <div>
69+ <div class="flex items-center justify-between mb-4">
70+ <h2 class="text-sm font-bold text-spot-text uppercase tracking-wide">Recommended feeds</h2>
71+ <a href="/discover" class="text-xs text-spot-secondary hover:text-spot-purple uppercase tracking-button transition">See all</a>
72+ </div>
73+ <div class="space-y-3">
74+ {{range .FeedRecommendations}}
75+ {{template "recommendation-card.html" .}}
76+ {{end}}
77+ </div>
78+ </div>
79+ {{end}}
80+
81+ {{if .PeopleRecommendations}}
82+ <div>
83+ <div class="flex items-center justify-between mb-4">
84+ <h2 class="text-sm font-bold text-spot-text uppercase tracking-wide">Similar readers</h2>
85+ <a href="/discover" class="text-xs text-spot-secondary hover:text-spot-purple uppercase tracking-button transition">See all</a>
86+ </div>
87+ <div class="space-y-3">
88+ {{range .PeopleRecommendations}}
89+ <div class="bg-spot-surface rounded-lg p-4 flex items-center gap-3 hover:bg-spot-hover-50 transition">
90+ {{if .avatar_url}}<img src="{{.avatar_url}}" class="w-10 h-10 rounded-full">{{end}}
91+ <div class="min-w-0 flex-1">
92+ <a href="/profile/{{.handle}}" class="font-bold text-spot-text hover:text-spot-purple transition">@{{.handle}}</a>
93+ {{if .display_name}}<div class="text-sm text-spot-secondary">{{.display_name}}</div>{{end}}
94+ </div>
95+ <span class="text-xs text-spot-secondary">{{.common_feeds}} shared</span>
96+ </div>
97+ {{end}}
98+ </div>
99+ </div>
100+ {{end}}
101+ </div>
102+</div>
103+{{end}}
added internal/tmpl/index.html +33 -0
new file mode 100644
@@ -0,0 +1,33 @@
1+{{define "index.html"}}
2+<div class="max-w-2xl mx-auto mt-20 lg:mt-32 text-center">
3+ <h1 class="text-5xl font-bold font-title mb-6 text-spot-text">Glean</h1>
4+ <p class="text-lg text-spot-secondary mb-10 max-w-lg mx-auto leading-relaxed">A social RSS reader. Follow feeds, share what you read, discover new sources through your network.</p>
5+ <div class="flex flex-col sm:flex-row justify-center gap-3">
6+ <a href="/auth/login" class="bg-spot-purple text-spot-bg rounded-pill px-8 py-3 text-sm font-bold uppercase tracking-button hover:brightness-110 transition">Sign in with Bluesky</a>
7+ <a href="/trending" class="border border-spot-outline text-spot-text rounded-pill px-8 py-3 text-sm font-bold uppercase tracking-button hover:border-spot-text transition">Browse trending</a>
8+ </div>
9+ <div class="mt-24 grid grid-cols-1 sm:grid-cols-3 gap-4 text-left">
10+ <div class="bg-spot-surface hover:bg-spot-hover rounded-lg p-6 transition shadow-spot">
11+ <div class="text-spot-purple text-2xl mb-3">
12+ <svg class="w-7 h-7" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 5c7.18 0 13 5.82 13 13M6 11a7 7 0 017 7m-7-1a1 1 0 11-2 0 1 1 0 012 0z"/></svg>
13+ </div>
14+ <h3 class="font-bold text-spot-text mb-1">Follow any feed</h3>
15+ <p class="text-sm text-spot-secondary">Subscribe to RSS and Atom feeds. Organize with categories. Import via OPML.</p>
16+ </div>
17+ <div class="bg-spot-surface hover:bg-spot-hover rounded-lg p-6 transition shadow-spot">
18+ <div class="text-spot-purple text-2xl mb-3">
19+ <svg class="w-7 h-7" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z"/></svg>
20+ </div>
21+ <h3 class="font-bold text-spot-text mb-1">Read together</h3>
22+ <p class="text-sm text-spot-secondary">See what your network reads. Like and annotate articles. Share discoveries.</p>
23+ </div>
24+ <div class="bg-spot-surface hover:bg-spot-hover rounded-lg p-6 transition shadow-spot">
25+ <div class="text-spot-purple text-2xl mb-3">
26+ <svg class="w-7 h-7" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/></svg>
27+ </div>
28+ <h3 class="font-bold text-spot-text mb-1">Find new sources</h3>
29+ <p class="text-sm text-spot-secondary">Get recommendations based on your reading. Find people with similar tastes.</p>
30+ </div>
31+ </div>
32+</div>
33+{{end}}
new file mode 100644
@@ -0,0 +1,33 @@
1+{{define "index.html"}}
2+<div class="max-w-2xl mx-auto mt-20 lg:mt-32 text-center">
3+ <h1 class="text-5xl font-bold font-title mb-6 text-spot-text">Glean</h1>
4+ <p class="text-lg text-spot-secondary mb-10 max-w-lg mx-auto leading-relaxed">A social RSS reader. Follow feeds, share what you read, discover new sources through your network.</p>
5+ <div class="flex flex-col sm:flex-row justify-center gap-3">
6+ <a href="/auth/login" class="bg-spot-purple text-spot-bg rounded-pill px-8 py-3 text-sm font-bold uppercase tracking-button hover:brightness-110 transition">Sign in with Bluesky</a>
7+ <a href="/trending" class="border border-spot-outline text-spot-text rounded-pill px-8 py-3 text-sm font-bold uppercase tracking-button hover:border-spot-text transition">Browse trending</a>
8+ </div>
9+ <div class="mt-24 grid grid-cols-1 sm:grid-cols-3 gap-4 text-left">
10+ <div class="bg-spot-surface hover:bg-spot-hover rounded-lg p-6 transition shadow-spot">
11+ <div class="text-spot-purple text-2xl mb-3">
12+ <svg class="w-7 h-7" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 5c7.18 0 13 5.82 13 13M6 11a7 7 0 017 7m-7-1a1 1 0 11-2 0 1 1 0 012 0z"/></svg>
13+ </div>
14+ <h3 class="font-bold text-spot-text mb-1">Follow any feed</h3>
15+ <p class="text-sm text-spot-secondary">Subscribe to RSS and Atom feeds. Organize with categories. Import via OPML.</p>
16+ </div>
17+ <div class="bg-spot-surface hover:bg-spot-hover rounded-lg p-6 transition shadow-spot">
18+ <div class="text-spot-purple text-2xl mb-3">
19+ <svg class="w-7 h-7" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z"/></svg>
20+ </div>
21+ <h3 class="font-bold text-spot-text mb-1">Read together</h3>
22+ <p class="text-sm text-spot-secondary">See what your network reads. Like and annotate articles. Share discoveries.</p>
23+ </div>
24+ <div class="bg-spot-surface hover:bg-spot-hover rounded-lg p-6 transition shadow-spot">
25+ <div class="text-spot-purple text-2xl mb-3">
26+ <svg class="w-7 h-7" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/></svg>
27+ </div>
28+ <h3 class="font-bold text-spot-text mb-1">Find new sources</h3>
29+ <p class="text-sm text-spot-secondary">Get recommendations based on your reading. Find people with similar tastes.</p>
30+ </div>
31+ </div>
32+</div>
33+{{end}}
added internal/tmpl/login.html +17 -0
new file mode 100644
@@ -0,0 +1,17 @@
1+{{define "login.html"}}
2+<div class="max-w-md mx-auto mt-20">
3+ <div class="bg-spot-surface rounded-lg shadow-spot-heavy p-8">
4+ <h1 class="text-2xl font-bold font-title text-spot-text mb-6">Sign in to Glean</h1>
5+ <form action="/auth/callback" method="GET">
6+ <label class="block text-sm font-bold text-spot-secondary mb-2 uppercase tracking-button">Bluesky Handle</label>
7+ <input type="text" name="handle" placeholder="you.bsky.social"
8+ class="w-full bg-spot-hover text-spot-text rounded-pill px-5 py-3 mb-5 focus:outline-none focus:ring-2 focus:ring-spot-purple placeholder:text-spot-placeholder"
9+ required>
10+ <button type="submit" class="w-full bg-spot-purple text-spot-bg rounded-pill px-4 py-3 text-sm font-bold uppercase tracking-button hover:brightness-110 transition">
11+ Sign in with Bluesky
12+ </button>
13+ </form>
14+ <p class="mt-5 text-sm text-spot-secondary">We'll authenticate you via your Bluesky account. No password needed.</p>
15+ </div>
16+</div>
17+{{end}}
new file mode 100644
@@ -0,0 +1,17 @@
1+{{define "login.html"}}
2+<div class="max-w-md mx-auto mt-20">
3+ <div class="bg-spot-surface rounded-lg shadow-spot-heavy p-8">
4+ <h1 class="text-2xl font-bold font-title text-spot-text mb-6">Sign in to Glean</h1>
5+ <form action="/auth/callback" method="GET">
6+ <label class="block text-sm font-bold text-spot-secondary mb-2 uppercase tracking-button">Bluesky Handle</label>
7+ <input type="text" name="handle" placeholder="you.bsky.social"
8+ class="w-full bg-spot-hover text-spot-text rounded-pill px-5 py-3 mb-5 focus:outline-none focus:ring-2 focus:ring-spot-purple placeholder:text-spot-placeholder"
9+ required>
10+ <button type="submit" class="w-full bg-spot-purple text-spot-bg rounded-pill px-4 py-3 text-sm font-bold uppercase tracking-button hover:brightness-110 transition">
11+ Sign in with Bluesky
12+ </button>
13+ </form>
14+ <p class="mt-5 text-sm text-spot-secondary">We'll authenticate you via your Bluesky account. No password needed.</p>
15+ </div>
16+</div>
17+{{end}}
added internal/tmpl/partials/annotation-card.html +22 -0
new file mode 100644
@@ -0,0 +1,22 @@
1+{{define "annotation-card.html"}}
2+<div class="bg-spot-surface rounded-lg p-4">
3+ {{if .Quote.Valid}}
4+ <blockquote class="border-l-2 border-spot-purple pl-3 text-sm text-spot-body italic">{{.Quote.String}}</blockquote>
5+ {{end}}
6+ {{if .Note.Valid}}
7+ <p class="text-sm text-spot-body mt-2">{{.Note.String}}</p>
8+ {{end}}
9+ {{if .Tags.Valid}}
10+ <div class="flex gap-1 mt-2 flex-wrap">
11+ {{range $tag := split .Tags.String ","}}<span class="text-xs bg-spot-hover text-spot-secondary px-2.5 py-0.5 rounded-full">{{$tag}}</span>{{end}}
12+ </div>
13+ {{end}}
14+ {{if .Rating.Valid}}
15+ <div class="text-sm text-spot-orange mt-1">{{repeat "&#9733;" (int .Rating.Int64)}}</div>
16+ {{end}}
17+ <div class="text-xs text-spot-muted mt-2">
18+ <a href="/profile/{{.AuthorDID}}" class="hover:text-spot-purple transition">{{.AuthorDID}}</a>
19+ {{if .CreatedAt.Valid}}<span class="ml-2">{{.CreatedAt.Time.Format "Jan 2, 2006 15:04"}}</span>{{end}}
20+ </div>
21+</div>
22+{{end}}
new file mode 100644
@@ -0,0 +1,22 @@
1+{{define "annotation-card.html"}}
2+<div class="bg-spot-surface rounded-lg p-4">
3+ {{if .Quote.Valid}}
4+ <blockquote class="border-l-2 border-spot-purple pl-3 text-sm text-spot-body italic">{{.Quote.String}}</blockquote>
5+ {{end}}
6+ {{if .Note.Valid}}
7+ <p class="text-sm text-spot-body mt-2">{{.Note.String}}</p>
8+ {{end}}
9+ {{if .Tags.Valid}}
10+ <div class="flex gap-1 mt-2 flex-wrap">
11+ {{range $tag := split .Tags.String ","}}<span class="text-xs bg-spot-hover text-spot-secondary px-2.5 py-0.5 rounded-full">{{$tag}}</span>{{end}}
12+ </div>
13+ {{end}}
14+ {{if .Rating.Valid}}
15+ <div class="text-sm text-spot-orange mt-1">{{repeat "&#9733;" (int .Rating.Int64)}}</div>
16+ {{end}}
17+ <div class="text-xs text-spot-muted mt-2">
18+ <a href="/profile/{{.AuthorDID}}" class="hover:text-spot-purple transition">{{.AuthorDID}}</a>
19+ {{if .CreatedAt.Valid}}<span class="ml-2">{{.CreatedAt.Time.Format "Jan 2, 2006 15:04"}}</span>{{end}}
20+ </div>
21+</div>
22+{{end}}
added internal/tmpl/partials/article-card.html +20 -0
new file mode 100644
@@ -0,0 +1,20 @@
1+{{define "article-card.html"}}
2+<article class="bg-spot-surface rounded-lg px-5 py-4 hover:bg-spot-hover-50 transition shadow-spot">
3+ <div class="flex items-start justify-between gap-4">
4+ <div class="min-w-0 flex-1">
5+ <a href="/articles/{{.ID}}" class="font-bold text-spot-text hover:text-spot-purple transition text-lg leading-tight">{{.Title}}</a>
6+ <div class="text-sm text-spot-secondary mt-1 flex items-center gap-2">
7+ {{if .Author.Valid}}{{if .Author.String}}<span>{{.Author.String}}</span>{{end}}{{end}}
8+ {{if .Published.Valid}}<span>{{.Published.Time.Format "Jan 2, 2006 15:04"}}</span>{{end}}
9+ <span class="text-spot-muted">{{if .FeedTitle}}{{.FeedTitle}}{{else}}{{.FeedURL}}{{end}}</span>
10+ </div>
11+ {{if .Summary.Valid}}{{if .Summary.String}}
12+ <p class="text-sm text-spot-secondary mt-2 line-clamp-2">{{plainText .Summary.String}}</p>
13+ {{end}}{{end}}
14+ </div>
15+ <div class="flex flex-col items-center gap-1 shrink-0">
16+ {{template "like-button.html" .}}
17+ </div>
18+ </div>
19+</article>
20+{{end}}
new file mode 100644
@@ -0,0 +1,20 @@
1+{{define "article-card.html"}}
2+<article class="bg-spot-surface rounded-lg px-5 py-4 hover:bg-spot-hover-50 transition shadow-spot">
3+ <div class="flex items-start justify-between gap-4">
4+ <div class="min-w-0 flex-1">
5+ <a href="/articles/{{.ID}}" class="font-bold text-spot-text hover:text-spot-purple transition text-lg leading-tight">{{.Title}}</a>
6+ <div class="text-sm text-spot-secondary mt-1 flex items-center gap-2">
7+ {{if .Author.Valid}}{{if .Author.String}}<span>{{.Author.String}}</span>{{end}}{{end}}
8+ {{if .Published.Valid}}<span>{{.Published.Time.Format "Jan 2, 2006 15:04"}}</span>{{end}}
9+ <span class="text-spot-muted">{{if .FeedTitle}}{{.FeedTitle}}{{else}}{{.FeedURL}}{{end}}</span>
10+ </div>
11+ {{if .Summary.Valid}}{{if .Summary.String}}
12+ <p class="text-sm text-spot-secondary mt-2 line-clamp-2">{{plainText .Summary.String}}</p>
13+ {{end}}{{end}}
14+ </div>
15+ <div class="flex flex-col items-center gap-1 shrink-0">
16+ {{template "like-button.html" .}}
17+ </div>
18+ </div>
19+</article>
20+{{end}}
added internal/tmpl/partials/article-list.html +21 -0
new file mode 100644
@@ -0,0 +1,21 @@
1+{{define "article-list.html"}}
2+{{range .Articles}}
3+<article class="bg-spot-surface rounded-lg px-5 py-4 hover:bg-spot-hover-50 transition shadow-spot">
4+ <div class="flex items-start justify-between gap-4">
5+ <div class="min-w-0 flex-1">
6+ <a href="/articles/{{.ID}}" class="font-bold text-spot-text hover:text-spot-purple transition text-lg leading-tight">{{.Title}}</a>
7+ <div class="text-sm text-spot-secondary mt-1 flex items-center gap-2">
8+ {{if .Author.Valid}}{{if .Author.String}}<span>{{.Author.String}}</span>{{end}}{{end}}
9+ {{if .Published.Valid}}<span>{{.Published.Time.Format "Jan 2, 2006 15:04"}}</span>{{end}}
10+ <span class="text-spot-muted">{{if .FeedTitle}}{{.FeedTitle}}{{else}}{{.FeedURL}}{{end}}</span>
11+ </div>
12+ {{if .Summary.Valid}}{{if .Summary.String}}
13+ <p class="text-sm text-spot-secondary mt-2 line-clamp-2">{{plainText .Summary.String}}</p>
14+ {{end}}{{end}}
15+ </div>
16+ </div>
17+</article>
18+{{else}}
19+<div class="text-center text-spot-secondary py-12">No articles found.</div>
20+{{end}}
21+{{end}}
new file mode 100644
@@ -0,0 +1,21 @@
1+{{define "article-list.html"}}
2+{{range .Articles}}
3+<article class="bg-spot-surface rounded-lg px-5 py-4 hover:bg-spot-hover-50 transition shadow-spot">
4+ <div class="flex items-start justify-between gap-4">
5+ <div class="min-w-0 flex-1">
6+ <a href="/articles/{{.ID}}" class="font-bold text-spot-text hover:text-spot-purple transition text-lg leading-tight">{{.Title}}</a>
7+ <div class="text-sm text-spot-secondary mt-1 flex items-center gap-2">
8+ {{if .Author.Valid}}{{if .Author.String}}<span>{{.Author.String}}</span>{{end}}{{end}}
9+ {{if .Published.Valid}}<span>{{.Published.Time.Format "Jan 2, 2006 15:04"}}</span>{{end}}
10+ <span class="text-spot-muted">{{if .FeedTitle}}{{.FeedTitle}}{{else}}{{.FeedURL}}{{end}}</span>
11+ </div>
12+ {{if .Summary.Valid}}{{if .Summary.String}}
13+ <p class="text-sm text-spot-secondary mt-2 line-clamp-2">{{plainText .Summary.String}}</p>
14+ {{end}}{{end}}
15+ </div>
16+ </div>
17+</article>
18+{{else}}
19+<div class="text-center text-spot-secondary py-12">No articles found.</div>
20+{{end}}
21+{{end}}
added internal/tmpl/partials/feed-list.html +15 -0
new file mode 100644
@@ -0,0 +1,15 @@
1+{{define "feed-list.html"}}
2+{{range .Subscriptions}}
3+<div class="px-5 py-4 flex items-center justify-between hover:bg-spot-hover-50 transition rounded-lg">
4+ <div class="min-w-0">
5+ <div class="font-bold text-spot-text">{{.FeedURL}}</div>
6+ {{if .Category.Valid}}<span class="text-xs text-spot-secondary">{{.Category.String}}</span>{{end}}
7+ </div>
8+ <button hx-delete="/feeds/remove" hx-vals='{"url": "{{.FeedURL}}"}' hx-target="closest .px-5" hx-swap="outerHTML swap:0.3s"
9+ hx-confirm="Unsubscribe from this feed?"
10+ class="text-sm text-spot-secondary hover:text-spot-red transition">Unsubscribe</button>
11+</div>
12+{{else}}
13+<div class="px-4 py-8 text-center text-spot-secondary">No feeds yet.</div>
14+{{end}}
15+{{end}}
new file mode 100644
@@ -0,0 +1,15 @@
1+{{define "feed-list.html"}}
2+{{range .Subscriptions}}
3+<div class="px-5 py-4 flex items-center justify-between hover:bg-spot-hover-50 transition rounded-lg">
4+ <div class="min-w-0">
5+ <div class="font-bold text-spot-text">{{.FeedURL}}</div>
6+ {{if .Category.Valid}}<span class="text-xs text-spot-secondary">{{.Category.String}}</span>{{end}}
7+ </div>
8+ <button hx-delete="/feeds/remove" hx-vals='{"url": "{{.FeedURL}}"}' hx-target="closest .px-5" hx-swap="outerHTML swap:0.3s"
9+ hx-confirm="Unsubscribe from this feed?"
10+ class="text-sm text-spot-secondary hover:text-spot-red transition">Unsubscribe</button>
11+</div>
12+{{else}}
13+<div class="px-4 py-8 text-center text-spot-secondary">No feeds yet.</div>
14+{{end}}
15+{{end}}
added internal/tmpl/partials/like-button.html +4 -0
new file mode 100644
@@ -0,0 +1,4 @@
1+{{define "like-button.html"}}
2+<button hx-post="/articles/{{.ID}}/like" hx-target="this" hx-swap="outerHTML"
3+ class="text-spot-muted hover:text-spot-red text-lg transition">&#9829;</button>
4+{{end}}
new file mode 100644
@@ -0,0 +1,4 @@
1+{{define "like-button.html"}}
2+<button hx-post="/articles/{{.ID}}/like" hx-target="this" hx-swap="outerHTML"
3+ class="text-spot-muted hover:text-spot-red text-lg transition">&#9829;</button>
4+{{end}}
added internal/tmpl/partials/profile-card.html +9 -0
new file mode 100644
@@ -0,0 +1,9 @@
1+{{define "profile-card.html"}}
2+<div class="bg-spot-surface rounded-lg p-4 flex items-center gap-3 hover:bg-spot-hover-50 transition">
3+ <div class="min-w-0 flex-1">
4+ <a href="/profile/{{.did}}" class="font-bold text-spot-text hover:text-spot-purple transition">@{{.handle}}</a>
5+ {{if .display_name}}<div class="text-sm text-spot-secondary">{{.display_name}}</div>{{end}}
6+ </div>
7+ <span class="text-xs text-spot-secondary">{{.common_feeds}} shared feeds</span>
8+</div>
9+{{end}}
new file mode 100644
@@ -0,0 +1,9 @@
1+{{define "profile-card.html"}}
2+<div class="bg-spot-surface rounded-lg p-4 flex items-center gap-3 hover:bg-spot-hover-50 transition">
3+ <div class="min-w-0 flex-1">
4+ <a href="/profile/{{.did}}" class="font-bold text-spot-text hover:text-spot-purple transition">@{{.handle}}</a>
5+ {{if .display_name}}<div class="text-sm text-spot-secondary">{{.display_name}}</div>{{end}}
6+ </div>
7+ <span class="text-xs text-spot-secondary">{{.common_feeds}} shared feeds</span>
8+</div>
9+{{end}}
added internal/tmpl/partials/recommendation-card.html +11 -0
new file mode 100644
@@ -0,0 +1,11 @@
1+{{define "recommendation-card.html"}}
2+<div class="bg-spot-surface rounded-lg p-3 hover:bg-spot-hover-50 transition">
3+ <div class="flex items-center justify-between">
4+ <div class="min-w-0">
5+ <div class="font-bold text-sm text-spot-text">{{if .title}}{{.title}}{{else}}{{.feed_url}}{{end}}</div>
6+ {{if .description}}<p class="text-xs text-spot-secondary truncate mt-0.5">{{.description}}</p>{{end}}
7+ </div>
8+ <span class="text-xs text-spot-secondary shrink-0 ml-2">{{.subscriber_count}} subs</span>
9+ </div>
10+</div>
11+{{end}}
new file mode 100644
@@ -0,0 +1,11 @@
1+{{define "recommendation-card.html"}}
2+<div class="bg-spot-surface rounded-lg p-3 hover:bg-spot-hover-50 transition">
3+ <div class="flex items-center justify-between">
4+ <div class="min-w-0">
5+ <div class="font-bold text-sm text-spot-text">{{if .title}}{{.title}}{{else}}{{.feed_url}}{{end}}</div>
6+ {{if .description}}<p class="text-xs text-spot-secondary truncate mt-0.5">{{.description}}</p>{{end}}
7+ </div>
8+ <span class="text-xs text-spot-secondary shrink-0 ml-2">{{.subscriber_count}} subs</span>
9+ </div>
10+</div>
11+{{end}}
added internal/tmpl/profile.html +47 -0
new file mode 100644
@@ -0,0 +1,47 @@
1+{{define "profile.html"}}
2+<div class="max-w-2xl mx-auto">
3+ <div class="bg-spot-surface rounded-lg p-6 mb-6">
4+ <div class="flex items-center gap-4">
5+ {{if .ProfileUser.AvatarURL.Valid}}<img src="{{.ProfileUser.AvatarURL.String}}" class="w-16 h-16 rounded-full">{{end}}
6+ <div>
7+ <h1 class="text-2xl font-bold font-title text-spot-text">
8+ {{if .ProfileUser.DisplayName.Valid}}{{.ProfileUser.DisplayName.String}}{{end}}
9+ </h1>
10+ <p class="text-spot-secondary">@{{.ProfileUser.Handle}}</p>
11+ </div>
12+ </div>
13+ </div>
14+
15+ <div class="grid grid-cols-2 gap-4 mb-6">
16+ <div class="bg-spot-surface rounded-lg p-4 text-center">
17+ <div class="text-2xl font-bold text-spot-text">{{.SubscriptionCount}}</div>
18+ <div class="text-sm text-spot-secondary">Feeds</div>
19+ </div>
20+ <div class="bg-spot-surface rounded-lg p-4 text-center">
21+ <div class="text-2xl font-bold text-spot-text">{{.AnnotationCount}}</div>
22+ <div class="text-sm text-spot-secondary">Annotations</div>
23+ </div>
24+ </div>
25+
26+ <h2 class="text-lg font-semibold text-spot-text mb-3">Feeds</h2>
27+ <div class="bg-spot-surface rounded-lg divide-y divide-spot-divider mb-6">
28+ {{range .Subscriptions}}
29+ <div class="px-5 py-3 hover:bg-spot-hover-50 transition rounded-lg">
30+ <div class="font-bold text-spot-text">{{if .FeedTitle}}{{.FeedTitle}}{{else}}{{.FeedURL}}{{end}}</div>
31+ {{if .Category.Valid}}<span class="text-xs text-spot-secondary">{{.Category.String}}</span>{{end}}
32+ </div>
33+ {{else}}
34+ <div class="px-4 py-6 text-center text-spot-secondary text-sm">No public feeds.</div>
35+ {{end}}
36+ </div>
37+
38+ <h2 class="text-lg font-semibold text-spot-text mb-3">Recent annotations</h2>
39+ <div class="space-y-3">
40+ {{range .Annotations}}
41+ {{template "annotation-card.html" .}}
42+ {{else}}
43+ <p class="text-sm text-spot-secondary">No public annotations.</p>
44+ {{end}}
45+ </div>
46+</div>
47+{{end}}
new file mode 100644
@@ -0,0 +1,47 @@
1+{{define "profile.html"}}
2+<div class="max-w-2xl mx-auto">
3+ <div class="bg-spot-surface rounded-lg p-6 mb-6">
4+ <div class="flex items-center gap-4">
5+ {{if .ProfileUser.AvatarURL.Valid}}<img src="{{.ProfileUser.AvatarURL.String}}" class="w-16 h-16 rounded-full">{{end}}
6+ <div>
7+ <h1 class="text-2xl font-bold font-title text-spot-text">
8+ {{if .ProfileUser.DisplayName.Valid}}{{.ProfileUser.DisplayName.String}}{{end}}
9+ </h1>
10+ <p class="text-spot-secondary">@{{.ProfileUser.Handle}}</p>
11+ </div>
12+ </div>
13+ </div>
14+
15+ <div class="grid grid-cols-2 gap-4 mb-6">
16+ <div class="bg-spot-surface rounded-lg p-4 text-center">
17+ <div class="text-2xl font-bold text-spot-text">{{.SubscriptionCount}}</div>
18+ <div class="text-sm text-spot-secondary">Feeds</div>
19+ </div>
20+ <div class="bg-spot-surface rounded-lg p-4 text-center">
21+ <div class="text-2xl font-bold text-spot-text">{{.AnnotationCount}}</div>
22+ <div class="text-sm text-spot-secondary">Annotations</div>
23+ </div>
24+ </div>
25+
26+ <h2 class="text-lg font-semibold text-spot-text mb-3">Feeds</h2>
27+ <div class="bg-spot-surface rounded-lg divide-y divide-spot-divider mb-6">
28+ {{range .Subscriptions}}
29+ <div class="px-5 py-3 hover:bg-spot-hover-50 transition rounded-lg">
30+ <div class="font-bold text-spot-text">{{if .FeedTitle}}{{.FeedTitle}}{{else}}{{.FeedURL}}{{end}}</div>
31+ {{if .Category.Valid}}<span class="text-xs text-spot-secondary">{{.Category.String}}</span>{{end}}
32+ </div>
33+ {{else}}
34+ <div class="px-4 py-6 text-center text-spot-secondary text-sm">No public feeds.</div>
35+ {{end}}
36+ </div>
37+
38+ <h2 class="text-lg font-semibold text-spot-text mb-3">Recent annotations</h2>
39+ <div class="space-y-3">
40+ {{range .Annotations}}
41+ {{template "annotation-card.html" .}}
42+ {{else}}
43+ <p class="text-sm text-spot-secondary">No public annotations.</p>
44+ {{end}}
45+ </div>
46+</div>
47+{{end}}
modified main.go +107 -1
@@ -1,3 +1,109 @@
11 package main
22
3-func main() {}
3+import (
4+ "context"
5+ "flag"
6+ "fmt"
7+ "log/slog"
8+ "net/http"
9+ "os"
10+ "os/signal"
11+ "syscall"
12+ "time"
13+
14+ "pkg.rbrt.fr/glean/internal/atproto"
15+ "pkg.rbrt.fr/glean/internal/cluster"
16+ "pkg.rbrt.fr/glean/internal/db"
17+ "pkg.rbrt.fr/glean/internal/feed"
18+ "pkg.rbrt.fr/glean/internal/server"
19+)
20+
21+func main() {
22+ addr := flag.String("addr", envOr("GLEAN_ADDR", ":8080"), "listen address")
23+ dbPath := flag.String("db", envOr("GLEAN_DB", "glean.db"), "database path")
24+ relayURL := flag.String("relay", envOr("GLEAN_RELAY", "wss://bsky.network"), "AT Relay URL")
25+ flag.Parse()
26+
27+ logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo}))
28+
29+ database, err := db.Open(*dbPath)
30+ if err != nil {
31+ logger.Error("failed to open database", "error", err)
32+ os.Exit(1)
33+ }
34+ defer database.Close()
35+
36+ oauth := &atproto.OAuthConfig{
37+ ClientID: envOr("GLEAN_OAUTH_CLIENT_ID", ""),
38+ RedirectURL: envOr("GLEAN_OAUTH_REDIRECT_URL", ""),
39+ }
40+
41+ srv := server.New(database, oauth, logger)
42+
43+ storeAdapter := db.NewFeedStoreAdapter(database)
44+ scheduler := feed.NewScheduler(storeAdapter, logger)
45+
46+ engine := cluster.NewEngine(database.DB, logger)
47+ cron := cluster.NewCron(engine, 6*time.Hour, logger)
48+
49+ firehose := atproto.NewFirehoseConsumer(*relayURL, func(ctx context.Context, event *atproto.FirehoseEvent) error {
50+ logger.Debug("firehose event", "type", event.Type, "collection", event.Collection, "did", event.DID)
51+ return nil
52+ }, logger)
53+
54+ ctx, cancel := context.WithCancel(context.Background())
55+ defer cancel()
56+
57+ go func() {
58+ if err := scheduler.Run(ctx); err != nil && ctx.Err() == nil {
59+ logger.Error("scheduler error", "error", err)
60+ }
61+ }()
62+ go func() {
63+ if err := cron.Run(ctx); err != nil && ctx.Err() == nil {
64+ logger.Error("cron error", "error", err)
65+ }
66+ }()
67+ go func() {
68+ if err := firehose.Start(ctx); err != nil && ctx.Err() == nil {
69+ logger.Error("firehose error", "error", err)
70+ }
71+ }()
72+
73+ httpServer := &http.Server{
74+ Addr: *addr,
75+ Handler: srv,
76+ }
77+
78+ sigCh := make(chan os.Signal, 1)
79+ signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
80+
81+ go func() {
82+ logger.Info("starting server", "addr", *addr)
83+ if err := httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed {
84+ logger.Error("server error", "error", err)
85+ os.Exit(1)
86+ }
87+ }()
88+
89+ sig := <-sigCh
90+ logger.Info("shutting down", "signal", sig)
91+
92+ shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 10*time.Second)
93+ defer shutdownCancel()
94+
95+ if err := httpServer.Shutdown(shutdownCtx); err != nil {
96+ logger.Error("shutdown error", "error", err)
97+ }
98+
99+ cancel()
100+
101+ fmt.Println("glean stopped")
102+}
103+
104+func envOr(key, fallback string) string {
105+ if v := os.Getenv(key); v != "" {
106+ return v
107+ }
108+ return fallback
109+}
@@ -1,3 +1,109 @@
1 package main1 package main
2 2
3-func main() {}3+import (
4+ "context"
5+ "flag"
6+ "fmt"
7+ "log/slog"
8+ "net/http"
9+ "os"
10+ "os/signal"
11+ "syscall"
12+ "time"
13+
14+ "pkg.rbrt.fr/glean/internal/atproto"
15+ "pkg.rbrt.fr/glean/internal/cluster"
16+ "pkg.rbrt.fr/glean/internal/db"
17+ "pkg.rbrt.fr/glean/internal/feed"
18+ "pkg.rbrt.fr/glean/internal/server"
19+)
20+
21+func main() {
22+ addr := flag.String("addr", envOr("GLEAN_ADDR", ":8080"), "listen address")
23+ dbPath := flag.String("db", envOr("GLEAN_DB", "glean.db"), "database path")
24+ relayURL := flag.String("relay", envOr("GLEAN_RELAY", "wss://bsky.network"), "AT Relay URL")
25+ flag.Parse()
26+
27+ logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo}))
28+
29+ database, err := db.Open(*dbPath)
30+ if err != nil {
31+ logger.Error("failed to open database", "error", err)
32+ os.Exit(1)
33+ }
34+ defer database.Close()
35+
36+ oauth := &atproto.OAuthConfig{
37+ ClientID: envOr("GLEAN_OAUTH_CLIENT_ID", ""),
38+ RedirectURL: envOr("GLEAN_OAUTH_REDIRECT_URL", ""),
39+ }
40+
41+ srv := server.New(database, oauth, logger)
42+
43+ storeAdapter := db.NewFeedStoreAdapter(database)
44+ scheduler := feed.NewScheduler(storeAdapter, logger)
45+
46+ engine := cluster.NewEngine(database.DB, logger)
47+ cron := cluster.NewCron(engine, 6*time.Hour, logger)
48+
49+ firehose := atproto.NewFirehoseConsumer(*relayURL, func(ctx context.Context, event *atproto.FirehoseEvent) error {
50+ logger.Debug("firehose event", "type", event.Type, "collection", event.Collection, "did", event.DID)
51+ return nil
52+ }, logger)
53+
54+ ctx, cancel := context.WithCancel(context.Background())
55+ defer cancel()
56+
57+ go func() {
58+ if err := scheduler.Run(ctx); err != nil && ctx.Err() == nil {
59+ logger.Error("scheduler error", "error", err)
60+ }
61+ }()
62+ go func() {
63+ if err := cron.Run(ctx); err != nil && ctx.Err() == nil {
64+ logger.Error("cron error", "error", err)
65+ }
66+ }()
67+ go func() {
68+ if err := firehose.Start(ctx); err != nil && ctx.Err() == nil {
69+ logger.Error("firehose error", "error", err)
70+ }
71+ }()
72+
73+ httpServer := &http.Server{
74+ Addr: *addr,
75+ Handler: srv,
76+ }
77+
78+ sigCh := make(chan os.Signal, 1)
79+ signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
80+
81+ go func() {
82+ logger.Info("starting server", "addr", *addr)
83+ if err := httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed {
84+ logger.Error("server error", "error", err)
85+ os.Exit(1)
86+ }
87+ }()
88+
89+ sig := <-sigCh
90+ logger.Info("shutting down", "signal", sig)
91+
92+ shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 10*time.Second)
93+ defer shutdownCancel()
94+
95+ if err := httpServer.Shutdown(shutdownCtx); err != nil {
96+ logger.Error("shutdown error", "error", err)
97+ }
98+
99+ cancel()
100+
101+ fmt.Println("glean stopped")
102+}
103+
104+func envOr(key, fallback string) string {
105+ if v := os.Getenv(key); v != "" {
106+ return v
107+ }
108+ return fallback
109+}
added static/input.css +10 -0
new file mode 100644
@@ -0,0 +1,10 @@
1+@tailwind base;
2+@tailwind components;
3+@tailwind utilities;
4+
5+.line-clamp-2 {
6+ display: -webkit-box;
7+ -webkit-line-clamp: 2;
8+ -webkit-box-orient: vertical;
9+ overflow: hidden;
10+}
new file mode 100644
@@ -0,0 +1,10 @@
1+@tailwind base;
2+@tailwind components;
3+@tailwind utilities;
4+
5+.line-clamp-2 {
6+ display: -webkit-box;
7+ -webkit-line-clamp: 2;
8+ -webkit-box-orient: vertical;
9+ overflow: hidden;
10+}
added tailwind.config.js +47 -0
new file mode 100644
@@ -0,0 +1,47 @@
1+/** @type {import('tailwindcss').Config} */
2+module.exports = {
3+ content: ["./internal/tmpl/**/*.html"],
4+ theme: {
5+ extend: {
6+ colors: {
7+ spot: {
8+ purple: '#a855f7',
9+ 'purple-border': '#9333ea',
10+ bg: 'var(--spot-bg)',
11+ surface: 'var(--spot-surface)',
12+ hover: 'var(--spot-hover)',
13+ 'hover-50': 'var(--spot-hover-50)',
14+ text: 'var(--spot-text)',
15+ secondary: 'var(--spot-secondary)',
16+ body: 'var(--spot-body)',
17+ muted: 'var(--spot-muted)',
18+ divider: 'var(--spot-divider)',
19+ 'divider-30': 'var(--spot-divider-30)',
20+ outline: 'var(--spot-outline)',
21+ placeholder: 'var(--spot-placeholder)',
22+ 'active-pill-bg': 'var(--spot-active-bg)',
23+ 'active-pill-text': 'var(--spot-active-text)',
24+ red: '#f3727f',
25+ orange: '#ffa42b',
26+ blue: '#539df5',
27+ }
28+ },
29+ fontFamily: {
30+ ui: ['SpotifyMixUI', 'CircularSp-Arab', 'CircularSp-Hebr', 'CircularSp-Cyrl', 'CircularSp-Grek', 'CircularSp-Deva', 'Helvetica Neue', 'helvetica', 'arial', 'Hiragino Sans', 'Hiragino Kaku Gothic ProN', 'Meiryo', 'MS Gothic', 'sans-serif'],
31+ title: ['SpotifyMixUITitle', 'CircularSp-Arab', 'CircularSp-Hebr', 'CircularSp-Cyrl', 'CircularSp-Grek', 'CircularSp-Deva', 'Helvetica Neue', 'helvetica', 'arial', 'Hiragino Sans', 'Hiragino Kaku Gothic ProN', 'Meiryo', 'MS Gothic', 'sans-serif'],
32+ },
33+ borderRadius: {
34+ pill: '9999px',
35+ 'pill-lg': '500px',
36+ },
37+ boxShadow: {
38+ 'spot': 'var(--spot-shadow)',
39+ 'spot-heavy': 'var(--spot-shadow-heavy)',
40+ },
41+ letterSpacing: {
42+ button: '1.4px',
43+ },
44+ },
45+ },
46+ plugins: [],
47+}
new file mode 100644
@@ -0,0 +1,47 @@
1+/** @type {import('tailwindcss').Config} */
2+module.exports = {
3+ content: ["./internal/tmpl/**/*.html"],
4+ theme: {
5+ extend: {
6+ colors: {
7+ spot: {
8+ purple: '#a855f7',
9+ 'purple-border': '#9333ea',
10+ bg: 'var(--spot-bg)',
11+ surface: 'var(--spot-surface)',
12+ hover: 'var(--spot-hover)',
13+ 'hover-50': 'var(--spot-hover-50)',
14+ text: 'var(--spot-text)',
15+ secondary: 'var(--spot-secondary)',
16+ body: 'var(--spot-body)',
17+ muted: 'var(--spot-muted)',
18+ divider: 'var(--spot-divider)',
19+ 'divider-30': 'var(--spot-divider-30)',
20+ outline: 'var(--spot-outline)',
21+ placeholder: 'var(--spot-placeholder)',
22+ 'active-pill-bg': 'var(--spot-active-bg)',
23+ 'active-pill-text': 'var(--spot-active-text)',
24+ red: '#f3727f',
25+ orange: '#ffa42b',
26+ blue: '#539df5',
27+ }
28+ },
29+ fontFamily: {
30+ ui: ['SpotifyMixUI', 'CircularSp-Arab', 'CircularSp-Hebr', 'CircularSp-Cyrl', 'CircularSp-Grek', 'CircularSp-Deva', 'Helvetica Neue', 'helvetica', 'arial', 'Hiragino Sans', 'Hiragino Kaku Gothic ProN', 'Meiryo', 'MS Gothic', 'sans-serif'],
31+ title: ['SpotifyMixUITitle', 'CircularSp-Arab', 'CircularSp-Hebr', 'CircularSp-Cyrl', 'CircularSp-Grek', 'CircularSp-Deva', 'Helvetica Neue', 'helvetica', 'arial', 'Hiragino Sans', 'Hiragino Kaku Gothic ProN', 'Meiryo', 'MS Gothic', 'sans-serif'],
32+ },
33+ borderRadius: {
34+ pill: '9999px',
35+ 'pill-lg': '500px',
36+ },
37+ boxShadow: {
38+ 'spot': 'var(--spot-shadow)',
39+ 'spot-heavy': 'var(--spot-shadow-heavy)',
40+ },
41+ letterSpacing: {
42+ button: '1.4px',
43+ },
44+ },
45+ },
46+ plugins: [],
47+}