A social RSS reader built on the AT Protocol. glean.at
glean atproto atmosphere rss feed social app
14
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: glean mvp

+7099 -17
+7
.gitignore
··· 20 20 21 21 # env file 22 22 .env 23 + 24 + # database 25 + *.db 26 + *.db.journal 27 + 28 + # tailwind output 29 + static/output.css
+315
docs/design.md
··· 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
+839
docs/specs.md
··· 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
+27 -4
go.mod
··· 5 5 tool github.com/golangci/golangci-lint/cmd/golangci-lint 6 6 7 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 + 15 + require ( 8 16 4d63.com/gocheckcompilerdirectives v1.3.0 // indirect 9 17 4d63.com/gochecknoglobals v0.2.2 // indirect 10 18 github.com/4meepo/tagalign v1.4.2 // indirect ··· 43 51 github.com/daixiang0/gci v0.13.5 // indirect 44 52 github.com/davecgh/go-spew v1.1.1 // indirect 45 53 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 56 github.com/ettle/strcase v0.2.0 // indirect 47 57 github.com/fatih/color v1.18.0 // indirect 48 58 github.com/fatih/structtag v1.2.0 // indirect ··· 71 81 github.com/golangci/revgrep v0.8.0 // indirect 72 82 github.com/golangci/unconvert v0.0.0-20240309020433-c5143eacb3ed // indirect 73 83 github.com/google/go-cmp v0.7.0 // indirect 84 + github.com/google/uuid v1.6.0 // indirect 74 85 github.com/gordonklaus/ineffassign v0.1.0 // indirect 75 86 github.com/gostaticanalysis/analysisutil v0.7.1 // indirect 76 87 github.com/gostaticanalysis/comment v1.5.0 // indirect ··· 111 122 github.com/mitchellh/go-homedir v1.1.0 // indirect 112 123 github.com/mitchellh/mapstructure v1.5.0 // indirect 113 124 github.com/moricho/tparallel v0.3.2 // indirect 125 + github.com/mr-tron/base58 v1.2.0 // indirect 114 126 github.com/nakabonne/nestif v0.3.1 // indirect 127 + github.com/ncruces/go-strftime v1.0.0 // indirect 115 128 github.com/nishanths/exhaustive v0.12.0 // indirect 116 129 github.com/nishanths/predeclared v0.2.2 // indirect 117 130 github.com/nunnatsa/ginkgolinter v0.19.1 // indirect ··· 130 143 github.com/quasilyte/regex/syntax v0.0.0-20210819130434-b3f0c404a727 // indirect 131 144 github.com/quasilyte/stdinfo v0.0.0-20220114132959-f7386bf02567 // indirect 132 145 github.com/raeperd/recvcheck v0.2.0 // indirect 146 + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect 133 147 github.com/rivo/uniseg v0.4.7 // indirect 134 148 github.com/rogpeppe/go-internal v1.14.1 // indirect 135 149 github.com/ryancurrah/gomodguard v1.3.5 // indirect ··· 170 184 github.com/yeya24/promlinter v0.3.0 // indirect 171 185 github.com/ykadowak/zerologlint v0.1.5 // indirect 172 186 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 189 go-simpler.org/musttag v0.13.0 // indirect 174 190 go-simpler.org/sloglint v0.9.0 // indirect 175 191 go.uber.org/automaxprocs v1.6.0 // indirect 176 192 go.uber.org/multierr v1.11.0 // indirect 177 193 go.uber.org/zap v1.26.0 // indirect 194 + golang.org/x/crypto v0.35.0 // indirect 178 195 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 182 199 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 184 204 google.golang.org/protobuf v1.36.5 // indirect 185 205 gopkg.in/ini.v1 v1.67.0 // indirect 186 206 gopkg.in/yaml.v2 v2.4.0 // indirect 187 207 gopkg.in/yaml.v3 v3.0.1 // indirect 188 208 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 212 mvdan.cc/gofumpt v0.7.0 // indirect 190 213 mvdan.cc/unparam v0.0.0-20240528143540-8a5130ca722f // indirect 191 214 )
+72 -12
go.sum
··· 48 48 github.com/bkielbasa/cyclop v1.2.3/go.mod h1:kHTwA9Q0uZqOADdupvcFJQtp/ksSnytRMe8ztxG8Fuo= 49 49 github.com/blizzy78/varnamelen v0.8.0 h1:oqSblyuQvFsW1hbBHh1zfwrKe3kcSj0rnXkKzsQ089M= 50 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 53 github.com/bombsimon/wsl/v4 v4.5.0 h1:iZRsEvDdyhd2La0FVi5k6tYehpOR/R7qIUjmKk7N74A= 52 54 github.com/bombsimon/wsl/v4 v4.5.0/go.mod h1:NOQ3aLF4nD7N5YPXMruR6ZXDOAqLoM0GEpLwTdvmOSc= 53 55 github.com/breml/bidichk v0.3.2 h1:xV4flJ9V5xWTqxL+/PMFF6dtJPvZLPsyixAoPe8BGJs= ··· 82 84 github.com/denis-tingaikin/go-header v0.5.0/go.mod h1:mMenU5bWrok6Wl2UsZjy+1okegmwQ3UgWl4V1D8gjlY= 83 85 github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI= 84 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 91 github.com/ettle/strcase v0.2.0 h1:fGNiVF21fHXpX1niBgk0aROov1LagYsOwV/xqKDKR/Q= 86 92 github.com/ettle/strcase v0.2.0/go.mod h1:DajmHElDSaX76ITe3/VHVyMin4LWSJN5Z909Wp+ED1A= 87 93 github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= ··· 98 104 github.com/fzipp/gocyclo v0.6.0/go.mod h1:rXPyn8fnlpa0R2csP/31uerbiVBugk5whMdlyaLkLoA= 99 105 github.com/ghostiam/protogetter v0.3.9 h1:j+zlLLWzqLay22Cz/aYwTHKQ88GE2DQ6GkWSYFOI4lQ= 100 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 111 github.com/go-critic/go-critic v0.12.0 h1:iLosHZuye812wnkEz1Xu3aBwn5ocCPfc9yqmFG9pa6w= 102 112 github.com/go-critic/go-critic v0.12.0/go.mod h1:DpE0P6OVc6JzVYzmM5gq5jMU31zLr4am5mB/VfFK64w= 103 113 github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= ··· 156 166 github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 157 167 github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= 158 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= 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= 161 173 github.com/gordonklaus/ineffassign v0.1.0 h1:y2Gd/9I7MdY1oEIt+n+rowjBNDcLQq3RsH5hwJd0f9s= 162 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 177 github.com/gostaticanalysis/analysisutil v0.7.1 h1:ZMCjoue3DtDWQ5WyU16YbjbQEQ3VuzwxALrpYd+HeKk= 164 178 github.com/gostaticanalysis/analysisutil v0.7.1/go.mod h1:v21E3hY37WKMGSnbsw2S/ojApNWb6C1//mXO48CXbVc= 165 179 github.com/gostaticanalysis/comment v1.4.1/go.mod h1:ih6ZxzTHLdadaiSnF5WY3dxUoXfXAlTaRzuaNDlSado= ··· 253 267 github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= 254 268 github.com/moricho/tparallel v0.3.2 h1:odr8aZVFA3NZrNybggMkYO3rgPRcqjeQUlBBFVxKHTI= 255 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 272 github.com/nakabonne/nestif v0.3.1 h1:wm28nZjhQY5HyYPx+weN3Q65k6ilSBxDb8v5S81B81U= 257 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 276 github.com/nishanths/exhaustive v0.12.0 h1:vIY9sALmw6T/yxiASewa4TQcFsVYZQQRUQJhKRf3Swg= 259 277 github.com/nishanths/exhaustive v0.12.0/go.mod h1:mEZ95wPIZW+x8kC4TgC+9YCUgiST7ecevsVDTgc2obs= 260 278 github.com/nishanths/predeclared v0.2.2 h1:V2EPdZPliZymNAn79T8RkNApBjMmVKh5XRpLm/w98Vk= ··· 304 322 github.com/quasilyte/stdinfo v0.0.0-20220114132959-f7386bf02567/go.mod h1:DWNGW8A4Y+GyBgPuaQJuWiy0XYftx4Xm/y5Jqk9I6VQ= 305 323 github.com/raeperd/recvcheck v0.2.0 h1:GnU+NsbiCqdC2XX5+vMZzP+jAJC5fht7rcVTAhX74UI= 306 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 327 github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= 308 328 github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= 309 329 github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= ··· 410 430 github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= 411 431 gitlab.com/bosi/decorder v0.4.2 h1:qbQaV3zgwnBZ4zPMhGLW4KZe7A7NwxEhJx39R3shffo= 412 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 437 go-simpler.org/assert v0.9.0 h1:PfpmcSvL7yAnWyChSjOz6Sp6m9j5lyK8Ok9pEL31YkQ= 414 438 go-simpler.org/assert v0.9.0/go.mod h1:74Eqh5eI6vCK6Y5l3PI8ZYFXG4Sa+tkr70OIPJAUr28= 415 439 go-simpler.org/musttag v0.13.0 h1:Q/YAW0AHvaoaIbsPj3bvEI5/QFP7w696IMUpnKXQfCE= ··· 430 454 golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= 431 455 golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= 432 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 459 golang.org/x/exp v0.0.0-20240909161429-701f63a606c0 h1:e66Fs6Z+fZTbFBAxKfP3PALWBtpfqks2bwGcexMxgtk= 434 460 golang.org/x/exp v0.0.0-20240909161429-701f63a606c0/go.mod h1:2TbTHSBQa924w8M6Xs1QcRcFwyucIwBGpK1p2f1YFFY= 435 461 golang.org/x/exp/typeparams v0.0.0-20220428152302-39d4317da171/go.mod h1:AbB0pIl9nAr9wVwH+Z2ZpaocVmF5I4GyWCDIsVjR0bk= ··· 447 473 golang.org/x/mod v0.9.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= 448 474 golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= 449 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= 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= 452 478 golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 453 479 golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 454 480 golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= ··· 464 490 golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= 465 491 golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= 466 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= 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= 469 495 golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 470 496 golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 471 497 golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= ··· 475 501 golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 476 502 golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= 477 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= 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= 480 506 golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 481 507 golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 482 508 golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= ··· 499 525 golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 500 526 golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 501 527 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= 504 530 golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 505 531 golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= 506 532 golang.org/x/term v0.2.0/go.mod h1:TVmDHMZPmdnySmBfhjOoOdhjzdE1h4u1VwSiw2l1Nuc= ··· 521 547 golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= 522 548 golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM= 523 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 552 golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 525 553 golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 526 554 golang.org/x/tools v0.0.0-20200324003944-a576cf524670/go.mod h1:Sl4aGygMT6LrqrWclx+PTx3U+LnKx/seiNR+3G19Ar8= ··· 539 567 golang.org/x/tools v0.7.0/go.mod h1:4pg6aUX35JBAogB10C9AtvVL+qowtN4pT3CGSQex14s= 540 568 golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= 541 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= 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= 544 576 golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 545 577 golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 546 578 golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= ··· 560 592 gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 561 593 honnef.co/go/tools v0.6.1 h1:R094WgE8K4JirYjBaOpz/AvTyUu/3wbmAoskKN/pxTI= 562 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 623 mvdan.cc/gofumpt v0.7.0 h1:bg91ttqXmi9y2xawvkuMXyvAA/1ZGJqYAEGjXuP0JXU= 564 624 mvdan.cc/gofumpt v0.7.0/go.mod h1:txVFJy/Sc/mvaycET54pV8SW8gWxTlUuGHVEcncmNUo= 565 625 mvdan.cc/unparam v0.0.0-20240528143540-8a5130ca722f h1:lMpcwN6GxNbWtbpI1+xzFLSW8XzX0u72NttUGVFjO3U=
internal/.gitkeep

This is a binary file and will not be displayed.

+85
internal/atproto/auth.go
··· 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 + }
+153
internal/atproto/client.go
··· 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 + }
+201
internal/atproto/firehose.go
··· 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 + }
+126
internal/atproto/lexicon.go
··· 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 + }
+496
internal/atproto/xrpc.go
··· 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 + }
+43
internal/cluster/cron.go
··· 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 + }
+123
internal/cluster/jaccard.go
··· 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 + }
+111
internal/cluster/recommender.go
··· 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 + }
+266
internal/db/article.go
··· 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 + }
+306
internal/db/cluster.go
··· 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 + }
+164
internal/db/db.go
··· 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 + }
+210
internal/db/feed.go
··· 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 + }
+210
internal/db/social.go
··· 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 + }
+59
internal/db/store.go
··· 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 + }
+55
internal/db/user.go
··· 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 + }
+187
internal/feed/fetcher.go
··· 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 + }
+95
internal/feed/opml.go
··· 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 + }
+270
internal/feed/parser.go
··· 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 + }
+113
internal/sanitize/sanitize.go
··· 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 + }
+208
internal/sanitize/sanitize_test.go
··· 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 + }
+63
internal/server/annotations_handler.go
··· 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 + }
+250
internal/server/articles_handler.go
··· 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 + }
+41
internal/server/auth_handler.go
··· 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 + }
+28
internal/server/dashboard_handler.go
··· 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 + }
+34
internal/server/discover_handler.go
··· 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 + }
+238
internal/server/feeds_handler.go
··· 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 + }
+12
internal/server/index_handler.go
··· 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 + }
+83
internal/server/middleware.go
··· 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 + }
+29
internal/server/profile_handler.go
··· 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 + }
+258
internal/server/server.go
··· 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 + }
+147
internal/server/session.go
··· 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" }
+16
internal/server/trending_handler.go
··· 1 + package server 2 + 3 + import ( 4 + "net/http" 5 + "time" 6 + ) 7 + 8 + func (s *Server) handleTrending(w http.ResponseWriter, r *http.Request) { 9 + user := currentUser(r) 10 + since := time.Now().AddDate(0, 0, -7).Format(time.RFC3339) 11 + articles, _ := s.db.ListTrendingArticles(r.Context(), since, 25, 0) 12 + s.render(w, r, "trending.html", map[string]any{ 13 + "User": user, 14 + "Trending": articles, 15 + }) 16 + }
+33
internal/tmpl/annotations.html
··· 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}}
+102
internal/tmpl/article_detail.html
··· 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}}
+29
internal/tmpl/articles.html
··· 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}}
+341
internal/tmpl/base.html
··· 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}}
+99
internal/tmpl/dashboard.html
··· 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}}
+58
internal/tmpl/discover.html
··· 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}}
+103
internal/tmpl/feeds.html
··· 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}}
+33
internal/tmpl/index.html
··· 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}}
+17
internal/tmpl/login.html
··· 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}}
+22
internal/tmpl/partials/annotation-card.html
··· 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}}
+20
internal/tmpl/partials/article-card.html
··· 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}}
+21
internal/tmpl/partials/article-list.html
··· 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}}
+15
internal/tmpl/partials/feed-list.html
··· 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}}
+4
internal/tmpl/partials/like-button.html
··· 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}}
+9
internal/tmpl/partials/profile-card.html
··· 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}}
+11
internal/tmpl/partials/recommendation-card.html
··· 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}}
+47
internal/tmpl/profile.html
··· 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}}
+31
internal/tmpl/trending.html
··· 1 + {{define "trending.html"}} 2 + <div class="mb-6"> 3 + <h1 class="text-2xl font-bold font-title text-spot-text">Trending</h1> 4 + <p class="text-spot-secondary text-sm mt-1">Most liked and discussed articles across Glean.</p> 5 + </div> 6 + 7 + <div class="space-y-3"> 8 + {{range $i, $t := .Trending}} 9 + <article class="bg-spot-surface rounded-lg px-5 py-4 hover:bg-spot-hover-50 transition shadow-spot"> 10 + <div class="flex items-start justify-between gap-4"> 11 + <div class="min-w-0 flex-1"> 12 + <a href="/articles/{{$t.ArticleID}}" class="font-bold text-spot-text hover:text-spot-purple transition text-lg leading-tight">{{$t.Title}}</a> 13 + <div class="text-sm text-spot-secondary mt-1 flex items-center gap-2"> 14 + {{if $t.Author}}<span>{{$t.Author}}</span>{{end}} 15 + <span class="text-spot-muted">{{if $t.FeedTitle}}{{$t.FeedTitle}}{{else}}{{$t.FeedURL}}{{end}}</span> 16 + </div> 17 + {{if $t.Summary}} 18 + <p class="text-sm text-spot-secondary mt-2 line-clamp-2">{{$t.Summary}}</p> 19 + {{end}} 20 + </div> 21 + <div class="flex flex-col items-end gap-1 shrink-0 text-sm text-spot-secondary"> 22 + <span>&#9829; {{$t.LikeCount}}</span> 23 + <span class="text-spot-muted">{{$t.AnnotationCount}} notes</span> 24 + </div> 25 + </div> 26 + </article> 27 + {{else}} 28 + <div class="text-center text-spot-secondary py-12">No trending articles yet. Start liking and annotating!</div> 29 + {{end}} 30 + </div> 31 + {{end}}
+107 -1
main.go
··· 1 1 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 + }
+10
static/input.css
··· 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 + }
+47
tailwind.config.js
··· 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 + }