chore: init feeds rewrite
cca232d1
35 file(s) · +3439 −0
| 1 | + | ADMIN_PASSWORD=changeme |
|
| 2 | + | COOKIE_SECURE=false |
|
| 3 | + | BASE_URL=http://localhost:3000 |
|
| 4 | + | HOST=127.0.0.1 |
|
| 5 | + | PORT=3000 |
|
| 6 | + | FEEDS_DB_PATH=/data/feeds-go.sqlite |
|
| 7 | + | API_KEY= |
|
| 8 | + | DEFAULT_POLL_MINUTES=30 |
|
| 9 | + | ITEM_CAP_PER_FEED=200 |
| 1 | + | feeds-go.sqlite |
|
| 2 | + | .env |
| 1 | + | FROM golang:1.24-bookworm AS builder |
|
| 2 | + | WORKDIR /app |
|
| 3 | + | COPY apps/feeds-go/go.mod apps/feeds-go/go.sum ./ |
|
| 4 | + | RUN go mod download |
|
| 5 | + | COPY apps/feeds-go/ ./ |
|
| 6 | + | RUN CGO_ENABLED=0 go build -o /feeds-go . |
|
| 7 | + | ||
| 8 | + | FROM debian:bookworm-slim |
|
| 9 | + | COPY --from=builder /feeds-go /usr/local/bin/feeds-go |
|
| 10 | + | WORKDIR /data |
|
| 11 | + | ENV HOST=0.0.0.0 |
|
| 12 | + | ENV PORT=3000 |
|
| 13 | + | EXPOSE 3000 |
|
| 14 | + | CMD ["feeds-go"] |
| 1 | + | # Feeds Go |
|
| 2 | + | ||
| 3 | + | A Go rewrite of `apps/feeds` using mostly the Go standard library plus a SQLite driver and a feed parser. |
|
| 4 | + | ||
| 5 | + | ## Stack |
|
| 6 | + | ||
| 7 | + | - `net/http` |
|
| 8 | + | - `html/template` |
|
| 9 | + | - `database/sql` |
|
| 10 | + | - `embed` |
|
| 11 | + | - `modernc.org/sqlite` |
|
| 12 | + | - `github.com/mmcdole/gofeed` |
|
| 13 | + | ||
| 14 | + | ## Run |
|
| 15 | + | ||
| 16 | + | ```bash |
|
| 17 | + | cd apps/feeds-go |
|
| 18 | + | go run . |
|
| 19 | + | ``` |
|
| 20 | + | ||
| 21 | + | Copy `.env.example` to `.env` if you want local config. |
|
| 22 | + | ||
| 23 | + | ## What it includes |
|
| 24 | + | ||
| 25 | + | - public feed list |
|
| 26 | + | - preview mode via `?url=` / `?urls=` |
|
| 27 | + | - admin login with cookie sessions |
|
| 28 | + | - add/remove subscriptions and categories |
|
| 29 | + | - OPML import |
|
| 30 | + | - JSON API |
|
| 31 | + | - background polling with ETag / Last-Modified |
|
| 32 | + | - embedded templates and static assets |
| 1 | + | package main |
|
| 2 | + | ||
| 3 | + | import ( |
|
| 4 | + | "database/sql" |
|
| 5 | + | "embed" |
|
| 6 | + | "html/template" |
|
| 7 | + | "log/slog" |
|
| 8 | + | ) |
|
| 9 | + | ||
| 10 | + | //go:embed templates/*.html static/* assets/* assets/fonts/* |
|
| 11 | + | var appFS embed.FS |
|
| 12 | + | ||
| 13 | + | type App struct { |
|
| 14 | + | DB *sql.DB |
|
| 15 | + | Log *slog.Logger |
|
| 16 | + | Templates *template.Template |
|
| 17 | + | AdminPassword string |
|
| 18 | + | APIKey string |
|
| 19 | + | CookieSecure bool |
|
| 20 | + | BaseURL string |
|
| 21 | + | DefaultPollMinutes int |
|
| 22 | + | ItemCap int |
|
| 23 | + | } |
|
| 24 | + | ||
| 25 | + | type templateItem struct { |
|
| 26 | + | Title string |
|
| 27 | + | Link string |
|
| 28 | + | Author string |
|
| 29 | + | FormattedDate string |
|
| 30 | + | } |
|
| 31 | + | ||
| 32 | + | type adminSubRow struct { |
|
| 33 | + | ID int64 |
|
| 34 | + | Title string |
|
| 35 | + | FeedURL string |
|
| 36 | + | SiteURL string |
|
| 37 | + | CategoryName string |
|
| 38 | + | LastFetchedAt string |
|
| 39 | + | LastError string |
|
| 40 | + | } |
|
| 41 | + | ||
| 42 | + | type indexPageData struct { |
|
| 43 | + | BaseURL string |
|
| 44 | + | Items []templateItem |
|
| 45 | + | FeedURLs []string |
|
| 46 | + | Error string |
|
| 47 | + | } |
|
| 48 | + | ||
| 49 | + | type loginPageData struct { |
|
| 50 | + | Error string |
|
| 51 | + | } |
|
| 52 | + | ||
| 53 | + | type adminPageData struct { |
|
| 54 | + | Success string |
|
| 55 | + | Error string |
|
| 56 | + | Subscriptions []adminSubRow |
|
| 57 | + | Categories []Category |
|
| 58 | + | PollIntervalMinutes int |
|
| 59 | + | ItemCap int |
|
| 60 | + | APIKeyConfigured bool |
|
| 61 | + | } |
|
| 62 | + | ||
| 63 | + | type createSubscriptionBody struct { |
|
| 64 | + | FeedURL string `json:"feed_url"` |
|
| 65 | + | Title string `json:"title"` |
|
| 66 | + | CategoryID *int64 `json:"category_id"` |
|
| 67 | + | CategoryName string `json:"category_name"` |
|
| 68 | + | } |
|
| 69 | + | ||
| 70 | + | type updateSubscriptionBody struct { |
|
| 71 | + | CategoryID *int64 `json:"category_id"` |
|
| 72 | + | CategoryName string `json:"category_name"` |
|
| 73 | + | ClearCategory bool `json:"clear_category"` |
|
| 74 | + | } |
|
| 75 | + | ||
| 76 | + | type createCategoryBody struct { |
|
| 77 | + | Name string `json:"name"` |
|
| 78 | + | } |
|
| 79 | + | ||
| 80 | + | type updateSettingsBody struct { |
|
| 81 | + | PollIntervalMinutes *int `json:"poll_interval_minutes"` |
|
| 82 | + | } |
|
| 83 | + | ||
| 84 | + | type discoverBody struct { |
|
| 85 | + | BaseURL string `json:"base_url"` |
|
| 86 | + | } |
|
| 87 | + | ||
| 88 | + | type importSummary struct { |
|
| 89 | + | Imported int `json:"imported"` |
|
| 90 | + | Skipped int `json:"skipped"` |
|
| 91 | + | Failed []string `json:"failed"` |
|
| 92 | + | } |
|
| 93 | + | ||
| 94 | + | type subscriptionView struct { |
|
| 95 | + | ID int64 `json:"id"` |
|
| 96 | + | FeedURL string `json:"feed_url"` |
|
| 97 | + | Title string `json:"title"` |
|
| 98 | + | SiteURL *string `json:"site_url,omitempty"` |
|
| 99 | + | FaviconURL *string `json:"favicon_url,omitempty"` |
|
| 100 | + | CategoryID *int64 `json:"category_id,omitempty"` |
|
| 101 | + | ETag *string `json:"etag,omitempty"` |
|
| 102 | + | LastModified *string `json:"last_modified,omitempty"` |
|
| 103 | + | LastFetchedAt *string `json:"last_fetched_at,omitempty"` |
|
| 104 | + | LastError *string `json:"last_error,omitempty"` |
|
| 105 | + | AddedAt string `json:"added_at"` |
|
| 106 | + | } |
| 1 | + | /* Darkmatter — canonical CSS for Andromeda apps. |
|
| 2 | + | * Source of truth for reset, tokens, and shared components. |
|
| 3 | + | * Docs: /darkmatter |
|
| 4 | + | */ |
|
| 5 | + | ||
| 6 | + | @font-face { |
|
| 7 | + | font-family: "Commit Mono"; |
|
| 8 | + | src: url("/assets/fonts/CommitMono-400-Regular.otf") format("opentype"); |
|
| 9 | + | font-weight: 400; |
|
| 10 | + | font-style: normal; |
|
| 11 | + | font-display: swap; |
|
| 12 | + | } |
|
| 13 | + | ||
| 14 | + | @font-face { |
|
| 15 | + | font-family: "Commit Mono"; |
|
| 16 | + | src: url("/assets/fonts/CommitMono-700-Regular.otf") format("opentype"); |
|
| 17 | + | font-weight: 700; |
|
| 18 | + | font-style: normal; |
|
| 19 | + | font-display: swap; |
|
| 20 | + | } |
|
| 21 | + | ||
| 22 | + | /* ── Reset + webkit hardening ─────────────────────────────────────── */ |
|
| 23 | + | ||
| 24 | + | *, |
|
| 25 | + | *::before, |
|
| 26 | + | *::after { |
|
| 27 | + | padding: 0; |
|
| 28 | + | margin: 0; |
|
| 29 | + | box-sizing: border-box; |
|
| 30 | + | font-family: "Commit Mono", monospace, sans-serif; |
|
| 31 | + | -webkit-tap-highlight-color: transparent; |
|
| 32 | + | } |
|
| 33 | + | ||
| 34 | + | * { |
|
| 35 | + | scrollbar-width: none; |
|
| 36 | + | -ms-overflow-style: none; |
|
| 37 | + | } |
|
| 38 | + | ||
| 39 | + | html { |
|
| 40 | + | background: #121113; |
|
| 41 | + | color: #ffffff; |
|
| 42 | + | font-size: 14px; |
|
| 43 | + | line-height: 1.6; |
|
| 44 | + | -webkit-text-size-adjust: 100%; |
|
| 45 | + | text-size-adjust: 100%; |
|
| 46 | + | } |
|
| 47 | + | ||
| 48 | + | html::-webkit-scrollbar { |
|
| 49 | + | display: none; |
|
| 50 | + | } |
|
| 51 | + | ||
| 52 | + | body { |
|
| 53 | + | display: flex; |
|
| 54 | + | flex-direction: column; |
|
| 55 | + | justify-content: start; |
|
| 56 | + | align-items: start; |
|
| 57 | + | gap: 1.5rem; |
|
| 58 | + | min-height: 100vh; |
|
| 59 | + | max-width: 700px; |
|
| 60 | + | margin: auto; |
|
| 61 | + | padding: 0 1rem 4rem; |
|
| 62 | + | } |
|
| 63 | + | ||
| 64 | + | @media (max-width: 480px) { |
|
| 65 | + | body { |
|
| 66 | + | padding: 1rem; |
|
| 67 | + | gap: 1rem; |
|
| 68 | + | } |
|
| 69 | + | } |
|
| 70 | + | ||
| 71 | + | /* ── Links ────────────────────────────────────────────────────────── */ |
|
| 72 | + | ||
| 73 | + | a { |
|
| 74 | + | color: #ffffff; |
|
| 75 | + | text-decoration: none; |
|
| 76 | + | touch-action: manipulation; |
|
| 77 | + | } |
|
| 78 | + | ||
| 79 | + | a:hover { |
|
| 80 | + | opacity: 0.7; |
|
| 81 | + | } |
|
| 82 | + | ||
| 83 | + | /* ── Header / nav ─────────────────────────────────────────────────── */ |
|
| 84 | + | ||
| 85 | + | .header { |
|
| 86 | + | display: flex; |
|
| 87 | + | flex-direction: column; |
|
| 88 | + | gap: 0.5rem; |
|
| 89 | + | width: 100%; |
|
| 90 | + | margin-top: 2rem; |
|
| 91 | + | border-bottom: 1px solid #333; |
|
| 92 | + | padding-bottom: 1rem; |
|
| 93 | + | } |
|
| 94 | + | ||
| 95 | + | .logo { |
|
| 96 | + | font-size: 28px; |
|
| 97 | + | font-weight: 700; |
|
| 98 | + | text-decoration: none; |
|
| 99 | + | text-transform: uppercase; |
|
| 100 | + | } |
|
| 101 | + | ||
| 102 | + | .links { |
|
| 103 | + | display: flex; |
|
| 104 | + | align-items: center; |
|
| 105 | + | gap: 0.75rem; |
|
| 106 | + | font-size: 12px; |
|
| 107 | + | } |
|
| 108 | + | ||
| 109 | + | /* ── Main ─────────────────────────────────────────────────────────── */ |
|
| 110 | + | ||
| 111 | + | main { |
|
| 112 | + | width: 100%; |
|
| 113 | + | display: flex; |
|
| 114 | + | flex-direction: column; |
|
| 115 | + | gap: 1rem; |
|
| 116 | + | } |
|
| 117 | + | ||
| 118 | + | /* ── Forms ────────────────────────────────────────────────────────── */ |
|
| 119 | + | ||
| 120 | + | .form { |
|
| 121 | + | display: flex; |
|
| 122 | + | flex-direction: column; |
|
| 123 | + | gap: 0.5rem; |
|
| 124 | + | width: 100%; |
|
| 125 | + | } |
|
| 126 | + | ||
| 127 | + | .form-row { |
|
| 128 | + | display: flex; |
|
| 129 | + | gap: 0.5rem; |
|
| 130 | + | width: 100%; |
|
| 131 | + | } |
|
| 132 | + | ||
| 133 | + | .form-row .form-field { |
|
| 134 | + | flex: 1; |
|
| 135 | + | } |
|
| 136 | + | ||
| 137 | + | .form-field { |
|
| 138 | + | display: flex; |
|
| 139 | + | flex-direction: column; |
|
| 140 | + | gap: 0.25rem; |
|
| 141 | + | } |
|
| 142 | + | ||
| 143 | + | .form-actions { |
|
| 144 | + | display: flex; |
|
| 145 | + | gap: 0.5rem; |
|
| 146 | + | } |
|
| 147 | + | ||
| 148 | + | @media (max-width: 480px) { |
|
| 149 | + | .form-row { |
|
| 150 | + | flex-direction: column; |
|
| 151 | + | } |
|
| 152 | + | } |
|
| 153 | + | ||
| 154 | + | label { |
|
| 155 | + | font-size: 12px; |
|
| 156 | + | opacity: 0.7; |
|
| 157 | + | } |
|
| 158 | + | ||
| 159 | + | input, |
|
| 160 | + | textarea, |
|
| 161 | + | select { |
|
| 162 | + | background: #121113; |
|
| 163 | + | color: #ffffff; |
|
| 164 | + | border: 1px solid #ffffff; |
|
| 165 | + | padding: 0.4rem 0.75rem; |
|
| 166 | + | font-size: 16px; /* 16px prevents iOS focus zoom */ |
|
| 167 | + | width: 100%; |
|
| 168 | + | border-radius: 0; |
|
| 169 | + | -webkit-appearance: none; |
|
| 170 | + | appearance: none; |
|
| 171 | + | outline: none; |
|
| 172 | + | } |
|
| 173 | + | ||
| 174 | + | input:focus, |
|
| 175 | + | textarea:focus, |
|
| 176 | + | select:focus { |
|
| 177 | + | outline: none; |
|
| 178 | + | } |
|
| 179 | + | ||
| 180 | + | textarea { |
|
| 181 | + | min-height: 400px; |
|
| 182 | + | resize: vertical; |
|
| 183 | + | } |
|
| 184 | + | ||
| 185 | + | select { |
|
| 186 | + | background-image: none; |
|
| 187 | + | padding-right: 0.75rem; |
|
| 188 | + | } |
|
| 189 | + | ||
| 190 | + | input[type="file"] { |
|
| 191 | + | cursor: pointer; |
|
| 192 | + | font-size: 14px; |
|
| 193 | + | } |
|
| 194 | + | ||
| 195 | + | input[type="file"]::-webkit-file-upload-button, |
|
| 196 | + | input[type="file"]::file-selector-button { |
|
| 197 | + | background: #121113; |
|
| 198 | + | color: #ffffff; |
|
| 199 | + | border: 1px solid #555; |
|
| 200 | + | padding: 0.25rem 0.5rem; |
|
| 201 | + | cursor: pointer; |
|
| 202 | + | font-family: "Commit Mono", monospace; |
|
| 203 | + | font-size: 12px; |
|
| 204 | + | margin-right: 0.5rem; |
|
| 205 | + | border-radius: 0; |
|
| 206 | + | } |
|
| 207 | + | ||
| 208 | + | input[type="search"]::-webkit-search-decoration, |
|
| 209 | + | input[type="search"]::-webkit-search-cancel-button, |
|
| 210 | + | input[type="search"]::-webkit-search-results-button, |
|
| 211 | + | input[type="search"]::-webkit-search-results-decoration { |
|
| 212 | + | -webkit-appearance: none; |
|
| 213 | + | } |
|
| 214 | + | ||
| 215 | + | input[type="checkbox"], |
|
| 216 | + | input[type="radio"] { |
|
| 217 | + | -webkit-appearance: none; |
|
| 218 | + | appearance: none; |
|
| 219 | + | width: 16px; |
|
| 220 | + | height: 16px; |
|
| 221 | + | background: transparent; |
|
| 222 | + | border: 1px solid #ffffff; |
|
| 223 | + | border-radius: 0; |
|
| 224 | + | padding: 0; |
|
| 225 | + | cursor: pointer; |
|
| 226 | + | position: relative; |
|
| 227 | + | flex-shrink: 0; |
|
| 228 | + | touch-action: manipulation; |
|
| 229 | + | } |
|
| 230 | + | ||
| 231 | + | input[type="radio"] { |
|
| 232 | + | border-radius: 50%; |
|
| 233 | + | } |
|
| 234 | + | ||
| 235 | + | input[type="checkbox"]:checked::after { |
|
| 236 | + | content: '✔︎'; |
|
| 237 | + | position: absolute; |
|
| 238 | + | top: 50%; |
|
| 239 | + | left: 50%; |
|
| 240 | + | transform: translate(-50%, -50%); |
|
| 241 | + | font-size: 12px; |
|
| 242 | + | color: #ffffff; |
|
| 243 | + | line-height: 1; |
|
| 244 | + | } |
|
| 245 | + | ||
| 246 | + | input[type="radio"]:checked::after { |
|
| 247 | + | content: ''; |
|
| 248 | + | position: absolute; |
|
| 249 | + | top: 50%; |
|
| 250 | + | left: 50%; |
|
| 251 | + | width: 8px; |
|
| 252 | + | height: 8px; |
|
| 253 | + | border-radius: 50%; |
|
| 254 | + | background: #ffffff; |
|
| 255 | + | transform: translate(-50%, -50%); |
|
| 256 | + | } |
|
| 257 | + | ||
| 258 | + | .checkbox-field { |
|
| 259 | + | justify-content: flex-end; |
|
| 260 | + | } |
|
| 261 | + | ||
| 262 | + | .checkbox-field label { |
|
| 263 | + | display: flex; |
|
| 264 | + | align-items: center; |
|
| 265 | + | gap: 0.5rem; |
|
| 266 | + | font-size: 14px; |
|
| 267 | + | opacity: 1; |
|
| 268 | + | cursor: pointer; |
|
| 269 | + | } |
|
| 270 | + | ||
| 271 | + | /* Switch */ |
|
| 272 | + | ||
| 273 | + | .switch-row { |
|
| 274 | + | display: flex; |
|
| 275 | + | align-items: center; |
|
| 276 | + | gap: 0.5rem; |
|
| 277 | + | } |
|
| 278 | + | ||
| 279 | + | .switch-label { |
|
| 280 | + | font-size: 14px; |
|
| 281 | + | } |
|
| 282 | + | ||
| 283 | + | .switch { |
|
| 284 | + | position: relative; |
|
| 285 | + | display: inline-block; |
|
| 286 | + | width: 36px; |
|
| 287 | + | height: 20px; |
|
| 288 | + | flex-shrink: 0; |
|
| 289 | + | } |
|
| 290 | + | ||
| 291 | + | .switch input { |
|
| 292 | + | opacity: 0; |
|
| 293 | + | width: 0; |
|
| 294 | + | height: 0; |
|
| 295 | + | } |
|
| 296 | + | ||
| 297 | + | .switch-slider { |
|
| 298 | + | position: absolute; |
|
| 299 | + | cursor: pointer; |
|
| 300 | + | top: 0; |
|
| 301 | + | left: 0; |
|
| 302 | + | right: 0; |
|
| 303 | + | bottom: 0; |
|
| 304 | + | background: #333; |
|
| 305 | + | border-radius: 20px; |
|
| 306 | + | transition: background 0.2s; |
|
| 307 | + | } |
|
| 308 | + | ||
| 309 | + | .switch-slider::before { |
|
| 310 | + | content: ""; |
|
| 311 | + | position: absolute; |
|
| 312 | + | height: 14px; |
|
| 313 | + | width: 14px; |
|
| 314 | + | left: 3px; |
|
| 315 | + | bottom: 3px; |
|
| 316 | + | background: #888; |
|
| 317 | + | border-radius: 50%; |
|
| 318 | + | transition: transform 0.2s, background 0.2s; |
|
| 319 | + | } |
|
| 320 | + | ||
| 321 | + | .switch input:checked + .switch-slider { |
|
| 322 | + | background: #555; |
|
| 323 | + | } |
|
| 324 | + | ||
| 325 | + | .switch input:checked + .switch-slider::before { |
|
| 326 | + | transform: translateX(16px); |
|
| 327 | + | background: #ffffff; |
|
| 328 | + | } |
|
| 329 | + | ||
| 330 | + | /* ── Buttons ──────────────────────────────────────────────────────── */ |
|
| 331 | + | ||
| 332 | + | button, |
|
| 333 | + | .btn { |
|
| 334 | + | background: #121113; |
|
| 335 | + | color: #ffffff; |
|
| 336 | + | padding: 0.2rem 0.75rem; |
|
| 337 | + | border: 1px solid #ffffff; |
|
| 338 | + | cursor: pointer; |
|
| 339 | + | width: fit-content; |
|
| 340 | + | font-size: 14px; |
|
| 341 | + | line-height: 1.4; |
|
| 342 | + | border-radius: 0; |
|
| 343 | + | -webkit-appearance: none; |
|
| 344 | + | appearance: none; |
|
| 345 | + | text-decoration: none; |
|
| 346 | + | display: inline-block; |
|
| 347 | + | touch-action: manipulation; |
|
| 348 | + | } |
|
| 349 | + | ||
| 350 | + | button:hover, |
|
| 351 | + | .btn:hover { |
|
| 352 | + | opacity: 0.7; |
|
| 353 | + | } |
|
| 354 | + | ||
| 355 | + | button.loading { |
|
| 356 | + | cursor: wait; |
|
| 357 | + | } |
|
| 358 | + | ||
| 359 | + | .link-button { |
|
| 360 | + | background: none; |
|
| 361 | + | border: none; |
|
| 362 | + | color: #ffffff; |
|
| 363 | + | cursor: pointer; |
|
| 364 | + | font-size: 12px; |
|
| 365 | + | padding: 0; |
|
| 366 | + | font-family: inherit; |
|
| 367 | + | -webkit-appearance: none; |
|
| 368 | + | appearance: none; |
|
| 369 | + | } |
|
| 370 | + | ||
| 371 | + | .link-button:hover { |
|
| 372 | + | opacity: 0.7; |
|
| 373 | + | } |
|
| 374 | + | ||
| 375 | + | .link-button.danger { |
|
| 376 | + | opacity: 0.5; |
|
| 377 | + | } |
|
| 378 | + | ||
| 379 | + | .link-button.danger:hover { |
|
| 380 | + | opacity: 0.3; |
|
| 381 | + | } |
|
| 382 | + | ||
| 383 | + | .inline-form { |
|
| 384 | + | display: inline; |
|
| 385 | + | margin: 0; |
|
| 386 | + | padding: 0; |
|
| 387 | + | } |
|
| 388 | + | ||
| 389 | + | /* ── Feedback ─────────────────────────────────────────────────────── */ |
|
| 390 | + | ||
| 391 | + | .error { |
|
| 392 | + | color: #ffffff; |
|
| 393 | + | border-left: 2px solid #ffffff; |
|
| 394 | + | padding-left: 0.5rem; |
|
| 395 | + | font-size: 13px; |
|
| 396 | + | opacity: 0.8; |
|
| 397 | + | } |
|
| 398 | + | ||
| 399 | + | .success { |
|
| 400 | + | color: #ffffff; |
|
| 401 | + | border-left: 2px solid #555; |
|
| 402 | + | padding-left: 0.5rem; |
|
| 403 | + | font-size: 13px; |
|
| 404 | + | opacity: 0.7; |
|
| 405 | + | } |
|
| 406 | + | ||
| 407 | + | .empty { |
|
| 408 | + | opacity: 0.5; |
|
| 409 | + | font-size: 12px; |
|
| 410 | + | } |
|
| 411 | + | ||
| 412 | + | /* ── Item list (generic stacked list pattern) ────────────────────── */ |
|
| 413 | + | ||
| 414 | + | .item-list { |
|
| 415 | + | display: flex; |
|
| 416 | + | flex-direction: column; |
|
| 417 | + | width: 100%; |
|
| 418 | + | } |
|
| 419 | + | ||
| 420 | + | .item { |
|
| 421 | + | display: flex; |
|
| 422 | + | flex-direction: column; |
|
| 423 | + | gap: 0.25rem; |
|
| 424 | + | padding: 0.75rem 0; |
|
| 425 | + | border-bottom: 1px solid #333; |
|
| 426 | + | min-width: 0; |
|
| 427 | + | } |
|
| 428 | + | ||
| 429 | + | .item:hover { |
|
| 430 | + | opacity: 0.7; |
|
| 431 | + | } |
|
| 432 | + | ||
| 433 | + | .item-title { |
|
| 434 | + | display: grid; |
|
| 435 | + | grid-template-columns: auto minmax(0, 1fr); |
|
| 436 | + | align-items: center; |
|
| 437 | + | gap: 0.4rem; |
|
| 438 | + | min-width: 0; |
|
| 439 | + | max-width: 100%; |
|
| 440 | + | font-size: 16px; |
|
| 441 | + | overflow-wrap: anywhere; |
|
| 442 | + | } |
|
| 443 | + | ||
| 444 | + | .item-meta { |
|
| 445 | + | max-width: 100%; |
|
| 446 | + | font-size: 12px; |
|
| 447 | + | opacity: 0.5; |
|
| 448 | + | overflow-wrap: anywhere; |
|
| 449 | + | word-break: break-word; |
|
| 450 | + | } |
|
| 451 | + | ||
| 452 | + | .favicon { |
|
| 453 | + | flex-shrink: 0; |
|
| 454 | + | } |
|
| 455 | + | ||
| 456 | + | /* ── Admin list (horizontal row w/ actions) ──────────────────────── */ |
|
| 457 | + | ||
| 458 | + | .admin-list { |
|
| 459 | + | display: flex; |
|
| 460 | + | flex-direction: column; |
|
| 461 | + | width: 100%; |
|
| 462 | + | } |
|
| 463 | + | ||
| 464 | + | .admin-list-item { |
|
| 465 | + | display: flex; |
|
| 466 | + | justify-content: space-between; |
|
| 467 | + | align-items: center; |
|
| 468 | + | padding: 8px 0; |
|
| 469 | + | border-bottom: 1px solid #333; |
|
| 470 | + | gap: 1rem; |
|
| 471 | + | min-width: 0; |
|
| 472 | + | } |
|
| 473 | + | ||
| 474 | + | .admin-list-info { |
|
| 475 | + | display: flex; |
|
| 476 | + | flex: 1; |
|
| 477 | + | flex-direction: column; |
|
| 478 | + | gap: 0.2rem; |
|
| 479 | + | min-width: 0; |
|
| 480 | + | } |
|
| 481 | + | ||
| 482 | + | .admin-list-title { |
|
| 483 | + | display: grid; |
|
| 484 | + | grid-template-columns: auto minmax(0, 1fr); |
|
| 485 | + | align-items: center; |
|
| 486 | + | gap: 0.4rem; |
|
| 487 | + | min-width: 0; |
|
| 488 | + | max-width: 100%; |
|
| 489 | + | font-size: 15px; |
|
| 490 | + | white-space: normal; |
|
| 491 | + | overflow: visible; |
|
| 492 | + | text-overflow: clip; |
|
| 493 | + | overflow-wrap: anywhere; |
|
| 494 | + | } |
|
| 495 | + | ||
| 496 | + | .admin-list-meta { |
|
| 497 | + | display: flex; |
|
| 498 | + | gap: 0.75rem; |
|
| 499 | + | align-items: center; |
|
| 500 | + | } |
|
| 501 | + | ||
| 502 | + | .admin-list-date { |
|
| 503 | + | font-size: 11px; |
|
| 504 | + | opacity: 0.4; |
|
| 505 | + | } |
|
| 506 | + | ||
| 507 | + | .admin-list-actions { |
|
| 508 | + | display: flex; |
|
| 509 | + | gap: 1rem; |
|
| 510 | + | font-size: 12px; |
|
| 511 | + | flex-shrink: 0; |
|
| 512 | + | flex-wrap: wrap; |
|
| 513 | + | } |
|
| 514 | + | ||
| 515 | + | .admin-toolbar { |
|
| 516 | + | display: flex; |
|
| 517 | + | justify-content: space-between; |
|
| 518 | + | align-items: center; |
|
| 519 | + | width: 100%; |
|
| 520 | + | } |
|
| 521 | + | ||
| 522 | + | .admin-toolbar h2 { |
|
| 523 | + | font-size: 18px; |
|
| 524 | + | font-weight: 700; |
|
| 525 | + | } |
|
| 526 | + | ||
| 527 | + | @media (max-width: 480px) { |
|
| 528 | + | .admin-list-item { |
|
| 529 | + | flex-direction: column; |
|
| 530 | + | align-items: flex-start; |
|
| 531 | + | gap: 0.5rem; |
|
| 532 | + | } |
|
| 533 | + | } |
|
| 534 | + | ||
| 535 | + | /* ── Tags / badges ───────────────────────────────────────────────── */ |
|
| 536 | + | ||
| 537 | + | .tag { |
|
| 538 | + | font-size: 11px; |
|
| 539 | + | opacity: 0.5; |
|
| 540 | + | background: #1e1c1f; |
|
| 541 | + | padding: 1px 6px; |
|
| 542 | + | border: 1px solid #333; |
|
| 543 | + | } |
|
| 544 | + | ||
| 545 | + | .status-badge { |
|
| 546 | + | font-size: 11px; |
|
| 547 | + | padding: 1px 6px; |
|
| 548 | + | border: 1px solid #333; |
|
| 549 | + | } |
|
| 550 | + | ||
| 551 | + | .status-published { |
|
| 552 | + | opacity: 1; |
|
| 553 | + | border-color: #555; |
|
| 554 | + | } |
|
| 555 | + | ||
| 556 | + | .status-draft { |
|
| 557 | + | opacity: 0.4; |
|
| 558 | + | } |
|
| 559 | + | ||
| 560 | + | /* ── Tables ──────────────────────────────────────────────────────── */ |
|
| 561 | + | ||
| 562 | + | table { |
|
| 563 | + | width: 100%; |
|
| 564 | + | border-collapse: collapse; |
|
| 565 | + | } |
|
| 566 | + | ||
| 567 | + | th { |
|
| 568 | + | opacity: 0.5; |
|
| 569 | + | font-weight: 400; |
|
| 570 | + | font-size: 12px; |
|
| 571 | + | text-transform: uppercase; |
|
| 572 | + | text-align: left; |
|
| 573 | + | padding: 6px; |
|
| 574 | + | border-bottom: 1px solid #333; |
|
| 575 | + | } |
|
| 576 | + | ||
| 577 | + | td { |
|
| 578 | + | padding: 6px; |
|
| 579 | + | border-bottom: 1px solid #333; |
|
| 580 | + | } |
|
| 581 | + | ||
| 582 | + | /* ── Spinner (braille) ────────────────────────────────────────────── */ |
|
| 583 | + | ||
| 584 | + | .spinner { |
|
| 585 | + | margin-left: 0.6rem; |
|
| 586 | + | } |
|
| 587 | + | ||
| 588 | + | .spinner::after { |
|
| 589 | + | content: "⠋"; |
|
| 590 | + | display: inline-block; |
|
| 591 | + | animation: braille-spin 0.8s steps(10) infinite; |
|
| 592 | + | } |
|
| 593 | + | ||
| 594 | + | @keyframes braille-spin { |
|
| 595 | + | 0% { content: "⠋"; } |
|
| 596 | + | 10% { content: "⠙"; } |
|
| 597 | + | 20% { content: "⠹"; } |
|
| 598 | + | 30% { content: "⠸"; } |
|
| 599 | + | 40% { content: "⠼"; } |
|
| 600 | + | 50% { content: "⠴"; } |
|
| 601 | + | 60% { content: "⠦"; } |
|
| 602 | + | 70% { content: "⠧"; } |
|
| 603 | + | 80% { content: "⠇"; } |
|
| 604 | + | 90% { content: "⠏"; } |
|
| 605 | + | } |
|
| 606 | + | ||
| 607 | + | /* ── Inline code ─────────────────────────────────────────────────── */ |
|
| 608 | + | ||
| 609 | + | code { |
|
| 610 | + | background: #1e1c1f; |
|
| 611 | + | padding: 2px 4px; |
|
| 612 | + | font-size: 13px; |
|
| 613 | + | } |
|
| 614 | + | ||
| 615 | + | pre { |
|
| 616 | + | background: #1e1c1f; |
|
| 617 | + | padding: 12px; |
|
| 618 | + | overflow-x: auto; |
|
| 619 | + | border: 1px solid #333; |
|
| 620 | + | -webkit-overflow-scrolling: touch; |
|
| 621 | + | } |
|
| 622 | + | ||
| 623 | + | pre code { |
|
| 624 | + | background: none; |
|
| 625 | + | padding: 0; |
|
| 626 | + | } |
|
| 627 | + | ||
| 628 | + | /* ── Footer ──────────────────────────────────────────────────────── */ |
|
| 629 | + | ||
| 630 | + | .footer { |
|
| 631 | + | width: 100%; |
|
| 632 | + | border-top: 1px solid #333; |
|
| 633 | + | padding-top: 1rem; |
|
| 634 | + | margin-top: auto; |
|
| 635 | + | display: flex; |
|
| 636 | + | justify-content: center; |
|
| 637 | + | } |
|
| 638 | + | ||
| 639 | + | /* ── Utility ─────────────────────────────────────────────────────── */ |
|
| 640 | + | ||
| 641 | + | .hidden { |
|
| 642 | + | display: none; |
|
| 643 | + | } |
|
| 644 | + | ||
| 645 | + | .scroll-x { |
|
| 646 | + | overflow-x: auto; |
|
| 647 | + | -webkit-overflow-scrolling: touch; |
|
| 648 | + | } |
Binary file — no preview.
Binary file — no preview.
| 1 | + | package main |
|
| 2 | + | ||
| 3 | + | import ( |
|
| 4 | + | "database/sql" |
|
| 5 | + | "errors" |
|
| 6 | + | "fmt" |
|
| 7 | + | "strings" |
|
| 8 | + | "time" |
|
| 9 | + | ||
| 10 | + | _ "modernc.org/sqlite" |
|
| 11 | + | ) |
|
| 12 | + | ||
| 13 | + | const subscriptionSelectColumns = `id, feed_url, title, site_url, favicon_url, category_id, etag, last_modified, last_fetched_at, last_error, added_at` |
|
| 14 | + | ||
| 15 | + | const feedsSchema = ` |
|
| 16 | + | CREATE TABLE IF NOT EXISTS sessions ( |
|
| 17 | + | id INTEGER PRIMARY KEY AUTOINCREMENT, |
|
| 18 | + | token TEXT NOT NULL UNIQUE, |
|
| 19 | + | expires_at TEXT NOT NULL |
|
| 20 | + | ); |
|
| 21 | + | ||
| 22 | + | CREATE TABLE IF NOT EXISTS categories ( |
|
| 23 | + | id INTEGER PRIMARY KEY AUTOINCREMENT, |
|
| 24 | + | name TEXT NOT NULL UNIQUE, |
|
| 25 | + | created_at TEXT NOT NULL DEFAULT (datetime('now')) |
|
| 26 | + | ); |
|
| 27 | + | ||
| 28 | + | CREATE TABLE IF NOT EXISTS subscriptions ( |
|
| 29 | + | id INTEGER PRIMARY KEY AUTOINCREMENT, |
|
| 30 | + | feed_url TEXT NOT NULL UNIQUE, |
|
| 31 | + | title TEXT NOT NULL, |
|
| 32 | + | site_url TEXT, |
|
| 33 | + | favicon_url TEXT, |
|
| 34 | + | category_id INTEGER REFERENCES categories(id) ON DELETE SET NULL, |
|
| 35 | + | etag TEXT, |
|
| 36 | + | last_modified TEXT, |
|
| 37 | + | last_fetched_at TEXT, |
|
| 38 | + | last_error TEXT, |
|
| 39 | + | added_at TEXT NOT NULL DEFAULT (datetime('now')) |
|
| 40 | + | ); |
|
| 41 | + | CREATE INDEX IF NOT EXISTS idx_subs_category ON subscriptions(category_id); |
|
| 42 | + | ||
| 43 | + | CREATE TABLE IF NOT EXISTS items ( |
|
| 44 | + | id INTEGER PRIMARY KEY AUTOINCREMENT, |
|
| 45 | + | subscription_id INTEGER NOT NULL REFERENCES subscriptions(id) ON DELETE CASCADE, |
|
| 46 | + | guid TEXT NOT NULL, |
|
| 47 | + | title TEXT NOT NULL, |
|
| 48 | + | link TEXT NOT NULL, |
|
| 49 | + | author TEXT, |
|
| 50 | + | published_at INTEGER NOT NULL, |
|
| 51 | + | is_read INTEGER NOT NULL DEFAULT 0, |
|
| 52 | + | fetched_at TEXT NOT NULL DEFAULT (datetime('now')), |
|
| 53 | + | UNIQUE(subscription_id, guid) |
|
| 54 | + | ); |
|
| 55 | + | CREATE INDEX IF NOT EXISTS idx_items_sub_pub ON items(subscription_id, published_at DESC); |
|
| 56 | + | CREATE INDEX IF NOT EXISTS idx_items_pub ON items(published_at DESC); |
|
| 57 | + | CREATE INDEX IF NOT EXISTS idx_items_unread ON items(is_read, published_at DESC); |
|
| 58 | + | ||
| 59 | + | CREATE TABLE IF NOT EXISTS settings ( |
|
| 60 | + | key TEXT PRIMARY KEY, |
|
| 61 | + | value TEXT NOT NULL |
|
| 62 | + | ); |
|
| 63 | + | ` |
|
| 64 | + | ||
| 65 | + | type Category struct { |
|
| 66 | + | ID int64 |
|
| 67 | + | Name string |
|
| 68 | + | CreatedAt string |
|
| 69 | + | } |
|
| 70 | + | ||
| 71 | + | type Subscription struct { |
|
| 72 | + | ID int64 |
|
| 73 | + | FeedURL string |
|
| 74 | + | Title string |
|
| 75 | + | SiteURL sql.NullString |
|
| 76 | + | FaviconURL sql.NullString |
|
| 77 | + | CategoryID sql.NullInt64 |
|
| 78 | + | ETag sql.NullString |
|
| 79 | + | LastModified sql.NullString |
|
| 80 | + | LastFetchedAt sql.NullString |
|
| 81 | + | LastError sql.NullString |
|
| 82 | + | AddedAt string |
|
| 83 | + | } |
|
| 84 | + | ||
| 85 | + | type ItemWithFeed struct { |
|
| 86 | + | ID int64 `json:"id"` |
|
| 87 | + | SubscriptionID int64 `json:"subscription_id"` |
|
| 88 | + | GUID string `json:"guid"` |
|
| 89 | + | Title string `json:"title"` |
|
| 90 | + | Link string `json:"link"` |
|
| 91 | + | Author *string `json:"author,omitempty"` |
|
| 92 | + | PublishedAt int64 `json:"published_at"` |
|
| 93 | + | IsRead bool `json:"is_read"` |
|
| 94 | + | FetchedAt string `json:"fetched_at"` |
|
| 95 | + | FeedTitle string `json:"feed_title"` |
|
| 96 | + | FeedURL string `json:"feed_url"` |
|
| 97 | + | CategoryID *int64 `json:"category_id,omitempty"` |
|
| 98 | + | CategoryName *string `json:"category_name,omitempty"` |
|
| 99 | + | } |
|
| 100 | + | ||
| 101 | + | type ListItemsFilter struct { |
|
| 102 | + | Limit int |
|
| 103 | + | UnreadOnly bool |
|
| 104 | + | CategoryID *int64 |
|
| 105 | + | SubscriptionID *int64 |
|
| 106 | + | } |
|
| 107 | + | ||
| 108 | + | type NewItem struct { |
|
| 109 | + | SubscriptionID int64 |
|
| 110 | + | GUID string |
|
| 111 | + | Title string |
|
| 112 | + | Link string |
|
| 113 | + | Author string |
|
| 114 | + | PublishedAt int64 |
|
| 115 | + | } |
|
| 116 | + | ||
| 117 | + | func openDB(path string) (*sql.DB, error) { |
|
| 118 | + | db, err := sql.Open("sqlite", path) |
|
| 119 | + | if err != nil { |
|
| 120 | + | return nil, err |
|
| 121 | + | } |
|
| 122 | + | db.SetMaxOpenConns(1) |
|
| 123 | + | db.SetMaxIdleConns(1) |
|
| 124 | + | if _, err := db.Exec("PRAGMA foreign_keys = ON"); err != nil { |
|
| 125 | + | return nil, err |
|
| 126 | + | } |
|
| 127 | + | if _, err := db.Exec(feedsSchema); err != nil { |
|
| 128 | + | return nil, err |
|
| 129 | + | } |
|
| 130 | + | return db, nil |
|
| 131 | + | } |
|
| 132 | + | ||
| 133 | + | func seedSettings(db *sql.DB, defaultPoll int) error { |
|
| 134 | + | _, err := db.Exec(`INSERT INTO settings (key, value) VALUES ('poll_interval_minutes', ?) |
|
| 135 | + | ON CONFLICT(key) DO NOTHING`, fmt.Sprintf("%d", defaultPoll)) |
|
| 136 | + | return err |
|
| 137 | + | } |
|
| 138 | + | ||
| 139 | + | func listItems(db *sql.DB, filter ListItemsFilter) ([]ItemWithFeed, error) { |
|
| 140 | + | limit := filter.Limit |
|
| 141 | + | if limit <= 0 { |
|
| 142 | + | limit = 100 |
|
| 143 | + | } |
|
| 144 | + | if limit > 1000 { |
|
| 145 | + | limit = 1000 |
|
| 146 | + | } |
|
| 147 | + | var b strings.Builder |
|
| 148 | + | b.WriteString(`SELECT i.id, i.subscription_id, i.guid, i.title, i.link, i.author, i.published_at, |
|
| 149 | + | i.is_read, i.fetched_at, s.title, s.feed_url, s.category_id, c.name |
|
| 150 | + | FROM items i |
|
| 151 | + | JOIN subscriptions s ON s.id = i.subscription_id |
|
| 152 | + | LEFT JOIN categories c ON c.id = s.category_id |
|
| 153 | + | WHERE 1=1`) |
|
| 154 | + | args := []any{} |
|
| 155 | + | if filter.UnreadOnly { |
|
| 156 | + | b.WriteString(" AND i.is_read = 0") |
|
| 157 | + | } |
|
| 158 | + | if filter.CategoryID != nil { |
|
| 159 | + | b.WriteString(" AND s.category_id = ?") |
|
| 160 | + | args = append(args, *filter.CategoryID) |
|
| 161 | + | } |
|
| 162 | + | if filter.SubscriptionID != nil { |
|
| 163 | + | b.WriteString(" AND i.subscription_id = ?") |
|
| 164 | + | args = append(args, *filter.SubscriptionID) |
|
| 165 | + | } |
|
| 166 | + | b.WriteString(" ORDER BY i.published_at DESC, i.id DESC LIMIT ?") |
|
| 167 | + | args = append(args, limit) |
|
| 168 | + | rows, err := db.Query(b.String(), args...) |
|
| 169 | + | if err != nil { |
|
| 170 | + | return nil, err |
|
| 171 | + | } |
|
| 172 | + | defer rows.Close() |
|
| 173 | + | var items []ItemWithFeed |
|
| 174 | + | for rows.Next() { |
|
| 175 | + | var it ItemWithFeed |
|
| 176 | + | var author sql.NullString |
|
| 177 | + | var categoryID sql.NullInt64 |
|
| 178 | + | var categoryName sql.NullString |
|
| 179 | + | var isRead int |
|
| 180 | + | if err := rows.Scan(&it.ID, &it.SubscriptionID, &it.GUID, &it.Title, &it.Link, &author, &it.PublishedAt, &isRead, &it.FetchedAt, &it.FeedTitle, &it.FeedURL, &categoryID, &categoryName); err != nil { |
|
| 181 | + | return nil, err |
|
| 182 | + | } |
|
| 183 | + | if author.Valid { |
|
| 184 | + | it.Author = &author.String |
|
| 185 | + | } |
|
| 186 | + | if categoryID.Valid { |
|
| 187 | + | v := categoryID.Int64 |
|
| 188 | + | it.CategoryID = &v |
|
| 189 | + | } |
|
| 190 | + | if categoryName.Valid { |
|
| 191 | + | v := categoryName.String |
|
| 192 | + | it.CategoryName = &v |
|
| 193 | + | } |
|
| 194 | + | it.IsRead = isRead != 0 |
|
| 195 | + | items = append(items, it) |
|
| 196 | + | } |
|
| 197 | + | return items, rows.Err() |
|
| 198 | + | } |
|
| 199 | + | ||
| 200 | + | func listSubscriptions(db *sql.DB) ([]Subscription, error) { |
|
| 201 | + | rows, err := db.Query(`SELECT ` + subscriptionSelectColumns + ` |
|
| 202 | + | FROM subscriptions ORDER BY title COLLATE NOCASE ASC`) |
|
| 203 | + | if err != nil { |
|
| 204 | + | return nil, err |
|
| 205 | + | } |
|
| 206 | + | defer rows.Close() |
|
| 207 | + | var subs []Subscription |
|
| 208 | + | for rows.Next() { |
|
| 209 | + | s, err := scanSubscription(rows) |
|
| 210 | + | if err != nil { |
|
| 211 | + | return nil, err |
|
| 212 | + | } |
|
| 213 | + | subs = append(subs, *s) |
|
| 214 | + | } |
|
| 215 | + | return subs, rows.Err() |
|
| 216 | + | } |
|
| 217 | + | ||
| 218 | + | func getSubscriptionByURL(db *sql.DB, feedURL string) (*Subscription, error) { |
|
| 219 | + | return querySubscription(db, `SELECT `+subscriptionSelectColumns+` FROM subscriptions WHERE feed_url = ?`, feedURL) |
|
| 220 | + | } |
|
| 221 | + | ||
| 222 | + | func insertSubscription(db *sql.DB, feedURL, title string, siteURL *string, categoryID *int64) (*Subscription, error) { |
|
| 223 | + | res, err := db.Exec(`INSERT INTO subscriptions (feed_url, title, site_url, category_id) VALUES (?, ?, ?, ?)`, feedURL, title, siteURL, categoryID) |
|
| 224 | + | if err != nil { |
|
| 225 | + | return nil, err |
|
| 226 | + | } |
|
| 227 | + | id, err := res.LastInsertId() |
|
| 228 | + | if err != nil { |
|
| 229 | + | return nil, err |
|
| 230 | + | } |
|
| 231 | + | return getSubscription(db, id) |
|
| 232 | + | } |
|
| 233 | + | ||
| 234 | + | func getSubscription(db *sql.DB, id int64) (*Subscription, error) { |
|
| 235 | + | return querySubscription(db, `SELECT `+subscriptionSelectColumns+` FROM subscriptions WHERE id = ?`, id) |
|
| 236 | + | } |
|
| 237 | + | ||
| 238 | + | func updateSubscriptionMeta(db *sql.DB, id int64, etag, lastModified *string, lastError *string) error { |
|
| 239 | + | _, err := db.Exec(`UPDATE subscriptions SET etag = ?, last_modified = ?, last_fetched_at = ?, last_error = ? WHERE id = ?`, |
|
| 240 | + | nullableString(etag), nullableString(lastModified), time.Now().UTC().Format("2006-01-02 15:04:05"), nullableString(lastError), id) |
|
| 241 | + | return err |
|
| 242 | + | } |
|
| 243 | + | ||
| 244 | + | func updateSubscriptionTitle(db *sql.DB, id int64, title string) error { |
|
| 245 | + | _, err := db.Exec(`UPDATE subscriptions SET title = ? WHERE id = ?`, title, id) |
|
| 246 | + | return err |
|
| 247 | + | } |
|
| 248 | + | ||
| 249 | + | func updateSubscriptionSiteURL(db *sql.DB, id int64, siteURL *string) error { |
|
| 250 | + | _, err := db.Exec(`UPDATE subscriptions SET site_url = ? WHERE id = ?`, nullableString(siteURL), id) |
|
| 251 | + | return err |
|
| 252 | + | } |
|
| 253 | + | ||
| 254 | + | func updateSubscriptionFavicon(db *sql.DB, id int64, favicon *string) error { |
|
| 255 | + | _, err := db.Exec(`UPDATE subscriptions SET favicon_url = ? WHERE id = ?`, nullableString(favicon), id) |
|
| 256 | + | return err |
|
| 257 | + | } |
|
| 258 | + | ||
| 259 | + | func updateSubscriptionCategory(db *sql.DB, id int64, categoryID *int64) error { |
|
| 260 | + | _, err := db.Exec(`UPDATE subscriptions SET category_id = ? WHERE id = ?`, nullableInt64(categoryID), id) |
|
| 261 | + | return err |
|
| 262 | + | } |
|
| 263 | + | ||
| 264 | + | func deleteSubscription(db *sql.DB, id int64) (bool, error) { |
|
| 265 | + | res, err := db.Exec(`DELETE FROM subscriptions WHERE id = ?`, id) |
|
| 266 | + | if err != nil { |
|
| 267 | + | return false, err |
|
| 268 | + | } |
|
| 269 | + | n, _ := res.RowsAffected() |
|
| 270 | + | return n > 0, nil |
|
| 271 | + | } |
|
| 272 | + | ||
| 273 | + | func insertItemIgnoreDup(db *sql.DB, item NewItem) (bool, error) { |
|
| 274 | + | res, err := db.Exec(`INSERT OR IGNORE INTO items (subscription_id, guid, title, link, author, published_at) VALUES (?, ?, ?, ?, ?, ?)`, |
|
| 275 | + | item.SubscriptionID, item.GUID, item.Title, item.Link, nullableString(stringPtr(strings.TrimSpace(item.Author))), item.PublishedAt) |
|
| 276 | + | if err != nil { |
|
| 277 | + | return false, err |
|
| 278 | + | } |
|
| 279 | + | n, _ := res.RowsAffected() |
|
| 280 | + | return n > 0, nil |
|
| 281 | + | } |
|
| 282 | + | ||
| 283 | + | func pruneSubscription(db *sql.DB, subscriptionID int64, keepN int) error { |
|
| 284 | + | _, err := db.Exec(`DELETE FROM items |
|
| 285 | + | WHERE subscription_id = ? |
|
| 286 | + | AND id NOT IN ( |
|
| 287 | + | SELECT id FROM items WHERE subscription_id = ? ORDER BY published_at DESC, id DESC LIMIT ? |
|
| 288 | + | )`, subscriptionID, subscriptionID, keepN) |
|
| 289 | + | return err |
|
| 290 | + | } |
|
| 291 | + | ||
| 292 | + | func markItemRead(db *sql.DB, id int64, isRead bool) (bool, error) { |
|
| 293 | + | val := 0 |
|
| 294 | + | if isRead { |
|
| 295 | + | val = 1 |
|
| 296 | + | } |
|
| 297 | + | res, err := db.Exec(`UPDATE items SET is_read = ? WHERE id = ?`, val, id) |
|
| 298 | + | if err != nil { |
|
| 299 | + | return false, err |
|
| 300 | + | } |
|
| 301 | + | n, _ := res.RowsAffected() |
|
| 302 | + | return n > 0, nil |
|
| 303 | + | } |
|
| 304 | + | ||
| 305 | + | func listCategories(db *sql.DB) ([]Category, error) { |
|
| 306 | + | rows, err := db.Query(`SELECT id, name, created_at FROM categories ORDER BY name ASC`) |
|
| 307 | + | if err != nil { |
|
| 308 | + | return nil, err |
|
| 309 | + | } |
|
| 310 | + | defer rows.Close() |
|
| 311 | + | var out []Category |
|
| 312 | + | for rows.Next() { |
|
| 313 | + | var c Category |
|
| 314 | + | if err := rows.Scan(&c.ID, &c.Name, &c.CreatedAt); err != nil { |
|
| 315 | + | return nil, err |
|
| 316 | + | } |
|
| 317 | + | out = append(out, c) |
|
| 318 | + | } |
|
| 319 | + | return out, rows.Err() |
|
| 320 | + | } |
|
| 321 | + | ||
| 322 | + | func getOrCreateCategory(db *sql.DB, name string) (*Category, error) { |
|
| 323 | + | name = strings.TrimSpace(name) |
|
| 324 | + | if name == "" { |
|
| 325 | + | return nil, nil |
|
| 326 | + | } |
|
| 327 | + | var c Category |
|
| 328 | + | err := db.QueryRow(`SELECT id, name, created_at FROM categories WHERE name = ?`, name).Scan(&c.ID, &c.Name, &c.CreatedAt) |
|
| 329 | + | if err == nil { |
|
| 330 | + | return &c, nil |
|
| 331 | + | } |
|
| 332 | + | if !errors.Is(err, sql.ErrNoRows) { |
|
| 333 | + | return nil, err |
|
| 334 | + | } |
|
| 335 | + | res, err := db.Exec(`INSERT INTO categories (name) VALUES (?)`, name) |
|
| 336 | + | if err != nil { |
|
| 337 | + | var existing Category |
|
| 338 | + | if err2 := db.QueryRow(`SELECT id, name, created_at FROM categories WHERE name = ?`, name).Scan(&existing.ID, &existing.Name, &existing.CreatedAt); err2 == nil { |
|
| 339 | + | return &existing, nil |
|
| 340 | + | } |
|
| 341 | + | return nil, err |
|
| 342 | + | } |
|
| 343 | + | id, _ := res.LastInsertId() |
|
| 344 | + | return getCategory(db, id) |
|
| 345 | + | } |
|
| 346 | + | ||
| 347 | + | func getCategory(db *sql.DB, id int64) (*Category, error) { |
|
| 348 | + | var c Category |
|
| 349 | + | err := db.QueryRow(`SELECT id, name, created_at FROM categories WHERE id = ?`, id).Scan(&c.ID, &c.Name, &c.CreatedAt) |
|
| 350 | + | if errors.Is(err, sql.ErrNoRows) { |
|
| 351 | + | return nil, nil |
|
| 352 | + | } |
|
| 353 | + | if err != nil { |
|
| 354 | + | return nil, err |
|
| 355 | + | } |
|
| 356 | + | return &c, nil |
|
| 357 | + | } |
|
| 358 | + | ||
| 359 | + | func deleteCategory(db *sql.DB, id int64) (bool, error) { |
|
| 360 | + | res, err := db.Exec(`DELETE FROM categories WHERE id = ?`, id) |
|
| 361 | + | if err != nil { |
|
| 362 | + | return false, err |
|
| 363 | + | } |
|
| 364 | + | n, _ := res.RowsAffected() |
|
| 365 | + | return n > 0, nil |
|
| 366 | + | } |
|
| 367 | + | ||
| 368 | + | func getSetting(db *sql.DB, key string) (string, bool, error) { |
|
| 369 | + | var value string |
|
| 370 | + | err := db.QueryRow(`SELECT value FROM settings WHERE key = ?`, key).Scan(&value) |
|
| 371 | + | if errors.Is(err, sql.ErrNoRows) { |
|
| 372 | + | return "", false, nil |
|
| 373 | + | } |
|
| 374 | + | if err != nil { |
|
| 375 | + | return "", false, err |
|
| 376 | + | } |
|
| 377 | + | return value, true, nil |
|
| 378 | + | } |
|
| 379 | + | ||
| 380 | + | func setSetting(db *sql.DB, key, value string) error { |
|
| 381 | + | _, err := db.Exec(`INSERT INTO settings (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value`, key, value) |
|
| 382 | + | return err |
|
| 383 | + | } |
|
| 384 | + | ||
| 385 | + | func createSession(db *sql.DB, token string, expiresAt time.Time) error { |
|
| 386 | + | _, err := db.Exec(`INSERT INTO sessions (token, expires_at) VALUES (?, ?)`, token, expiresAt.UTC().Format("2006-01-02 15:04:05")) |
|
| 387 | + | return err |
|
| 388 | + | } |
|
| 389 | + | ||
| 390 | + | func isValidSession(db *sql.DB, token string) bool { |
|
| 391 | + | var expires string |
|
| 392 | + | err := db.QueryRow(`SELECT expires_at FROM sessions WHERE token = ?`, token).Scan(&expires) |
|
| 393 | + | if err != nil { |
|
| 394 | + | return false |
|
| 395 | + | } |
|
| 396 | + | t, err := time.ParseInLocation("2006-01-02 15:04:05", expires, time.UTC) |
|
| 397 | + | return err == nil && t.After(time.Now().UTC()) |
|
| 398 | + | } |
|
| 399 | + | ||
| 400 | + | func deleteSession(db *sql.DB, token string) { |
|
| 401 | + | _, _ = db.Exec(`DELETE FROM sessions WHERE token = ?`, token) |
|
| 402 | + | } |
|
| 403 | + | ||
| 404 | + | func pruneExpiredSessions(db *sql.DB) { |
|
| 405 | + | _, _ = db.Exec(`DELETE FROM sessions WHERE expires_at < datetime('now')`) |
|
| 406 | + | } |
|
| 407 | + | ||
| 408 | + | func querySubscription(db *sql.DB, query string, args ...any) (*Subscription, error) { |
|
| 409 | + | return scanSubscription(db.QueryRow(query, args...)) |
|
| 410 | + | } |
|
| 411 | + | ||
| 412 | + | func scanSubscription(scanner interface{ Scan(dest ...any) error }) (*Subscription, error) { |
|
| 413 | + | var s Subscription |
|
| 414 | + | err := scanner.Scan(&s.ID, &s.FeedURL, &s.Title, &s.SiteURL, &s.FaviconURL, &s.CategoryID, &s.ETag, &s.LastModified, &s.LastFetchedAt, &s.LastError, &s.AddedAt) |
|
| 415 | + | if errors.Is(err, sql.ErrNoRows) { |
|
| 416 | + | return nil, nil |
|
| 417 | + | } |
|
| 418 | + | if err != nil { |
|
| 419 | + | return nil, err |
|
| 420 | + | } |
|
| 421 | + | return &s, nil |
|
| 422 | + | } |
|
| 423 | + | ||
| 424 | + | func nullableString(s *string) any { |
|
| 425 | + | if s == nil || strings.TrimSpace(*s) == "" { |
|
| 426 | + | return nil |
|
| 427 | + | } |
|
| 428 | + | return *s |
|
| 429 | + | } |
|
| 430 | + | ||
| 431 | + | func nullableInt64(v *int64) any { |
|
| 432 | + | if v == nil { |
|
| 433 | + | return nil |
|
| 434 | + | } |
|
| 435 | + | return *v |
|
| 436 | + | } |
|
| 437 | + | ||
| 438 | + | func stringPtr(s string) *string { |
|
| 439 | + | if strings.TrimSpace(s) == "" { |
|
| 440 | + | return nil |
|
| 441 | + | } |
|
| 442 | + | return &s |
|
| 443 | + | } |
| 1 | + | services: |
|
| 2 | + | app: |
|
| 3 | + | build: |
|
| 4 | + | context: ../.. |
|
| 5 | + | dockerfile: apps/feeds-go/Dockerfile |
|
| 6 | + | ports: |
|
| 7 | + | - "${PORT:-3000}:${PORT:-3000}" |
|
| 8 | + | environment: |
|
| 9 | + | - ADMIN_PASSWORD=${ADMIN_PASSWORD:-changeme} |
|
| 10 | + | - FEEDS_DB_PATH=/data/feeds-go.sqlite |
|
| 11 | + | - COOKIE_SECURE=false |
|
| 12 | + | - HOST=0.0.0.0 |
|
| 13 | + | - PORT=${PORT:-3000} |
|
| 14 | + | - BASE_URL=${BASE_URL:-http://localhost:${PORT:-3000}} |
|
| 15 | + | - API_KEY=${API_KEY:-} |
|
| 16 | + | - DEFAULT_POLL_MINUTES=${DEFAULT_POLL_MINUTES:-30} |
|
| 17 | + | - ITEM_CAP_PER_FEED=${ITEM_CAP_PER_FEED:-200} |
|
| 18 | + | volumes: |
|
| 19 | + | - feeds-go-data:/data |
|
| 20 | + | restart: unless-stopped |
|
| 21 | + | ||
| 22 | + | volumes: |
|
| 23 | + | feeds-go-data: |
| 1 | + | package main |
|
| 2 | + | ||
| 3 | + | import ( |
|
| 4 | + | "context" |
|
| 5 | + | "encoding/xml" |
|
| 6 | + | "errors" |
|
| 7 | + | "fmt" |
|
| 8 | + | "io" |
|
| 9 | + | "log/slog" |
|
| 10 | + | "net/http" |
|
| 11 | + | "net/url" |
|
| 12 | + | "slices" |
|
| 13 | + | "strings" |
|
| 14 | + | "sync" |
|
| 15 | + | "time" |
|
| 16 | + | "unicode/utf8" |
|
| 17 | + | ||
| 18 | + | "github.com/mmcdole/gofeed" |
|
| 19 | + | "golang.org/x/net/html" |
|
| 20 | + | ) |
|
| 21 | + | ||
| 22 | + | type ParsedEntry struct { |
|
| 23 | + | GUID string |
|
| 24 | + | Title string |
|
| 25 | + | Link string |
|
| 26 | + | Author string |
|
| 27 | + | PublishedAt int64 |
|
| 28 | + | } |
|
| 29 | + | ||
| 30 | + | type FetchResult struct { |
|
| 31 | + | Status int |
|
| 32 | + | ETag string |
|
| 33 | + | LastModified string |
|
| 34 | + | Title string |
|
| 35 | + | SiteURL string |
|
| 36 | + | Entries []ParsedEntry |
|
| 37 | + | } |
|
| 38 | + | ||
| 39 | + | type FeedPreviewItem struct { |
|
| 40 | + | Title string |
|
| 41 | + | Link string |
|
| 42 | + | Author string |
|
| 43 | + | Published int64 |
|
| 44 | + | } |
|
| 45 | + | ||
| 46 | + | type OPMLEntry struct { |
|
| 47 | + | XMLURL string |
|
| 48 | + | Title string |
|
| 49 | + | HTMLURL string |
|
| 50 | + | Category string |
|
| 51 | + | } |
|
| 52 | + | ||
| 53 | + | const appUserAgent = "andromeda-feeds-go/0.1 (+https://github.com/stevedylandev/andromeda)" |
|
| 54 | + | ||
| 55 | + | func buildHTTPClient() *http.Client { |
|
| 56 | + | return &http.Client{Timeout: 15 * time.Second} |
|
| 57 | + | } |
|
| 58 | + | ||
| 59 | + | func newRequest(ctx context.Context, method, rawURL string) (*http.Request, error) { |
|
| 60 | + | req, err := http.NewRequestWithContext(ctx, method, rawURL, nil) |
|
| 61 | + | if err != nil { |
|
| 62 | + | return nil, err |
|
| 63 | + | } |
|
| 64 | + | req.Header.Set("User-Agent", appUserAgent) |
|
| 65 | + | return req, nil |
|
| 66 | + | } |
|
| 67 | + | ||
| 68 | + | func fetchFeed(ctx context.Context, feedURL, etag, lastModified string) (*FetchResult, error) { |
|
| 69 | + | client := buildHTTPClient() |
|
| 70 | + | req, err := newRequest(ctx, http.MethodGet, feedURL) |
|
| 71 | + | if err != nil { |
|
| 72 | + | return nil, err |
|
| 73 | + | } |
|
| 74 | + | if etag != "" { |
|
| 75 | + | req.Header.Set("If-None-Match", etag) |
|
| 76 | + | } |
|
| 77 | + | if lastModified != "" { |
|
| 78 | + | req.Header.Set("If-Modified-Since", lastModified) |
|
| 79 | + | } |
|
| 80 | + | resp, err := client.Do(req) |
|
| 81 | + | if err != nil { |
|
| 82 | + | return nil, fmt.Errorf("fetch failed: %w", err) |
|
| 83 | + | } |
|
| 84 | + | defer resp.Body.Close() |
|
| 85 | + | result := &FetchResult{ |
|
| 86 | + | Status: resp.StatusCode, |
|
| 87 | + | ETag: resp.Header.Get("ETag"), |
|
| 88 | + | LastModified: resp.Header.Get("Last-Modified"), |
|
| 89 | + | } |
|
| 90 | + | if resp.StatusCode == http.StatusNotModified { |
|
| 91 | + | if result.ETag == "" { |
|
| 92 | + | result.ETag = etag |
|
| 93 | + | } |
|
| 94 | + | if result.LastModified == "" { |
|
| 95 | + | result.LastModified = lastModified |
|
| 96 | + | } |
|
| 97 | + | return result, nil |
|
| 98 | + | } |
|
| 99 | + | if resp.StatusCode < 200 || resp.StatusCode >= 300 { |
|
| 100 | + | return nil, fmt.Errorf("upstream returned %d", resp.StatusCode) |
|
| 101 | + | } |
|
| 102 | + | parser := gofeed.NewParser() |
|
| 103 | + | feed, err := parser.Parse(resp.Body) |
|
| 104 | + | if err != nil { |
|
| 105 | + | return nil, fmt.Errorf("feed parse failed: %w", err) |
|
| 106 | + | } |
|
| 107 | + | result.Title = strings.TrimSpace(feed.Title) |
|
| 108 | + | result.SiteURL = firstNonEmpty(feed.Link, firstFeedAltLink(feed)) |
|
| 109 | + | for _, item := range feed.Items { |
|
| 110 | + | link := strings.TrimSpace(item.Link) |
|
| 111 | + | if link == "" { |
|
| 112 | + | continue |
|
| 113 | + | } |
|
| 114 | + | title := strings.TrimSpace(item.Title) |
|
| 115 | + | if title == "" { |
|
| 116 | + | title = deriveTitleFromHTML(firstNonEmpty(item.Description, item.Content)) |
|
| 117 | + | } |
|
| 118 | + | author := "" |
|
| 119 | + | if item.Author != nil { |
|
| 120 | + | author = strings.TrimSpace(item.Author.Name) |
|
| 121 | + | } |
|
| 122 | + | guid := strings.TrimSpace(item.GUID) |
|
| 123 | + | if guid == "" { |
|
| 124 | + | guid = link |
|
| 125 | + | } |
|
| 126 | + | published := int64(0) |
|
| 127 | + | switch { |
|
| 128 | + | case item.PublishedParsed != nil: |
|
| 129 | + | published = item.PublishedParsed.Unix() |
|
| 130 | + | case item.UpdatedParsed != nil: |
|
| 131 | + | published = item.UpdatedParsed.Unix() |
|
| 132 | + | } |
|
| 133 | + | result.Entries = append(result.Entries, ParsedEntry{ |
|
| 134 | + | GUID: guid, |
|
| 135 | + | Title: title, |
|
| 136 | + | Link: link, |
|
| 137 | + | Author: author, |
|
| 138 | + | PublishedAt: published, |
|
| 139 | + | }) |
|
| 140 | + | } |
|
| 141 | + | return result, nil |
|
| 142 | + | } |
|
| 143 | + | ||
| 144 | + | func deriveTitleFromHTML(src string) string { |
|
| 145 | + | txt := strings.Join(strings.Fields(htmlToText(src)), " ") |
|
| 146 | + | if txt == "" { |
|
| 147 | + | return "" |
|
| 148 | + | } |
|
| 149 | + | const maxChars = 80 |
|
| 150 | + | if utf8.RuneCountInString(txt) <= maxChars { |
|
| 151 | + | return txt |
|
| 152 | + | } |
|
| 153 | + | runes := []rune(txt) |
|
| 154 | + | return strings.TrimSpace(string(runes[:maxChars])) + "…" |
|
| 155 | + | } |
|
| 156 | + | ||
| 157 | + | func htmlToText(src string) string { |
|
| 158 | + | if strings.TrimSpace(src) == "" { |
|
| 159 | + | return "" |
|
| 160 | + | } |
|
| 161 | + | node, err := html.Parse(strings.NewReader(src)) |
|
| 162 | + | if err != nil { |
|
| 163 | + | return src |
|
| 164 | + | } |
|
| 165 | + | var b strings.Builder |
|
| 166 | + | var walk func(*html.Node) |
|
| 167 | + | walk = func(n *html.Node) { |
|
| 168 | + | if n.Type == html.TextNode { |
|
| 169 | + | b.WriteString(n.Data) |
|
| 170 | + | b.WriteByte(' ') |
|
| 171 | + | } |
|
| 172 | + | for c := n.FirstChild; c != nil; c = c.NextSibling { |
|
| 173 | + | walk(c) |
|
| 174 | + | } |
|
| 175 | + | } |
|
| 176 | + | walk(node) |
|
| 177 | + | return html.UnescapeString(b.String()) |
|
| 178 | + | } |
|
| 179 | + | ||
| 180 | + | func previewURLs(ctx context.Context, urls []string, log *slog.Logger) []FeedPreviewItem { |
|
| 181 | + | var wg sync.WaitGroup |
|
| 182 | + | var mu sync.Mutex |
|
| 183 | + | items := []FeedPreviewItem{} |
|
| 184 | + | for _, raw := range urls { |
|
| 185 | + | feedURL := strings.TrimSpace(raw) |
|
| 186 | + | if feedURL == "" { |
|
| 187 | + | continue |
|
| 188 | + | } |
|
| 189 | + | wg.Add(1) |
|
| 190 | + | go func() { |
|
| 191 | + | defer wg.Done() |
|
| 192 | + | res, err := fetchFeed(ctx, feedURL, "", "") |
|
| 193 | + | if err != nil { |
|
| 194 | + | log.Warn("preview fetch failed", "url", feedURL, "err", err) |
|
| 195 | + | return |
|
| 196 | + | } |
|
| 197 | + | feedTitle := res.Title |
|
| 198 | + | local := make([]FeedPreviewItem, 0, len(res.Entries)) |
|
| 199 | + | for _, entry := range res.Entries { |
|
| 200 | + | author := feedTitle |
|
| 201 | + | if entry.Author != "" && feedTitle != "" { |
|
| 202 | + | author = feedTitle + " - " + entry.Author |
|
| 203 | + | } else if entry.Author != "" { |
|
| 204 | + | author = entry.Author |
|
| 205 | + | } |
|
| 206 | + | local = append(local, FeedPreviewItem{Title: entry.Title, Link: entry.Link, Author: author, Published: entry.PublishedAt}) |
|
| 207 | + | } |
|
| 208 | + | mu.Lock() |
|
| 209 | + | items = append(items, local...) |
|
| 210 | + | mu.Unlock() |
|
| 211 | + | }() |
|
| 212 | + | } |
|
| 213 | + | wg.Wait() |
|
| 214 | + | slices.SortFunc(items, func(a, b FeedPreviewItem) int { |
|
| 215 | + | switch { |
|
| 216 | + | case a.Published > b.Published: |
|
| 217 | + | return -1 |
|
| 218 | + | case a.Published < b.Published: |
|
| 219 | + | return 1 |
|
| 220 | + | default: |
|
| 221 | + | return 0 |
|
| 222 | + | } |
|
| 223 | + | }) |
|
| 224 | + | return items |
|
| 225 | + | } |
|
| 226 | + | ||
| 227 | + | func discoverFavicon(ctx context.Context, siteURL string) string { |
|
| 228 | + | parsed, err := url.Parse(siteURL) |
|
| 229 | + | if err != nil { |
|
| 230 | + | return "" |
|
| 231 | + | } |
|
| 232 | + | client := buildHTTPClient() |
|
| 233 | + | req, err := newRequest(ctx, http.MethodGet, siteURL) |
|
| 234 | + | if err != nil { |
|
| 235 | + | return "" |
|
| 236 | + | } |
|
| 237 | + | resp, err := client.Do(req) |
|
| 238 | + | if err == nil { |
|
| 239 | + | defer resp.Body.Close() |
|
| 240 | + | body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) |
|
| 241 | + | if href := findLinkHref(string(body), func(rel, typ string) bool { |
|
| 242 | + | rel = strings.ToLower(rel) |
|
| 243 | + | return strings.Contains(rel, "icon") |
|
| 244 | + | }); href != "" { |
|
| 245 | + | if resolved, err := parsed.Parse(href); err == nil { |
|
| 246 | + | return resolved.String() |
|
| 247 | + | } |
|
| 248 | + | } |
|
| 249 | + | } |
|
| 250 | + | if fallback, err := parsed.Parse("/favicon.ico"); err == nil { |
|
| 251 | + | return fallback.String() |
|
| 252 | + | } |
|
| 253 | + | return "" |
|
| 254 | + | } |
|
| 255 | + | ||
| 256 | + | func discoverFeeds(ctx context.Context, baseURL string) ([]string, error) { |
|
| 257 | + | parsed, err := url.Parse(baseURL) |
|
| 258 | + | if err != nil { |
|
| 259 | + | return nil, fmt.Errorf("invalid URL: %w", err) |
|
| 260 | + | } |
|
| 261 | + | client := buildHTTPClient() |
|
| 262 | + | req, err := newRequest(ctx, http.MethodGet, baseURL) |
|
| 263 | + | if err != nil { |
|
| 264 | + | return nil, fmt.Errorf("invalid URL: %w", err) |
|
| 265 | + | } |
|
| 266 | + | feeds := []string{} |
|
| 267 | + | resp, err := client.Do(req) |
|
| 268 | + | if err == nil { |
|
| 269 | + | defer resp.Body.Close() |
|
| 270 | + | body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) |
|
| 271 | + | links := findAlternateFeedLinks(string(body)) |
|
| 272 | + | for _, href := range links { |
|
| 273 | + | resolved := href |
|
| 274 | + | if u, err := parsed.Parse(href); err == nil { |
|
| 275 | + | resolved = u.String() |
|
| 276 | + | } |
|
| 277 | + | if !slices.Contains(feeds, resolved) { |
|
| 278 | + | feeds = append(feeds, resolved) |
|
| 279 | + | } |
|
| 280 | + | } |
|
| 281 | + | } |
|
| 282 | + | if len(feeds) == 0 { |
|
| 283 | + | paths := []string{"/feed", "/feed.xml", "/rss", "/rss.xml", "/atom.xml", "/index.xml", "/feed/rss", "/blog/feed", "/blog/rss"} |
|
| 284 | + | for _, path := range paths { |
|
| 285 | + | probe, err := parsed.Parse(path) |
|
| 286 | + | if err != nil { |
|
| 287 | + | continue |
|
| 288 | + | } |
|
| 289 | + | req, err := newRequest(ctx, http.MethodHead, probe.String()) |
|
| 290 | + | if err != nil { |
|
| 291 | + | continue |
|
| 292 | + | } |
|
| 293 | + | resp, err := client.Do(req) |
|
| 294 | + | if err != nil { |
|
| 295 | + | continue |
|
| 296 | + | } |
|
| 297 | + | _ = resp.Body.Close() |
|
| 298 | + | ct := strings.ToLower(resp.Header.Get("Content-Type")) |
|
| 299 | + | if resp.StatusCode >= 200 && resp.StatusCode < 300 && (strings.Contains(ct, "xml") || strings.Contains(ct, "rss") || strings.Contains(ct, "atom")) { |
|
| 300 | + | feeds = append(feeds, probe.String()) |
|
| 301 | + | } |
|
| 302 | + | } |
|
| 303 | + | } |
|
| 304 | + | if len(feeds) == 0 { |
|
| 305 | + | return nil, errors.New("no feeds found at this URL") |
|
| 306 | + | } |
|
| 307 | + | return feeds, nil |
|
| 308 | + | } |
|
| 309 | + | ||
| 310 | + | func parseOPML(content string) []OPMLEntry { |
|
| 311 | + | dec := xml.NewDecoder(strings.NewReader(content)) |
|
| 312 | + | type outline struct { |
|
| 313 | + | Title string `xml:"title,attr"` |
|
| 314 | + | Text string `xml:"text,attr"` |
|
| 315 | + | XMLURL string `xml:"xmlUrl,attr"` |
|
| 316 | + | HTMLURL string `xml:"htmlUrl,attr"` |
|
| 317 | + | Nodes []outline `xml:"outline"` |
|
| 318 | + | } |
|
| 319 | + | type opml struct { |
|
| 320 | + | Body struct { |
|
| 321 | + | Nodes []outline `xml:"outline"` |
|
| 322 | + | } `xml:"body"` |
|
| 323 | + | } |
|
| 324 | + | var doc opml |
|
| 325 | + | if err := dec.Decode(&doc); err != nil { |
|
| 326 | + | return nil |
|
| 327 | + | } |
|
| 328 | + | var out []OPMLEntry |
|
| 329 | + | var walk func(nodes []outline, category string) |
|
| 330 | + | walk = func(nodes []outline, category string) { |
|
| 331 | + | for _, node := range nodes { |
|
| 332 | + | title := firstNonEmpty(node.Title, node.Text) |
|
| 333 | + | if strings.TrimSpace(node.XMLURL) != "" { |
|
| 334 | + | out = append(out, OPMLEntry{XMLURL: strings.TrimSpace(node.XMLURL), Title: title, HTMLURL: strings.TrimSpace(node.HTMLURL), Category: strings.TrimSpace(category)}) |
|
| 335 | + | if len(node.Nodes) > 0 { |
|
| 336 | + | walk(node.Nodes, title) |
|
| 337 | + | } |
|
| 338 | + | continue |
|
| 339 | + | } |
|
| 340 | + | walk(node.Nodes, title) |
|
| 341 | + | } |
|
| 342 | + | } |
|
| 343 | + | walk(doc.Body.Nodes, "") |
|
| 344 | + | return out |
|
| 345 | + | } |
|
| 346 | + | ||
| 347 | + | func findAlternateFeedLinks(doc string) []string { |
|
| 348 | + | node, err := html.Parse(strings.NewReader(doc)) |
|
| 349 | + | if err != nil { |
|
| 350 | + | return nil |
|
| 351 | + | } |
|
| 352 | + | links := []string{} |
|
| 353 | + | var walk func(*html.Node) |
|
| 354 | + | walk = func(n *html.Node) { |
|
| 355 | + | if n.Type == html.ElementNode && strings.EqualFold(n.Data, "link") { |
|
| 356 | + | attrs := attrsMap(n) |
|
| 357 | + | rel := strings.ToLower(attrs["rel"]) |
|
| 358 | + | typ := strings.ToLower(attrs["type"]) |
|
| 359 | + | href := attrs["href"] |
|
| 360 | + | if strings.Contains(rel, "alternate") && href != "" && (strings.Contains(typ, "rss") || strings.Contains(typ, "atom") || strings.Contains(typ, "xml")) { |
|
| 361 | + | links = append(links, href) |
|
| 362 | + | } |
|
| 363 | + | } |
|
| 364 | + | for c := n.FirstChild; c != nil; c = c.NextSibling { |
|
| 365 | + | walk(c) |
|
| 366 | + | } |
|
| 367 | + | } |
|
| 368 | + | walk(node) |
|
| 369 | + | return links |
|
| 370 | + | } |
|
| 371 | + | ||
| 372 | + | func findLinkHref(doc string, match func(rel, typ string) bool) string { |
|
| 373 | + | node, err := html.Parse(strings.NewReader(doc)) |
|
| 374 | + | if err != nil { |
|
| 375 | + | return "" |
|
| 376 | + | } |
|
| 377 | + | var found string |
|
| 378 | + | var walk func(*html.Node) |
|
| 379 | + | walk = func(n *html.Node) { |
|
| 380 | + | if found != "" { |
|
| 381 | + | return |
|
| 382 | + | } |
|
| 383 | + | if n.Type == html.ElementNode && strings.EqualFold(n.Data, "link") { |
|
| 384 | + | attrs := attrsMap(n) |
|
| 385 | + | if match(attrs["rel"], attrs["type"]) { |
|
| 386 | + | found = attrs["href"] |
|
| 387 | + | return |
|
| 388 | + | } |
|
| 389 | + | } |
|
| 390 | + | for c := n.FirstChild; c != nil; c = c.NextSibling { |
|
| 391 | + | walk(c) |
|
| 392 | + | } |
|
| 393 | + | } |
|
| 394 | + | walk(node) |
|
| 395 | + | return found |
|
| 396 | + | } |
|
| 397 | + | ||
| 398 | + | func attrsMap(n *html.Node) map[string]string { |
|
| 399 | + | out := make(map[string]string, len(n.Attr)) |
|
| 400 | + | for _, a := range n.Attr { |
|
| 401 | + | out[strings.ToLower(a.Key)] = a.Val |
|
| 402 | + | } |
|
| 403 | + | return out |
|
| 404 | + | } |
|
| 405 | + | ||
| 406 | + | func firstFeedAltLink(feed *gofeed.Feed) string { |
|
| 407 | + | for _, link := range feed.Links { |
|
| 408 | + | if strings.TrimSpace(link) != "" { |
|
| 409 | + | return link |
|
| 410 | + | } |
|
| 411 | + | } |
|
| 412 | + | return "" |
|
| 413 | + | } |
|
| 414 | + | ||
| 415 | + | func firstNonEmpty(values ...string) string { |
|
| 416 | + | for _, v := range values { |
|
| 417 | + | if strings.TrimSpace(v) != "" { |
|
| 418 | + | return strings.TrimSpace(v) |
|
| 419 | + | } |
|
| 420 | + | } |
|
| 421 | + | return "" |
|
| 422 | + | } |
| 1 | + | module github.com/stevedylandev/andromeda/apps/feeds-go |
|
| 2 | + | ||
| 3 | + | go 1.24.4 |
|
| 4 | + | ||
| 5 | + | require ( |
|
| 6 | + | github.com/google/uuid v1.6.0 |
|
| 7 | + | github.com/mmcdole/gofeed v1.3.0 |
|
| 8 | + | golang.org/x/crypto v0.39.0 |
|
| 9 | + | golang.org/x/net v0.41.0 |
|
| 10 | + | modernc.org/sqlite v1.37.1 |
|
| 11 | + | ) |
|
| 12 | + | ||
| 13 | + | require ( |
|
| 14 | + | github.com/PuerkitoBio/goquery v1.8.0 // indirect |
|
| 15 | + | github.com/andybalholm/cascadia v1.3.1 // indirect |
|
| 16 | + | github.com/dustin/go-humanize v1.0.1 // indirect |
|
| 17 | + | github.com/json-iterator/go v1.1.12 // indirect |
|
| 18 | + | github.com/mattn/go-isatty v0.0.20 // indirect |
|
| 19 | + | github.com/mmcdole/goxpp v1.1.1-0.20240225020742-a0c311522b23 // indirect |
|
| 20 | + | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect |
|
| 21 | + | github.com/modern-go/reflect2 v1.0.2 // indirect |
|
| 22 | + | github.com/ncruces/go-strftime v0.1.9 // indirect |
|
| 23 | + | github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect |
|
| 24 | + | golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 // indirect |
|
| 25 | + | golang.org/x/sys v0.33.0 // indirect |
|
| 26 | + | golang.org/x/text v0.26.0 // indirect |
|
| 27 | + | modernc.org/libc v1.65.7 // indirect |
|
| 28 | + | modernc.org/mathutil v1.7.1 // indirect |
|
| 29 | + | modernc.org/memory v1.11.0 // indirect |
|
| 30 | + | ) |
| 1 | + | github.com/PuerkitoBio/goquery v1.8.0 h1:PJTF7AmFCFKk1N6V6jmKfrNH9tV5pNE6lZMkG0gta/U= |
|
| 2 | + | github.com/PuerkitoBio/goquery v1.8.0/go.mod h1:ypIiRMtY7COPGk+I/YbZLbxsxn9g5ejnI2HSMtkjZvI= |
|
| 3 | + | github.com/andybalholm/cascadia v1.3.1 h1:nhxRkql1kdYCc8Snf7D5/D3spOX+dBgjA6u8x004T2c= |
|
| 4 | + | github.com/andybalholm/cascadia v1.3.1/go.mod h1:R4bJ1UQfqADjvDa4P6HZHLh/3OxWWEqc0Sk8XGwHqvA= |
|
| 5 | + | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= |
|
| 6 | + | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= |
|
| 7 | + | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= |
|
| 8 | + | github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= |
|
| 9 | + | github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= |
|
| 10 | + | github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= |
|
| 11 | + | github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= |
|
| 12 | + | github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= |
|
| 13 | + | github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= |
|
| 14 | + | github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= |
|
| 15 | + | github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= |
|
| 16 | + | github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= |
|
| 17 | + | github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= |
|
| 18 | + | github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= |
|
| 19 | + | github.com/mmcdole/gofeed v1.3.0 h1:5yn+HeqlcvjMeAI4gu6T+crm7d0anY85+M+v6fIFNG4= |
|
| 20 | + | github.com/mmcdole/gofeed v1.3.0/go.mod h1:9TGv2LcJhdXePDzxiuMnukhV2/zb6VtnZt1mS+SjkLE= |
|
| 21 | + | github.com/mmcdole/goxpp v1.1.1-0.20240225020742-a0c311522b23 h1:Zr92CAlFhy2gL+V1F+EyIuzbQNbSgP4xhTODZtrXUtk= |
|
| 22 | + | github.com/mmcdole/goxpp v1.1.1-0.20240225020742-a0c311522b23/go.mod h1:v+25+lT2ViuQ7mVxcncQ8ch1URund48oH+jhjiwEgS8= |
|
| 23 | + | github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= |
|
| 24 | + | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= |
|
| 25 | + | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= |
|
| 26 | + | github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= |
|
| 27 | + | github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= |
|
| 28 | + | github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= |
|
| 29 | + | github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= |
|
| 30 | + | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= |
|
| 31 | + | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= |
|
| 32 | + | github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= |
|
| 33 | + | github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= |
|
| 34 | + | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= |
|
| 35 | + | github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= |
|
| 36 | + | github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= |
|
| 37 | + | github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= |
|
| 38 | + | golang.org/x/crypto v0.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM= |
|
| 39 | + | golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U= |
|
| 40 | + | golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 h1:R84qjqJb5nVJMxqWYb3np9L5ZsaDtB+a39EqjV0JSUM= |
|
| 41 | + | golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0/go.mod h1:S9Xr4PYopiDyqSyp5NjCrhFrqg6A5zA2E/iPHPhqnS8= |
|
| 42 | + | golang.org/x/mod v0.25.0 h1:n7a+ZbQKQA/Ysbyb0/6IbB1H/X41mKgbhfv7AfG/44w= |
|
| 43 | + | golang.org/x/mod v0.25.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww= |
|
| 44 | + | golang.org/x/net v0.0.0-20210916014120-12bc252f5db8/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= |
|
| 45 | + | golang.org/x/net v0.41.0 h1:vBTly1HeNPEn3wtREYfy4GZ/NECgw2Cnl+nK6Nz3uvw= |
|
| 46 | + | golang.org/x/net v0.41.0/go.mod h1:B/K4NNqkfmg07DQYrbwvSluqCJOOXwUjeb/5lOisjbA= |
|
| 47 | + | golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8= |
|
| 48 | + | golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= |
|
| 49 | + | golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= |
|
| 50 | + | golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= |
|
| 51 | + | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= |
|
| 52 | + | golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= |
|
| 53 | + | golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= |
|
| 54 | + | golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= |
|
| 55 | + | golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= |
|
| 56 | + | golang.org/x/text v0.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M= |
|
| 57 | + | golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA= |
|
| 58 | + | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= |
|
| 59 | + | golang.org/x/tools v0.33.0 h1:4qz2S3zmRxbGIhDIAgjxvFutSvH5EfnsYrRBj0UI0bc= |
|
| 60 | + | golang.org/x/tools v0.33.0/go.mod h1:CIJMaWEY88juyUfo7UbgPqbC8rU2OqfAV1h2Qp0oMYI= |
|
| 61 | + | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= |
|
| 62 | + | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= |
|
| 63 | + | modernc.org/cc/v4 v4.26.1 h1:+X5NtzVBn0KgsBCBe+xkDC7twLb/jNVj9FPgiwSQO3s= |
|
| 64 | + | modernc.org/cc/v4 v4.26.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0= |
|
| 65 | + | modernc.org/ccgo/v4 v4.28.0 h1:rjznn6WWehKq7dG4JtLRKxb52Ecv8OUGah8+Z/SfpNU= |
|
| 66 | + | modernc.org/ccgo/v4 v4.28.0/go.mod h1:JygV3+9AV6SmPhDasu4JgquwU81XAKLd3OKTUDNOiKE= |
|
| 67 | + | modernc.org/fileutil v1.3.1 h1:8vq5fe7jdtEvoCf3Zf9Nm0Q05sH6kGx0Op2CPx1wTC8= |
|
| 68 | + | modernc.org/fileutil v1.3.1/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc= |
|
| 69 | + | modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= |
|
| 70 | + | modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= |
|
| 71 | + | modernc.org/libc v1.65.7 h1:Ia9Z4yzZtWNtUIuiPuQ7Qf7kxYrxP1/jeHZzG8bFu00= |
|
| 72 | + | modernc.org/libc v1.65.7/go.mod h1:011EQibzzio/VX3ygj1qGFt5kMjP0lHb0qCW5/D/pQU= |
|
| 73 | + | modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= |
|
| 74 | + | modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= |
|
| 75 | + | modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= |
|
| 76 | + | modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= |
|
| 77 | + | modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8= |
|
| 78 | + | modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= |
|
| 79 | + | modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= |
|
| 80 | + | modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= |
|
| 81 | + | modernc.org/sqlite v1.37.1 h1:EgHJK/FPoqC+q2YBXg7fUmES37pCHFc97sI7zSayBEs= |
|
| 82 | + | modernc.org/sqlite v1.37.1/go.mod h1:XwdRtsE1MpiBcL54+MbKcaDvcuej+IYSMfLN6gSKV8g= |
|
| 83 | + | modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= |
|
| 84 | + | modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= |
|
| 85 | + | modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= |
|
| 86 | + | modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= |
| 1 | + | package main |
|
| 2 | + | ||
| 3 | + | import ( |
|
| 4 | + | "fmt" |
|
| 5 | + | "net/http" |
|
| 6 | + | "strings" |
|
| 7 | + | "time" |
|
| 8 | + | ||
| 9 | + | "github.com/google/uuid" |
|
| 10 | + | ) |
|
| 11 | + | ||
| 12 | + | func (a *App) loginGetHandler(w http.ResponseWriter, r *http.Request) { |
|
| 13 | + | a.render(w, "login.html", loginPageData{Error: r.URL.Query().Get("error")}) |
|
| 14 | + | } |
|
| 15 | + | ||
| 16 | + | func (a *App) loginPostHandler(w http.ResponseWriter, r *http.Request) { |
|
| 17 | + | if a.AdminPassword == "" { |
|
| 18 | + | http.Redirect(w, r, "/admin/login?error=No+admin+password+configured", http.StatusSeeOther) |
|
| 19 | + | return |
|
| 20 | + | } |
|
| 21 | + | if err := r.ParseForm(); err != nil { |
|
| 22 | + | http.Redirect(w, r, "/admin/login?error=Bad+request", http.StatusSeeOther) |
|
| 23 | + | return |
|
| 24 | + | } |
|
| 25 | + | if !verifyPassword(r.FormValue("password"), a.AdminPassword) { |
|
| 26 | + | http.Redirect(w, r, "/admin/login?error=Invalid+password", http.StatusSeeOther) |
|
| 27 | + | return |
|
| 28 | + | } |
|
| 29 | + | token := uuid.NewString() |
|
| 30 | + | if err := createSession(a.DB, token, time.Now().Add(7*24*time.Hour)); err != nil { |
|
| 31 | + | a.Log.Error("create session failed", "err", err) |
|
| 32 | + | http.Redirect(w, r, "/admin/login?error=Session+error", http.StatusSeeOther) |
|
| 33 | + | return |
|
| 34 | + | } |
|
| 35 | + | pruneExpiredSessions(a.DB) |
|
| 36 | + | http.SetCookie(w, a.sessionCookie(token)) |
|
| 37 | + | http.Redirect(w, r, "/admin", http.StatusSeeOther) |
|
| 38 | + | } |
|
| 39 | + | ||
| 40 | + | func (a *App) logoutHandler(w http.ResponseWriter, r *http.Request) { |
|
| 41 | + | if cookie, err := r.Cookie("feeds_session"); err == nil { |
|
| 42 | + | deleteSession(a.DB, cookie.Value) |
|
| 43 | + | } |
|
| 44 | + | http.SetCookie(w, &http.Cookie{Name: "feeds_session", Value: "", Path: "/", HttpOnly: true, Secure: a.CookieSecure, SameSite: http.SameSiteLaxMode, MaxAge: -1}) |
|
| 45 | + | http.Redirect(w, r, "/admin/login", http.StatusSeeOther) |
|
| 46 | + | } |
|
| 47 | + | ||
| 48 | + | func (a *App) adminHandler(w http.ResponseWriter, r *http.Request) { |
|
| 49 | + | subs, _ := listSubscriptions(a.DB) |
|
| 50 | + | cats, _ := listCategories(a.DB) |
|
| 51 | + | catMap := map[int64]string{} |
|
| 52 | + | for _, c := range cats { |
|
| 53 | + | catMap[c.ID] = c.Name |
|
| 54 | + | } |
|
| 55 | + | rows := []adminSubRow{} |
|
| 56 | + | for _, s := range subs { |
|
| 57 | + | rows = append(rows, adminSubRow{ID: s.ID, Title: s.Title, FeedURL: s.FeedURL, SiteURL: firstNonEmpty(nullStringValue(s.SiteURL), s.FeedURL), CategoryName: catMap[s.CategoryID.Int64], LastFetchedAt: nullStringValue(s.LastFetchedAt), LastError: nullStringValue(s.LastError)}) |
|
| 58 | + | } |
|
| 59 | + | a.render(w, "admin.html", adminPageData{Success: r.URL.Query().Get("success"), Error: r.URL.Query().Get("error"), Subscriptions: rows, Categories: cats, PollIntervalMinutes: a.pollIntervalMinutes(), ItemCap: a.ItemCap, APIKeyConfigured: a.APIKey != ""}) |
|
| 60 | + | } |
|
| 61 | + | ||
| 62 | + | func (a *App) discoverFeedsHandler(w http.ResponseWriter, r *http.Request) { |
|
| 63 | + | if err := r.ParseForm(); err != nil { |
|
| 64 | + | writeJSON(w, http.StatusBadRequest, map[string]any{"error": "bad request"}) |
|
| 65 | + | return |
|
| 66 | + | } |
|
| 67 | + | feeds, err := discoverFeeds(r.Context(), r.FormValue("base_url")) |
|
| 68 | + | if err != nil { |
|
| 69 | + | writeJSON(w, http.StatusBadRequest, map[string]any{"error": err.Error()}) |
|
| 70 | + | return |
|
| 71 | + | } |
|
| 72 | + | writeJSON(w, http.StatusOK, feeds) |
|
| 73 | + | } |
|
| 74 | + | ||
| 75 | + | func (a *App) addFeedHandler(w http.ResponseWriter, r *http.Request) { |
|
| 76 | + | if err := r.ParseForm(); err != nil { |
|
| 77 | + | redirectAdminError(w, r, "Bad request") |
|
| 78 | + | return |
|
| 79 | + | } |
|
| 80 | + | body := createSubscriptionBody{FeedURL: r.FormValue("feed_url"), CategoryName: r.FormValue("category_name")} |
|
| 81 | + | if _, err := a.createSubscription(r.Context(), body, true); err != nil { |
|
| 82 | + | if isAlreadySubscribedError(err) { |
|
| 83 | + | redirectAdminError(w, r, "Already subscribed") |
|
| 84 | + | return |
|
| 85 | + | } |
|
| 86 | + | redirectAdminError(w, r, "Failed to add feed") |
|
| 87 | + | return |
|
| 88 | + | } |
|
| 89 | + | redirectAdminSuccess(w, r, "Feed added and will be fetched in the background") |
|
| 90 | + | } |
|
| 91 | + | ||
| 92 | + | func (a *App) deleteFeedHandler(w http.ResponseWriter, r *http.Request) { |
|
| 93 | + | id, ok := pathInt64(r, "id") |
|
| 94 | + | if !ok { |
|
| 95 | + | redirectAdminError(w, r, "Invalid feed ID") |
|
| 96 | + | return |
|
| 97 | + | } |
|
| 98 | + | deleted, err := deleteSubscription(a.DB, id) |
|
| 99 | + | if err != nil { |
|
| 100 | + | redirectAdminError(w, r, "Failed to remove") |
|
| 101 | + | return |
|
| 102 | + | } |
|
| 103 | + | if !deleted { |
|
| 104 | + | redirectAdminError(w, r, "Failed to remove") |
|
| 105 | + | return |
|
| 106 | + | } |
|
| 107 | + | redirectAdminSuccess(w, r, "Feed removed") |
|
| 108 | + | } |
|
| 109 | + | ||
| 110 | + | func (a *App) updateSubCategoryHandler(w http.ResponseWriter, r *http.Request) { |
|
| 111 | + | id, ok := pathInt64(r, "id") |
|
| 112 | + | if !ok { |
|
| 113 | + | redirectAdminError(w, r, "Invalid feed ID") |
|
| 114 | + | return |
|
| 115 | + | } |
|
| 116 | + | if err := r.ParseForm(); err != nil { |
|
| 117 | + | redirectAdminError(w, r, "Bad request") |
|
| 118 | + | return |
|
| 119 | + | } |
|
| 120 | + | categoryID, err := a.resolveCategory(nil, r.FormValue("category_name")) |
|
| 121 | + | if err != nil { |
|
| 122 | + | redirectAdminError(w, r, "Failed to update category") |
|
| 123 | + | return |
|
| 124 | + | } |
|
| 125 | + | if err := updateSubscriptionCategory(a.DB, id, categoryID); err != nil { |
|
| 126 | + | redirectAdminError(w, r, "Failed to update category") |
|
| 127 | + | return |
|
| 128 | + | } |
|
| 129 | + | redirectAdminSuccess(w, r, "Category updated") |
|
| 130 | + | } |
|
| 131 | + | ||
| 132 | + | func (a *App) addCategoryHandler(w http.ResponseWriter, r *http.Request) { |
|
| 133 | + | if err := r.ParseForm(); err != nil { |
|
| 134 | + | redirectAdminError(w, r, "Bad request") |
|
| 135 | + | return |
|
| 136 | + | } |
|
| 137 | + | name := strings.TrimSpace(r.FormValue("name")) |
|
| 138 | + | if name == "" { |
|
| 139 | + | redirectAdminError(w, r, "Name required") |
|
| 140 | + | return |
|
| 141 | + | } |
|
| 142 | + | if _, err := getOrCreateCategory(a.DB, name); err != nil { |
|
| 143 | + | redirectAdminError(w, r, "Failed to add category") |
|
| 144 | + | return |
|
| 145 | + | } |
|
| 146 | + | redirectAdminSuccess(w, r, "Category added") |
|
| 147 | + | } |
|
| 148 | + | ||
| 149 | + | func (a *App) deleteCategoryHandler(w http.ResponseWriter, r *http.Request) { |
|
| 150 | + | id, ok := pathInt64(r, "id") |
|
| 151 | + | if !ok { |
|
| 152 | + | redirectAdminError(w, r, "Invalid category ID") |
|
| 153 | + | return |
|
| 154 | + | } |
|
| 155 | + | deleted, err := deleteCategory(a.DB, id) |
|
| 156 | + | if err != nil { |
|
| 157 | + | redirectAdminError(w, r, "Failed to remove category") |
|
| 158 | + | return |
|
| 159 | + | } |
|
| 160 | + | if !deleted { |
|
| 161 | + | redirectAdminError(w, r, "Category not found") |
|
| 162 | + | return |
|
| 163 | + | } |
|
| 164 | + | redirectAdminSuccess(w, r, "Category removed") |
|
| 165 | + | } |
|
| 166 | + | ||
| 167 | + | func (a *App) importOPMLHandler(w http.ResponseWriter, r *http.Request) { |
|
| 168 | + | summary, err := a.readAndImportOPML(r) |
|
| 169 | + | if err != nil { |
|
| 170 | + | redirectAdminError(w, r, "No file uploaded") |
|
| 171 | + | return |
|
| 172 | + | } |
|
| 173 | + | redirectAdminSuccess(w, r, fmt.Sprintf("Imported %d, skipped %d", summary.Imported, summary.Skipped)) |
|
| 174 | + | } |
|
| 175 | + | ||
| 176 | + | func (a *App) updateSettingsFormHandler(w http.ResponseWriter, r *http.Request) { |
|
| 177 | + | if err := r.ParseForm(); err != nil { |
|
| 178 | + | redirectAdminError(w, r, "Bad request") |
|
| 179 | + | return |
|
| 180 | + | } |
|
| 181 | + | mins, ok := formPollMinutes(r) |
|
| 182 | + | if !ok { |
|
| 183 | + | redirectAdminError(w, r, "Interval must be 1-1440") |
|
| 184 | + | return |
|
| 185 | + | } |
|
| 186 | + | if err := setSetting(a.DB, "poll_interval_minutes", fmt.Sprintf("%d", mins)); err != nil { |
|
| 187 | + | redirectAdminError(w, r, "Failed to save settings") |
|
| 188 | + | return |
|
| 189 | + | } |
|
| 190 | + | redirectAdminSuccess(w, r, "Settings saved") |
|
| 191 | + | } |
| 1 | + | package main |
|
| 2 | + | ||
| 3 | + | import ( |
|
| 4 | + | "net/http" |
|
| 5 | + | ) |
|
| 6 | + | ||
| 7 | + | func (a *App) listItemsAPI(w http.ResponseWriter, r *http.Request) { |
|
| 8 | + | items, err := listItems(a.DB, itemFilterFromRequest(r)) |
|
| 9 | + | if err != nil { |
|
| 10 | + | writeJSON(w, http.StatusInternalServerError, map[string]any{"error": err.Error()}) |
|
| 11 | + | return |
|
| 12 | + | } |
|
| 13 | + | writeJSON(w, http.StatusOK, map[string]any{"items": items}) |
|
| 14 | + | } |
|
| 15 | + | ||
| 16 | + | func (a *App) markItemReadAPI(isRead bool) http.HandlerFunc { |
|
| 17 | + | return func(w http.ResponseWriter, r *http.Request) { |
|
| 18 | + | id, ok := pathInt64(r, "id") |
|
| 19 | + | if !ok { |
|
| 20 | + | writeJSON(w, http.StatusBadRequest, map[string]any{"error": "invalid item id"}) |
|
| 21 | + | return |
|
| 22 | + | } |
|
| 23 | + | updated, err := markItemRead(a.DB, id, isRead) |
|
| 24 | + | if err != nil { |
|
| 25 | + | writeJSON(w, http.StatusInternalServerError, map[string]any{"error": err.Error()}) |
|
| 26 | + | return |
|
| 27 | + | } |
|
| 28 | + | if !updated { |
|
| 29 | + | writeJSON(w, http.StatusNotFound, map[string]any{"error": "item not found"}) |
|
| 30 | + | return |
|
| 31 | + | } |
|
| 32 | + | writeJSON(w, http.StatusOK, map[string]any{"ok": true, "is_read": isRead}) |
|
| 33 | + | } |
|
| 34 | + | } |
|
| 35 | + | ||
| 36 | + | func (a *App) listSubscriptionsAPI(w http.ResponseWriter, r *http.Request) { |
|
| 37 | + | subs, err := listSubscriptions(a.DB) |
|
| 38 | + | if err != nil { |
|
| 39 | + | writeJSON(w, http.StatusInternalServerError, map[string]any{"error": err.Error()}) |
|
| 40 | + | return |
|
| 41 | + | } |
|
| 42 | + | views := make([]subscriptionView, 0, len(subs)) |
|
| 43 | + | for _, sub := range subs { |
|
| 44 | + | views = append(views, toSubscriptionView(sub)) |
|
| 45 | + | } |
|
| 46 | + | writeJSON(w, http.StatusOK, map[string]any{"subscriptions": views}) |
|
| 47 | + | } |
|
| 48 | + | ||
| 49 | + | func (a *App) createSubscriptionAPI(w http.ResponseWriter, r *http.Request) { |
|
| 50 | + | var body createSubscriptionBody |
|
| 51 | + | if !decodeJSONBody(w, r, &body) { |
|
| 52 | + | return |
|
| 53 | + | } |
|
| 54 | + | sub, err := a.createSubscription(r.Context(), body, false) |
|
| 55 | + | if err != nil { |
|
| 56 | + | status := http.StatusBadRequest |
|
| 57 | + | if isAlreadySubscribedError(err) { |
|
| 58 | + | status = http.StatusConflict |
|
| 59 | + | } |
|
| 60 | + | writeJSON(w, status, map[string]any{"error": err.Error()}) |
|
| 61 | + | return |
|
| 62 | + | } |
|
| 63 | + | writeJSON(w, http.StatusCreated, map[string]any{"subscription": toSubscriptionView(*sub)}) |
|
| 64 | + | } |
|
| 65 | + | ||
| 66 | + | func (a *App) updateSubscriptionAPI(w http.ResponseWriter, r *http.Request) { |
|
| 67 | + | id, ok := pathInt64(r, "id") |
|
| 68 | + | if !ok { |
|
| 69 | + | writeJSON(w, http.StatusBadRequest, map[string]any{"error": "invalid subscription id"}) |
|
| 70 | + | return |
|
| 71 | + | } |
|
| 72 | + | var body updateSubscriptionBody |
|
| 73 | + | if !decodeJSONBody(w, r, &body) { |
|
| 74 | + | return |
|
| 75 | + | } |
|
| 76 | + | categoryID, err := a.resolveSubscriptionCategory(body) |
|
| 77 | + | if err != nil { |
|
| 78 | + | writeJSON(w, http.StatusInternalServerError, map[string]any{"error": err.Error()}) |
|
| 79 | + | return |
|
| 80 | + | } |
|
| 81 | + | if err := updateSubscriptionCategory(a.DB, id, categoryID); err != nil { |
|
| 82 | + | writeJSON(w, http.StatusInternalServerError, map[string]any{"error": err.Error()}) |
|
| 83 | + | return |
|
| 84 | + | } |
|
| 85 | + | writeJSON(w, http.StatusOK, map[string]any{"ok": true}) |
|
| 86 | + | } |
|
| 87 | + | ||
| 88 | + | func (a *App) deleteSubscriptionAPI(w http.ResponseWriter, r *http.Request) { |
|
| 89 | + | id, ok := pathInt64(r, "id") |
|
| 90 | + | if !ok { |
|
| 91 | + | writeJSON(w, http.StatusBadRequest, map[string]any{"error": "invalid subscription id"}) |
|
| 92 | + | return |
|
| 93 | + | } |
|
| 94 | + | deleted, err := deleteSubscription(a.DB, id) |
|
| 95 | + | if err != nil { |
|
| 96 | + | writeJSON(w, http.StatusInternalServerError, map[string]any{"error": err.Error()}) |
|
| 97 | + | return |
|
| 98 | + | } |
|
| 99 | + | if !deleted { |
|
| 100 | + | writeJSON(w, http.StatusNotFound, map[string]any{"error": "subscription not found"}) |
|
| 101 | + | return |
|
| 102 | + | } |
|
| 103 | + | writeJSON(w, http.StatusOK, map[string]any{"ok": true}) |
|
| 104 | + | } |
|
| 105 | + | ||
| 106 | + | func (a *App) listCategoriesAPI(w http.ResponseWriter, r *http.Request) { |
|
| 107 | + | cats, err := listCategories(a.DB) |
|
| 108 | + | if err != nil { |
|
| 109 | + | writeJSON(w, http.StatusInternalServerError, map[string]any{"error": err.Error()}) |
|
| 110 | + | return |
|
| 111 | + | } |
|
| 112 | + | writeJSON(w, http.StatusOK, map[string]any{"categories": cats}) |
|
| 113 | + | } |
|
| 114 | + | ||
| 115 | + | func (a *App) createCategoryAPI(w http.ResponseWriter, r *http.Request) { |
|
| 116 | + | var body createCategoryBody |
|
| 117 | + | if !decodeJSONBody(w, r, &body) { |
|
| 118 | + | return |
|
| 119 | + | } |
|
| 120 | + | cat, err := getOrCreateCategory(a.DB, body.Name) |
|
| 121 | + | if err != nil { |
|
| 122 | + | writeJSON(w, http.StatusInternalServerError, map[string]any{"error": err.Error()}) |
|
| 123 | + | return |
|
| 124 | + | } |
|
| 125 | + | writeJSON(w, http.StatusCreated, map[string]any{"category": cat}) |
|
| 126 | + | } |
|
| 127 | + | ||
| 128 | + | func (a *App) deleteCategoryAPI(w http.ResponseWriter, r *http.Request) { |
|
| 129 | + | id, ok := pathInt64(r, "id") |
|
| 130 | + | if !ok { |
|
| 131 | + | writeJSON(w, http.StatusBadRequest, map[string]any{"error": "invalid category id"}) |
|
| 132 | + | return |
|
| 133 | + | } |
|
| 134 | + | deleted, err := deleteCategory(a.DB, id) |
|
| 135 | + | if err != nil { |
|
| 136 | + | writeJSON(w, http.StatusInternalServerError, map[string]any{"error": err.Error()}) |
|
| 137 | + | return |
|
| 138 | + | } |
|
| 139 | + | if !deleted { |
|
| 140 | + | writeJSON(w, http.StatusNotFound, map[string]any{"error": "category not found"}) |
|
| 141 | + | return |
|
| 142 | + | } |
|
| 143 | + | writeJSON(w, http.StatusOK, map[string]any{"ok": true}) |
|
| 144 | + | } |
|
| 145 | + | ||
| 146 | + | func (a *App) importOPMLAPI(w http.ResponseWriter, r *http.Request) { |
|
| 147 | + | summary, err := a.readAndImportOPML(r) |
|
| 148 | + | if err != nil { |
|
| 149 | + | writeJSON(w, http.StatusBadRequest, map[string]any{"error": err.Error()}) |
|
| 150 | + | return |
|
| 151 | + | } |
|
| 152 | + | writeJSON(w, http.StatusOK, summary) |
|
| 153 | + | } |
|
| 154 | + | ||
| 155 | + | func (a *App) getSettingsAPI(w http.ResponseWriter, r *http.Request) { |
|
| 156 | + | writeJSON(w, http.StatusOK, map[string]any{"poll_interval_minutes": a.pollIntervalMinutes(), "default_poll_minutes": a.DefaultPollMinutes, "item_cap_per_feed": a.ItemCap, "api_key_configured": a.APIKey != ""}) |
|
| 157 | + | } |
|
| 158 | + | ||
| 159 | + | func (a *App) updateSettingsAPI(w http.ResponseWriter, r *http.Request) { |
|
| 160 | + | var body updateSettingsBody |
|
| 161 | + | if !decodeJSONBody(w, r, &body) { |
|
| 162 | + | return |
|
| 163 | + | } |
|
| 164 | + | if body.PollIntervalMinutes != nil { |
|
| 165 | + | if !validPollMinutes(*body.PollIntervalMinutes) { |
|
| 166 | + | writeJSON(w, http.StatusBadRequest, map[string]any{"error": "poll_interval_minutes must be between 1 and 1440"}) |
|
| 167 | + | return |
|
| 168 | + | } |
|
| 169 | + | if err := setSetting(a.DB, "poll_interval_minutes", itoa(*body.PollIntervalMinutes)); err != nil { |
|
| 170 | + | writeJSON(w, http.StatusInternalServerError, map[string]any{"error": err.Error()}) |
|
| 171 | + | return |
|
| 172 | + | } |
|
| 173 | + | } |
|
| 174 | + | writeJSON(w, http.StatusOK, map[string]any{"ok": true}) |
|
| 175 | + | } |
|
| 176 | + | ||
| 177 | + | func (a *App) discoverAPI(w http.ResponseWriter, r *http.Request) { |
|
| 178 | + | var body discoverBody |
|
| 179 | + | if !decodeJSONBody(w, r, &body) { |
|
| 180 | + | return |
|
| 181 | + | } |
|
| 182 | + | feeds, err := discoverFeeds(r.Context(), body.BaseURL) |
|
| 183 | + | if err != nil { |
|
| 184 | + | writeJSON(w, http.StatusBadRequest, map[string]any{"error": err.Error()}) |
|
| 185 | + | return |
|
| 186 | + | } |
|
| 187 | + | writeJSON(w, http.StatusOK, map[string]any{"feeds": feeds}) |
|
| 188 | + | } |
| 1 | + | package main |
|
| 2 | + | ||
| 3 | + | import ( |
|
| 4 | + | "encoding/xml" |
|
| 5 | + | "fmt" |
|
| 6 | + | "net/http" |
|
| 7 | + | "strings" |
|
| 8 | + | "time" |
|
| 9 | + | ) |
|
| 10 | + | ||
| 11 | + | func (a *App) indexHandler(w http.ResponseWriter, r *http.Request) { |
|
| 12 | + | query := r.URL.Query().Get("url") |
|
| 13 | + | if query == "" { |
|
| 14 | + | query = r.URL.Query().Get("urls") |
|
| 15 | + | } |
|
| 16 | + | data := indexPageData{BaseURL: a.BaseURL} |
|
| 17 | + | if query != "" { |
|
| 18 | + | urls := splitAndTrim(query) |
|
| 19 | + | data.FeedURLs = urls |
|
| 20 | + | if len(urls) == 0 { |
|
| 21 | + | data.Error = "No URLs provided" |
|
| 22 | + | a.render(w, "index.html", data) |
|
| 23 | + | return |
|
| 24 | + | } |
|
| 25 | + | for _, item := range previewURLs(r.Context(), urls, a.Log) { |
|
| 26 | + | data.Items = append(data.Items, templateItem{Title: item.Title, Link: item.Link, Author: item.Author, FormattedDate: formatDate(item.Published)}) |
|
| 27 | + | } |
|
| 28 | + | a.render(w, "index.html", data) |
|
| 29 | + | return |
|
| 30 | + | } |
|
| 31 | + | ||
| 32 | + | items, err := listItems(a.DB, ListItemsFilter{Limit: 100}) |
|
| 33 | + | if err != nil { |
|
| 34 | + | a.Log.Error("index query failed", "err", err) |
|
| 35 | + | data.Error = "Error loading feeds. Please try again later." |
|
| 36 | + | a.render(w, "index.html", data) |
|
| 37 | + | return |
|
| 38 | + | } |
|
| 39 | + | for _, item := range items { |
|
| 40 | + | author := item.FeedTitle |
|
| 41 | + | if item.Author != nil && strings.TrimSpace(*item.Author) != "" { |
|
| 42 | + | author = item.FeedTitle + " - " + *item.Author |
|
| 43 | + | } |
|
| 44 | + | data.Items = append(data.Items, templateItem{Title: item.Title, Link: item.Link, Author: author, FormattedDate: formatDate(item.PublishedAt)}) |
|
| 45 | + | } |
|
| 46 | + | a.render(w, "index.html", data) |
|
| 47 | + | } |
|
| 48 | + | ||
| 49 | + | func (a *App) feedsExportHandler(w http.ResponseWriter, r *http.Request) { |
|
| 50 | + | subs, err := listSubscriptions(a.DB) |
|
| 51 | + | if err != nil { |
|
| 52 | + | http.Error(w, "internal server error", http.StatusInternalServerError) |
|
| 53 | + | return |
|
| 54 | + | } |
|
| 55 | + | ||
| 56 | + | switch r.URL.Query().Get("format") { |
|
| 57 | + | case "", "json": |
|
| 58 | + | rows := make([]map[string]any, 0, len(subs)) |
|
| 59 | + | for _, s := range subs { |
|
| 60 | + | rows = append(rows, map[string]any{ |
|
| 61 | + | "id": fmt.Sprintf("feed/%d", s.ID), |
|
| 62 | + | "title": s.Title, |
|
| 63 | + | "url": s.FeedURL, |
|
| 64 | + | "htmlUrl": nullStringValue(s.SiteURL), |
|
| 65 | + | }) |
|
| 66 | + | } |
|
| 67 | + | writeJSON(w, http.StatusOK, map[string]any{"subscriptions": rows}) |
|
| 68 | + | case "opml": |
|
| 69 | + | a.writeOPMLExport(w, subs) |
|
| 70 | + | default: |
|
| 71 | + | writeJSON(w, http.StatusBadRequest, map[string]any{"error": "Invalid format. Use ?format=json or ?format=opml"}) |
|
| 72 | + | } |
|
| 73 | + | } |
|
| 74 | + | ||
| 75 | + | func (a *App) atomFeedHandler(w http.ResponseWriter, r *http.Request) { |
|
| 76 | + | items, err := listItems(a.DB, ListItemsFilter{Limit: 100}) |
|
| 77 | + | if err != nil { |
|
| 78 | + | http.Error(w, "internal server error", http.StatusInternalServerError) |
|
| 79 | + | return |
|
| 80 | + | } |
|
| 81 | + | type atomLink struct { |
|
| 82 | + | Href string `xml:"href,attr"` |
|
| 83 | + | Rel string `xml:"rel,attr,omitempty"` |
|
| 84 | + | Type string `xml:"type,attr,omitempty"` |
|
| 85 | + | } |
|
| 86 | + | type atomAuthor struct { |
|
| 87 | + | Name string `xml:"name"` |
|
| 88 | + | } |
|
| 89 | + | type atomSource struct { |
|
| 90 | + | Title string `xml:"title"` |
|
| 91 | + | } |
|
| 92 | + | type atomEntry struct { |
|
| 93 | + | Title string `xml:"title"` |
|
| 94 | + | Link atomLink `xml:"link"` |
|
| 95 | + | ID string `xml:"id"` |
|
| 96 | + | Updated string `xml:"updated"` |
|
| 97 | + | Published string `xml:"published"` |
|
| 98 | + | Author atomAuthor `xml:"author"` |
|
| 99 | + | Source atomSource `xml:"source"` |
|
| 100 | + | } |
|
| 101 | + | type atomFeed struct { |
|
| 102 | + | XMLName xml.Name `xml:"feed"` |
|
| 103 | + | Xmlns string `xml:"xmlns,attr"` |
|
| 104 | + | Title string `xml:"title"` |
|
| 105 | + | Links []atomLink `xml:"link"` |
|
| 106 | + | ID string `xml:"id"` |
|
| 107 | + | Updated string `xml:"updated"` |
|
| 108 | + | Entries []atomEntry `xml:"entry"` |
|
| 109 | + | } |
|
| 110 | + | ||
| 111 | + | updated := time.Now().UTC().Format(time.RFC3339) |
|
| 112 | + | if len(items) > 0 { |
|
| 113 | + | updated = time.Unix(items[0].PublishedAt, 0).UTC().Format(time.RFC3339) |
|
| 114 | + | } |
|
| 115 | + | base := strings.TrimRight(a.BaseURL, "/") |
|
| 116 | + | feed := atomFeed{ |
|
| 117 | + | Xmlns: "http://www.w3.org/2005/Atom", |
|
| 118 | + | Title: "Feeds", |
|
| 119 | + | ID: base + "/feed.xml", |
|
| 120 | + | Updated: updated, |
|
| 121 | + | Links: []atomLink{{Href: base + "/feed.xml", Rel: "self", Type: "application/atom+xml"}, {Href: base}}, |
|
| 122 | + | } |
|
| 123 | + | for _, item := range items { |
|
| 124 | + | author := item.FeedTitle |
|
| 125 | + | if item.Author != nil && *item.Author != "" { |
|
| 126 | + | author = *item.Author |
|
| 127 | + | } |
|
| 128 | + | entryID := item.GUID |
|
| 129 | + | if strings.TrimSpace(entryID) == "" { |
|
| 130 | + | entryID = item.Link |
|
| 131 | + | } |
|
| 132 | + | published := time.Unix(item.PublishedAt, 0).UTC().Format(time.RFC3339) |
|
| 133 | + | feed.Entries = append(feed.Entries, atomEntry{Title: item.Title, Link: atomLink{Href: item.Link}, ID: entryID, Updated: published, Published: published, Author: atomAuthor{Name: author}, Source: atomSource{Title: item.FeedTitle}}) |
|
| 134 | + | } |
|
| 135 | + | body, _ := xml.MarshalIndent(feed, "", " ") |
|
| 136 | + | w.Header().Set("Content-Type", "application/atom+xml; charset=utf-8") |
|
| 137 | + | _, _ = w.Write([]byte(xml.Header)) |
|
| 138 | + | _, _ = w.Write(body) |
|
| 139 | + | } |
|
| 140 | + | ||
| 141 | + | func (a *App) writeOPMLExport(w http.ResponseWriter, subs []Subscription) { |
|
| 142 | + | cats, _ := listCategories(a.DB) |
|
| 143 | + | catNames := map[int64]string{} |
|
| 144 | + | for _, c := range cats { |
|
| 145 | + | catNames[c.ID] = c.Name |
|
| 146 | + | } |
|
| 147 | + | grouped := map[string][]Subscription{} |
|
| 148 | + | for _, s := range subs { |
|
| 149 | + | key := "" |
|
| 150 | + | if s.CategoryID.Valid { |
|
| 151 | + | key = catNames[s.CategoryID.Int64] |
|
| 152 | + | } |
|
| 153 | + | grouped[key] = append(grouped[key], s) |
|
| 154 | + | } |
|
| 155 | + | type outline struct { |
|
| 156 | + | XMLName xml.Name `xml:"outline"` |
|
| 157 | + | Text string `xml:"text,attr,omitempty"` |
|
| 158 | + | Title string `xml:"title,attr,omitempty"` |
|
| 159 | + | Type string `xml:"type,attr,omitempty"` |
|
| 160 | + | XMLURL string `xml:"xmlUrl,attr,omitempty"` |
|
| 161 | + | HTMLURL string `xml:"htmlUrl,attr,omitempty"` |
|
| 162 | + | Nodes []outline `xml:"outline,omitempty"` |
|
| 163 | + | } |
|
| 164 | + | type opml struct { |
|
| 165 | + | XMLName xml.Name `xml:"opml"` |
|
| 166 | + | Version string `xml:"version,attr"` |
|
| 167 | + | Head struct { |
|
| 168 | + | Title string `xml:"title"` |
|
| 169 | + | DateCreated string `xml:"dateCreated"` |
|
| 170 | + | } `xml:"head"` |
|
| 171 | + | Body struct { |
|
| 172 | + | Nodes []outline `xml:"outline"` |
|
| 173 | + | } `xml:"body"` |
|
| 174 | + | } |
|
| 175 | + | doc := opml{Version: "2.0"} |
|
| 176 | + | doc.Head.Title = "Feeds" |
|
| 177 | + | doc.Head.DateCreated = time.Now().Format(time.RFC1123Z) |
|
| 178 | + | for category, rows := range grouped { |
|
| 179 | + | if category == "" { |
|
| 180 | + | for _, s := range rows { |
|
| 181 | + | doc.Body.Nodes = append(doc.Body.Nodes, outline{Text: s.Title, Title: s.Title, Type: "rss", XMLURL: s.FeedURL, HTMLURL: nullStringValue(s.SiteURL)}) |
|
| 182 | + | } |
|
| 183 | + | continue |
|
| 184 | + | } |
|
| 185 | + | group := outline{Text: category, Title: category} |
|
| 186 | + | for _, s := range rows { |
|
| 187 | + | group.Nodes = append(group.Nodes, outline{Text: s.Title, Title: s.Title, Type: "rss", XMLURL: s.FeedURL, HTMLURL: nullStringValue(s.SiteURL)}) |
|
| 188 | + | } |
|
| 189 | + | doc.Body.Nodes = append(doc.Body.Nodes, group) |
|
| 190 | + | } |
|
| 191 | + | body, _ := xml.MarshalIndent(doc, "", " ") |
|
| 192 | + | w.Header().Set("Content-Type", "application/xml") |
|
| 193 | + | w.Header().Set("Content-Disposition", `attachment; filename="feeds.opml"`) |
|
| 194 | + | _, _ = w.Write([]byte(xml.Header)) |
|
| 195 | + | _, _ = w.Write(body) |
|
| 196 | + | } |
| 1 | + | package main |
|
| 2 | + | ||
| 3 | + | import ( |
|
| 4 | + | "context" |
|
| 5 | + | "html/template" |
|
| 6 | + | "log" |
|
| 7 | + | "log/slog" |
|
| 8 | + | "net/http" |
|
| 9 | + | "os" |
|
| 10 | + | "strings" |
|
| 11 | + | ) |
|
| 12 | + | ||
| 13 | + | func main() { |
|
| 14 | + | loadDotEnv(".env") |
|
| 15 | + | logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo})) |
|
| 16 | + | ||
| 17 | + | dbPath := getenv("FEEDS_DB_PATH", "feeds.sqlite") |
|
| 18 | + | db, err := openDB(dbPath) |
|
| 19 | + | if err != nil { |
|
| 20 | + | log.Fatal(err) |
|
| 21 | + | } |
|
| 22 | + | defer db.Close() |
|
| 23 | + | ||
| 24 | + | defaultPoll := getenvInt("DEFAULT_POLL_MINUTES", 30) |
|
| 25 | + | itemCap := getenvInt("ITEM_CAP_PER_FEED", 200) |
|
| 26 | + | if err := seedSettings(db, defaultPoll); err != nil { |
|
| 27 | + | log.Fatal(err) |
|
| 28 | + | } |
|
| 29 | + | ||
| 30 | + | tmpl := template.Must(template.New("").Funcs(template.FuncMap{"safeURL": func(s string) string { return s }}).ParseFS(appFS, "templates/*.html")) |
|
| 31 | + | app := &App{ |
|
| 32 | + | DB: db, |
|
| 33 | + | Log: logger, |
|
| 34 | + | Templates: tmpl, |
|
| 35 | + | AdminPassword: os.Getenv("ADMIN_PASSWORD"), |
|
| 36 | + | APIKey: os.Getenv("API_KEY"), |
|
| 37 | + | CookieSecure: strings.EqualFold(os.Getenv("COOKIE_SECURE"), "true"), |
|
| 38 | + | BaseURL: getenv("BASE_URL", "http://localhost:3000"), |
|
| 39 | + | DefaultPollMinutes: defaultPoll, |
|
| 40 | + | ItemCap: itemCap, |
|
| 41 | + | } |
|
| 42 | + | if app.APIKey == "" { |
|
| 43 | + | logger.Warn("API_KEY is not set; API requires session cookie only") |
|
| 44 | + | } |
|
| 45 | + | go app.poller(context.Background()) |
|
| 46 | + | ||
| 47 | + | addr := getenv("HOST", "0.0.0.0") + ":" + getenv("PORT", "3000") |
|
| 48 | + | logger.Info("feeds-go server running", "addr", addr) |
|
| 49 | + | if err := http.ListenAndServe(addr, app.routes()); err != nil { |
|
| 50 | + | log.Fatal(err) |
|
| 51 | + | } |
|
| 52 | + | } |
| 1 | + | package main |
|
| 2 | + | ||
| 3 | + | import ( |
|
| 4 | + | "net/http" |
|
| 5 | + | "strings" |
|
| 6 | + | ) |
|
| 7 | + | ||
| 8 | + | func (a *App) requireSession(next http.HandlerFunc) http.HandlerFunc { |
|
| 9 | + | return func(w http.ResponseWriter, r *http.Request) { |
|
| 10 | + | if !a.hasValidSession(r) { |
|
| 11 | + | http.Redirect(w, r, "/admin/login", http.StatusSeeOther) |
|
| 12 | + | return |
|
| 13 | + | } |
|
| 14 | + | next(w, r) |
|
| 15 | + | } |
|
| 16 | + | } |
|
| 17 | + | ||
| 18 | + | func (a *App) requireAPIAuth(next http.HandlerFunc) http.HandlerFunc { |
|
| 19 | + | return func(w http.ResponseWriter, r *http.Request) { |
|
| 20 | + | if a.APIKey != "" { |
|
| 21 | + | authz := r.Header.Get("Authorization") |
|
| 22 | + | if strings.HasPrefix(strings.ToLower(authz), "bearer ") && verifyAPIKey(strings.TrimSpace(authz[7:]), a.APIKey) { |
|
| 23 | + | next(w, r) |
|
| 24 | + | return |
|
| 25 | + | } |
|
| 26 | + | } |
|
| 27 | + | if a.hasValidSession(r) { |
|
| 28 | + | next(w, r) |
|
| 29 | + | return |
|
| 30 | + | } |
|
| 31 | + | writeJSON(w, http.StatusUnauthorized, map[string]any{"error": "unauthorized"}) |
|
| 32 | + | } |
|
| 33 | + | } |
|
| 34 | + | ||
| 35 | + | func (a *App) hasValidSession(r *http.Request) bool { |
|
| 36 | + | cookie, err := r.Cookie("feeds_session") |
|
| 37 | + | if err != nil || cookie.Value == "" { |
|
| 38 | + | return false |
|
| 39 | + | } |
|
| 40 | + | return isValidSession(a.DB, cookie.Value) |
|
| 41 | + | } |
|
| 42 | + | ||
| 43 | + | func (a *App) sessionCookie(token string) *http.Cookie { |
|
| 44 | + | return &http.Cookie{Name: "feeds_session", Value: token, Path: "/", HttpOnly: true, Secure: a.CookieSecure, SameSite: http.SameSiteLaxMode, MaxAge: 7 * 24 * 60 * 60} |
|
| 45 | + | } |
|
| 46 | + | ||
| 47 | + | func (a *App) withCORS(next http.HandlerFunc) http.HandlerFunc { |
|
| 48 | + | return func(w http.ResponseWriter, r *http.Request) { |
|
| 49 | + | w.Header().Set("Access-Control-Allow-Origin", "*") |
|
| 50 | + | w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS") |
|
| 51 | + | w.Header().Set("Access-Control-Allow-Headers", "Authorization, Content-Type") |
|
| 52 | + | if r.Method == http.MethodOptions { |
|
| 53 | + | w.WriteHeader(http.StatusNoContent) |
|
| 54 | + | return |
|
| 55 | + | } |
|
| 56 | + | next(w, r) |
|
| 57 | + | } |
|
| 58 | + | } |
| 1 | + | package main |
|
| 2 | + | ||
| 3 | + | import "net/http" |
|
| 4 | + | ||
| 5 | + | func (a *App) routes() *http.ServeMux { |
|
| 6 | + | mux := http.NewServeMux() |
|
| 7 | + | ||
| 8 | + | mux.HandleFunc("GET /", a.indexHandler) |
|
| 9 | + | mux.HandleFunc("GET /feeds", a.feedsExportHandler) |
|
| 10 | + | mux.HandleFunc("GET /feed.xml", a.atomFeedHandler) |
|
| 11 | + | mux.HandleFunc("GET /static/", a.embeddedHandler("static")) |
|
| 12 | + | mux.HandleFunc("GET /assets/", a.embeddedHandler("assets")) |
|
| 13 | + | ||
| 14 | + | mux.HandleFunc("GET /admin/login", a.loginGetHandler) |
|
| 15 | + | mux.HandleFunc("POST /admin/login", a.loginPostHandler) |
|
| 16 | + | mux.HandleFunc("GET /admin/logout", a.logoutHandler) |
|
| 17 | + | mux.HandleFunc("GET /admin", a.requireSession(a.adminHandler)) |
|
| 18 | + | mux.HandleFunc("POST /admin/add-feed", a.requireSession(a.addFeedHandler)) |
|
| 19 | + | mux.HandleFunc("POST /admin/feeds/{id}/delete", a.requireSession(a.deleteFeedHandler)) |
|
| 20 | + | mux.HandleFunc("POST /admin/feeds/{id}/category", a.requireSession(a.updateSubCategoryHandler)) |
|
| 21 | + | mux.HandleFunc("POST /admin/categories", a.requireSession(a.addCategoryHandler)) |
|
| 22 | + | mux.HandleFunc("POST /admin/categories/{id}/delete", a.requireSession(a.deleteCategoryHandler)) |
|
| 23 | + | mux.HandleFunc("POST /admin/import-opml", a.requireSession(a.importOPMLHandler)) |
|
| 24 | + | mux.HandleFunc("POST /admin/settings", a.requireSession(a.updateSettingsFormHandler)) |
|
| 25 | + | mux.HandleFunc("POST /admin/discover-feeds", a.requireSession(a.discoverFeedsHandler)) |
|
| 26 | + | ||
| 27 | + | mux.HandleFunc("GET /api/items", a.withCORS(a.listItemsAPI)) |
|
| 28 | + | mux.HandleFunc("POST /api/items/{id}/read", a.withCORS(a.requireAPIAuth(a.markItemReadAPI(true)))) |
|
| 29 | + | mux.HandleFunc("POST /api/items/{id}/unread", a.withCORS(a.requireAPIAuth(a.markItemReadAPI(false)))) |
|
| 30 | + | mux.HandleFunc("GET /api/subscriptions", a.withCORS(a.listSubscriptionsAPI)) |
|
| 31 | + | mux.HandleFunc("POST /api/subscriptions", a.withCORS(a.requireAPIAuth(a.createSubscriptionAPI))) |
|
| 32 | + | mux.HandleFunc("PATCH /api/subscriptions/{id}", a.withCORS(a.requireAPIAuth(a.updateSubscriptionAPI))) |
|
| 33 | + | mux.HandleFunc("DELETE /api/subscriptions/{id}", a.withCORS(a.requireAPIAuth(a.deleteSubscriptionAPI))) |
|
| 34 | + | mux.HandleFunc("GET /api/categories", a.withCORS(a.listCategoriesAPI)) |
|
| 35 | + | mux.HandleFunc("POST /api/categories", a.withCORS(a.requireAPIAuth(a.createCategoryAPI))) |
|
| 36 | + | mux.HandleFunc("DELETE /api/categories/{id}", a.withCORS(a.requireAPIAuth(a.deleteCategoryAPI))) |
|
| 37 | + | mux.HandleFunc("POST /api/import/opml", a.withCORS(a.requireAPIAuth(a.importOPMLAPI))) |
|
| 38 | + | mux.HandleFunc("GET /api/settings", a.withCORS(a.getSettingsAPI)) |
|
| 39 | + | mux.HandleFunc("PUT /api/settings", a.withCORS(a.requireAPIAuth(a.updateSettingsAPI))) |
|
| 40 | + | mux.HandleFunc("POST /api/discover", a.withCORS(a.requireAPIAuth(a.discoverAPI))) |
|
| 41 | + | ||
| 42 | + | return mux |
|
| 43 | + | } |
Binary file — no preview.
Binary file — no preview.
Binary file — no preview.
Binary file — no preview.
Binary file — no preview.
Binary file — no preview.
Binary file — no preview.
Binary file — no preview.
| 1 | + | {"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"} |
| 1 | + | /* feeds — app-specific styles. |
|
| 2 | + | * Shared reset / tokens / components come from /assets/darkmatter.css. |
|
| 3 | + | */ |
|
| 4 | + | ||
| 5 | + | /* The logo wraps an h1 in feeds markup. */ |
|
| 6 | + | ||
| 7 | + | .logo h1 { |
|
| 8 | + | font-size: 28px; |
|
| 9 | + | font-weight: 700; |
|
| 10 | + | text-transform: uppercase; |
|
| 11 | + | } |
|
| 12 | + | ||
| 13 | + | .about { |
|
| 14 | + | display: flex; |
|
| 15 | + | flex-direction: column; |
|
| 16 | + | gap: 0.5rem; |
|
| 17 | + | font-size: 14px; |
|
| 18 | + | line-height: 1.25rem; |
|
| 19 | + | } |
|
| 20 | + | ||
| 21 | + | /* Feeds list */ |
|
| 22 | + | ||
| 23 | + | .feeds-list { |
|
| 24 | + | width: 100%; |
|
| 25 | + | display: flex; |
|
| 26 | + | flex-direction: column; |
|
| 27 | + | gap: 1.5rem; |
|
| 28 | + | } |
|
| 29 | + | ||
| 30 | + | .feed-item { |
|
| 31 | + | display: flex; |
|
| 32 | + | flex-direction: column; |
|
| 33 | + | gap: 0.5rem; |
|
| 34 | + | padding: 1rem 0; |
|
| 35 | + | border-bottom: 1px solid #333; |
|
| 36 | + | } |
|
| 37 | + | ||
| 38 | + | .feed-item:last-child { |
|
| 39 | + | border-bottom: none; |
|
| 40 | + | } |
|
| 41 | + | ||
| 42 | + | .feed-meta { |
|
| 43 | + | display: flex; |
|
| 44 | + | justify-content: space-between; |
|
| 45 | + | align-items: center; |
|
| 46 | + | font-size: 12px; |
|
| 47 | + | opacity: 0.5; |
|
| 48 | + | } |
|
| 49 | + | ||
| 50 | + | .feed-source { |
|
| 51 | + | font-weight: 700; |
|
| 52 | + | } |
|
| 53 | + | ||
| 54 | + | .feed-title { |
|
| 55 | + | font-size: 16px; |
|
| 56 | + | font-weight: 400; |
|
| 57 | + | line-height: 1.4; |
|
| 58 | + | } |
|
| 59 | + | ||
| 60 | + | .feed-title a { |
|
| 61 | + | text-decoration: none; |
|
| 62 | + | } |
|
| 63 | + | ||
| 64 | + | .feed-author { |
|
| 65 | + | font-size: 12px; |
|
| 66 | + | opacity: 0.5; |
|
| 67 | + | font-style: italic; |
|
| 68 | + | } |
|
| 69 | + | ||
| 70 | + | #feed-urls { |
|
| 71 | + | font-size: 12px; |
|
| 72 | + | opacity: 0.5; |
|
| 73 | + | } |
|
| 74 | + | ||
| 75 | + | .no-feeds, |
|
| 76 | + | #loading { |
|
| 77 | + | text-align: center; |
|
| 78 | + | opacity: 0.5; |
|
| 79 | + | padding: 2rem; |
|
| 80 | + | } |
|
| 81 | + | ||
| 82 | + | #error { |
|
| 83 | + | text-align: center; |
|
| 84 | + | padding: 2rem; |
|
| 85 | + | } |
|
| 86 | + | ||
| 87 | + | /* Admin forms */ |
|
| 88 | + | ||
| 89 | + | .admin-form { |
|
| 90 | + | display: flex; |
|
| 91 | + | flex-direction: column; |
|
| 92 | + | gap: 0.75rem; |
|
| 93 | + | width: 100%; |
|
| 94 | + | } |
|
| 95 | + | ||
| 96 | + | .admin-form h3 { |
|
| 97 | + | font-size: 14px; |
|
| 98 | + | font-weight: 400; |
|
| 99 | + | opacity: 0.5; |
|
| 100 | + | } |
|
| 101 | + | ||
| 102 | + | .admin-notice, |
|
| 103 | + | .hint { |
|
| 104 | + | font-size: 12px; |
|
| 105 | + | opacity: 0.5; |
|
| 106 | + | line-height: 1.4; |
|
| 107 | + | } |
|
| 108 | + | ||
| 109 | + | /* Discover panel */ |
|
| 110 | + | ||
| 111 | + | .discover-row { |
|
| 112 | + | display: flex; |
|
| 113 | + | gap: 0.5rem; |
|
| 114 | + | width: 100%; |
|
| 115 | + | } |
|
| 116 | + | ||
| 117 | + | .discover-row input { |
|
| 118 | + | flex: 1; |
|
| 119 | + | } |
|
| 120 | + | ||
| 121 | + | .discover-status { |
|
| 122 | + | font-size: 12px; |
|
| 123 | + | } |
|
| 124 | + | ||
| 125 | + | .discover-results { |
|
| 126 | + | display: flex; |
|
| 127 | + | flex-direction: column; |
|
| 128 | + | gap: 0.25rem; |
|
| 129 | + | width: 100%; |
|
| 130 | + | } |
|
| 131 | + | ||
| 132 | + | .discover-result-item { |
|
| 133 | + | background: #121113; |
|
| 134 | + | color: #ffffff; |
|
| 135 | + | border: 1px solid #333; |
|
| 136 | + | padding: 8px 10px; |
|
| 137 | + | font-size: 12px; |
|
| 138 | + | text-align: left; |
|
| 139 | + | cursor: pointer; |
|
| 140 | + | width: 100%; |
|
| 141 | + | white-space: nowrap; |
|
| 142 | + | overflow: hidden; |
|
| 143 | + | text-overflow: ellipsis; |
|
| 144 | + | opacity: 0.7; |
|
| 145 | + | border-radius: 0; |
|
| 146 | + | -webkit-appearance: none; |
|
| 147 | + | appearance: none; |
|
| 148 | + | } |
|
| 149 | + | ||
| 150 | + | .discover-result-item:hover { |
|
| 151 | + | border-color: #555; |
|
| 152 | + | opacity: 1; |
|
| 153 | + | } |
|
| 154 | + | ||
| 155 | + | .discover-result-item.active { |
|
| 156 | + | border-color: #ffffff; |
|
| 157 | + | opacity: 1; |
|
| 158 | + | } |
|
| 159 | + | ||
| 160 | + | /* Admin subs */ |
|
| 161 | + | ||
| 162 | + | .admin-subs { |
|
| 163 | + | width: 100%; |
|
| 164 | + | display: flex; |
|
| 165 | + | flex-direction: column; |
|
| 166 | + | gap: 1rem; |
|
| 167 | + | } |
|
| 168 | + | ||
| 169 | + | .admin-subs h3 { |
|
| 170 | + | font-size: 14px; |
|
| 171 | + | opacity: 0.5; |
|
| 172 | + | font-weight: 400; |
|
| 173 | + | } |
|
| 174 | + | ||
| 175 | + | .feed-item form.inline { |
|
| 176 | + | display: flex; |
|
| 177 | + | gap: 0.5rem; |
|
| 178 | + | align-items: center; |
|
| 179 | + | } |
|
| 180 | + | ||
| 181 | + | .feed-item form.inline input { |
|
| 182 | + | flex: 1; |
|
| 183 | + | } |
|
| 184 | + | ||
| 185 | + | /* Generic .danger on buttons (used in admin) */ |
|
| 186 | + | ||
| 187 | + | button.danger, |
|
| 188 | + | .btn.danger { |
|
| 189 | + | opacity: 0.5; |
|
| 190 | + | } |
|
| 191 | + | ||
| 192 | + | button.danger:hover, |
|
| 193 | + | .btn.danger:hover { |
|
| 194 | + | opacity: 0.3; |
|
| 195 | + | } |
|
| 196 | + | ||
| 197 | + | /* Category list (admin) */ |
|
| 198 | + | ||
| 199 | + | .category-list { |
|
| 200 | + | list-style: none; |
|
| 201 | + | margin-left: 0; |
|
| 202 | + | } |
|
| 203 | + | ||
| 204 | + | .category-list li { |
|
| 205 | + | display: flex; |
|
| 206 | + | justify-content: space-between; |
|
| 207 | + | align-items: center; |
|
| 208 | + | padding: 0.25rem 0; |
|
| 209 | + | } |
|
| 210 | + | ||
| 211 | + | @media (max-width: 480px) { |
|
| 212 | + | .feed-meta { |
|
| 213 | + | flex-direction: column; |
|
| 214 | + | align-items: flex-start; |
|
| 215 | + | gap: 0.25rem; |
|
| 216 | + | } |
|
| 217 | + | ||
| 218 | + | .feed-title { |
|
| 219 | + | font-size: 14px; |
|
| 220 | + | } |
|
| 221 | + | } |
| 1 | + | package main |
|
| 2 | + | ||
| 3 | + | import ( |
|
| 4 | + | "context" |
|
| 5 | + | "database/sql" |
|
| 6 | + | "fmt" |
|
| 7 | + | "io" |
|
| 8 | + | "net/http" |
|
| 9 | + | "strings" |
|
| 10 | + | "time" |
|
| 11 | + | ) |
|
| 12 | + | ||
| 13 | + | const errAlreadySubscribed = "already subscribed" |
|
| 14 | + | ||
| 15 | + | func (a *App) createSubscription(ctx context.Context, body createSubscriptionBody, background bool) (*Subscription, error) { |
|
| 16 | + | feedURL := strings.TrimSpace(body.FeedURL) |
|
| 17 | + | if feedURL == "" { |
|
| 18 | + | return nil, fmt.Errorf("feed_url required") |
|
| 19 | + | } |
|
| 20 | + | existing, err := getSubscriptionByURL(a.DB, feedURL) |
|
| 21 | + | if err != nil { |
|
| 22 | + | return nil, err |
|
| 23 | + | } |
|
| 24 | + | if existing != nil { |
|
| 25 | + | return nil, fmt.Errorf(errAlreadySubscribed) |
|
| 26 | + | } |
|
| 27 | + | categoryID, err := a.resolveCategory(body.CategoryID, body.CategoryName) |
|
| 28 | + | if err != nil { |
|
| 29 | + | return nil, err |
|
| 30 | + | } |
|
| 31 | + | title := firstNonEmpty(body.Title, feedURL) |
|
| 32 | + | if background { |
|
| 33 | + | return a.createSubscriptionInBackground(feedURL, title, body, categoryID) |
|
| 34 | + | } |
|
| 35 | + | return a.createSubscriptionImmediately(ctx, feedURL, title, body, categoryID) |
|
| 36 | + | } |
|
| 37 | + | ||
| 38 | + | func (a *App) createSubscriptionInBackground(feedURL, title string, body createSubscriptionBody, categoryID *int64) (*Subscription, error) { |
|
| 39 | + | sub, err := insertSubscription(a.DB, feedURL, title, nil, categoryID) |
|
| 40 | + | if err != nil { |
|
| 41 | + | return nil, err |
|
| 42 | + | } |
|
| 43 | + | go func(subID int64, originalTitle string) { |
|
| 44 | + | res, err := fetchFeed(context.Background(), feedURL, "", "") |
|
| 45 | + | if err != nil { |
|
| 46 | + | msg := err.Error() |
|
| 47 | + | _ = updateSubscriptionMeta(a.DB, subID, nil, nil, &msg) |
|
| 48 | + | return |
|
| 49 | + | } |
|
| 50 | + | resolvedTitle := firstNonEmpty(body.Title, res.Title, feedURL) |
|
| 51 | + | if resolvedTitle != originalTitle { |
|
| 52 | + | _ = updateSubscriptionTitle(a.DB, subID, resolvedTitle) |
|
| 53 | + | } |
|
| 54 | + | if res.SiteURL != "" { |
|
| 55 | + | _ = updateSubscriptionSiteURL(a.DB, subID, &res.SiteURL) |
|
| 56 | + | if fav := discoverFavicon(context.Background(), res.SiteURL); fav != "" { |
|
| 57 | + | _ = updateSubscriptionFavicon(a.DB, subID, &fav) |
|
| 58 | + | } |
|
| 59 | + | } |
|
| 60 | + | _ = a.seedSubscription(subID, res) |
|
| 61 | + | }(sub.ID, sub.Title) |
|
| 62 | + | return sub, nil |
|
| 63 | + | } |
|
| 64 | + | ||
| 65 | + | func (a *App) createSubscriptionImmediately(ctx context.Context, feedURL, title string, body createSubscriptionBody, categoryID *int64) (*Subscription, error) { |
|
| 66 | + | res, err := fetchFeed(ctx, feedURL, "", "") |
|
| 67 | + | if err != nil { |
|
| 68 | + | return nil, fmt.Errorf("feed not reachable: %w", err) |
|
| 69 | + | } |
|
| 70 | + | resolvedTitle := firstNonEmpty(body.Title, res.Title, feedURL) |
|
| 71 | + | siteURL := stringPtr(res.SiteURL) |
|
| 72 | + | sub, err := insertSubscription(a.DB, feedURL, resolvedTitle, siteURL, categoryID) |
|
| 73 | + | if err != nil { |
|
| 74 | + | return nil, err |
|
| 75 | + | } |
|
| 76 | + | if res.SiteURL != "" { |
|
| 77 | + | if fav := discoverFavicon(ctx, res.SiteURL); fav != "" { |
|
| 78 | + | _ = updateSubscriptionFavicon(a.DB, sub.ID, &fav) |
|
| 79 | + | sub.FaviconURL = sql.NullString{String: fav, Valid: true} |
|
| 80 | + | } |
|
| 81 | + | } |
|
| 82 | + | if err := a.seedSubscription(sub.ID, res); err != nil { |
|
| 83 | + | return nil, err |
|
| 84 | + | } |
|
| 85 | + | return getSubscription(a.DB, sub.ID) |
|
| 86 | + | } |
|
| 87 | + | ||
| 88 | + | func (a *App) seedSubscription(subID int64, res *FetchResult) error { |
|
| 89 | + | for _, entry := range res.Entries { |
|
| 90 | + | _, err := insertItemIgnoreDup(a.DB, NewItem{SubscriptionID: subID, GUID: entry.GUID, Title: entry.Title, Link: entry.Link, Author: entry.Author, PublishedAt: entry.PublishedAt}) |
|
| 91 | + | if err != nil { |
|
| 92 | + | a.Log.Warn("seed insert failed", "sub_id", subID, "err", err) |
|
| 93 | + | } |
|
| 94 | + | } |
|
| 95 | + | if err := pruneSubscription(a.DB, subID, a.ItemCap); err != nil { |
|
| 96 | + | return err |
|
| 97 | + | } |
|
| 98 | + | return updateSubscriptionMeta(a.DB, subID, stringPtr(res.ETag), stringPtr(res.LastModified), nil) |
|
| 99 | + | } |
|
| 100 | + | ||
| 101 | + | func (a *App) resolveCategory(id *int64, name string) (*int64, error) { |
|
| 102 | + | if id != nil { |
|
| 103 | + | return id, nil |
|
| 104 | + | } |
|
| 105 | + | if strings.TrimSpace(name) == "" { |
|
| 106 | + | return nil, nil |
|
| 107 | + | } |
|
| 108 | + | cat, err := getOrCreateCategory(a.DB, name) |
|
| 109 | + | if err != nil || cat == nil { |
|
| 110 | + | return nil, err |
|
| 111 | + | } |
|
| 112 | + | return &cat.ID, nil |
|
| 113 | + | } |
|
| 114 | + | ||
| 115 | + | func (a *App) resolveSubscriptionCategory(body updateSubscriptionBody) (*int64, error) { |
|
| 116 | + | if body.ClearCategory { |
|
| 117 | + | return nil, nil |
|
| 118 | + | } |
|
| 119 | + | return a.resolveCategory(body.CategoryID, body.CategoryName) |
|
| 120 | + | } |
|
| 121 | + | ||
| 122 | + | func (a *App) readAndImportOPML(r *http.Request) (*importSummary, error) { |
|
| 123 | + | if err := r.ParseMultipartForm(8 << 20); err != nil { |
|
| 124 | + | return nil, err |
|
| 125 | + | } |
|
| 126 | + | file, _, err := r.FormFile("file") |
|
| 127 | + | if err != nil { |
|
| 128 | + | return nil, err |
|
| 129 | + | } |
|
| 130 | + | defer file.Close() |
|
| 131 | + | body, err := io.ReadAll(file) |
|
| 132 | + | if err != nil { |
|
| 133 | + | return nil, err |
|
| 134 | + | } |
|
| 135 | + | return a.importOPMLString(r.Context(), string(body)), nil |
|
| 136 | + | } |
|
| 137 | + | ||
| 138 | + | func (a *App) importOPMLString(ctx context.Context, content string) *importSummary { |
|
| 139 | + | entries := parseOPML(content) |
|
| 140 | + | summary := &importSummary{} |
|
| 141 | + | for _, entry := range entries { |
|
| 142 | + | existing, _ := getSubscriptionByURL(a.DB, entry.XMLURL) |
|
| 143 | + | if existing != nil { |
|
| 144 | + | summary.Skipped++ |
|
| 145 | + | continue |
|
| 146 | + | } |
|
| 147 | + | body := createSubscriptionBody{FeedURL: entry.XMLURL, Title: entry.Title, CategoryName: entry.Category} |
|
| 148 | + | if _, err := a.createSubscription(ctx, body, true); err != nil { |
|
| 149 | + | summary.Failed = append(summary.Failed, fmt.Sprintf("%s: %v", entry.XMLURL, err)) |
|
| 150 | + | continue |
|
| 151 | + | } |
|
| 152 | + | summary.Imported++ |
|
| 153 | + | } |
|
| 154 | + | return summary |
|
| 155 | + | } |
|
| 156 | + | ||
| 157 | + | func (a *App) poller(ctx context.Context) { |
|
| 158 | + | time.Sleep(3 * time.Second) |
|
| 159 | + | for { |
|
| 160 | + | mins := a.pollIntervalMinutes() |
|
| 161 | + | a.Log.Info("poller pass starting", "interval_minutes", mins) |
|
| 162 | + | subs, err := listSubscriptions(a.DB) |
|
| 163 | + | if err == nil { |
|
| 164 | + | for _, sub := range subs { |
|
| 165 | + | if err := a.pollOne(ctx, sub); err != nil { |
|
| 166 | + | msg := err.Error() |
|
| 167 | + | _ = updateSubscriptionMeta(a.DB, sub.ID, nullStringPointer(sub.ETag), nullStringPointer(sub.LastModified), &msg) |
|
| 168 | + | a.Log.Warn("feed poll failed", "feed_url", sub.FeedURL, "err", err) |
|
| 169 | + | } |
|
| 170 | + | } |
|
| 171 | + | } |
|
| 172 | + | time.Sleep(time.Duration(mins) * time.Minute) |
|
| 173 | + | } |
|
| 174 | + | } |
|
| 175 | + | ||
| 176 | + | func (a *App) pollOne(ctx context.Context, sub Subscription) error { |
|
| 177 | + | res, err := fetchFeed(ctx, sub.FeedURL, nullStringValue(sub.ETag), nullStringValue(sub.LastModified)) |
|
| 178 | + | if err != nil { |
|
| 179 | + | return err |
|
| 180 | + | } |
|
| 181 | + | inserted := 0 |
|
| 182 | + | if res.Status != http.StatusNotModified { |
|
| 183 | + | for _, entry := range res.Entries { |
|
| 184 | + | ok, err := insertItemIgnoreDup(a.DB, NewItem{SubscriptionID: sub.ID, GUID: entry.GUID, Title: entry.Title, Link: entry.Link, Author: entry.Author, PublishedAt: entry.PublishedAt}) |
|
| 185 | + | if err != nil { |
|
| 186 | + | a.Log.Warn("insert item failed", "feed_url", sub.FeedURL, "err", err) |
|
| 187 | + | continue |
|
| 188 | + | } |
|
| 189 | + | if ok { |
|
| 190 | + | inserted++ |
|
| 191 | + | } |
|
| 192 | + | } |
|
| 193 | + | if res.Title != "" && sub.Title == sub.FeedURL && res.Title != sub.Title { |
|
| 194 | + | _ = updateSubscriptionTitle(a.DB, sub.ID, res.Title) |
|
| 195 | + | } |
|
| 196 | + | if err := pruneSubscription(a.DB, sub.ID, a.ItemCap); err != nil { |
|
| 197 | + | return err |
|
| 198 | + | } |
|
| 199 | + | } |
|
| 200 | + | if err := updateSubscriptionMeta(a.DB, sub.ID, stringPtr(res.ETag), stringPtr(res.LastModified), nil); err != nil { |
|
| 201 | + | return err |
|
| 202 | + | } |
|
| 203 | + | a.Log.Info("feed polled", "feed_url", sub.FeedURL, "status", res.Status, "new_items", inserted, "entries", len(res.Entries)) |
|
| 204 | + | return nil |
|
| 205 | + | } |
|
| 206 | + | ||
| 207 | + | func (a *App) pollIntervalMinutes() int { |
|
| 208 | + | if value, ok, err := getSetting(a.DB, "poll_interval_minutes"); err == nil && ok { |
|
| 209 | + | if mins, err := parsePositiveInt(value); err == nil { |
|
| 210 | + | return mins |
|
| 211 | + | } |
|
| 212 | + | } |
|
| 213 | + | return a.DefaultPollMinutes |
|
| 214 | + | } |
|
| 215 | + | ||
| 216 | + | func isAlreadySubscribedError(err error) bool { |
|
| 217 | + | return err != nil && strings.Contains(err.Error(), errAlreadySubscribed) |
|
| 218 | + | } |
| 1 | + | <!doctype html> |
|
| 2 | + | <html lang="en"> |
|
| 3 | + | <head> |
|
| 4 | + | <meta charset="UTF-8" /> |
|
| 5 | + | <meta name="viewport" content="width=device-width, initial-scale=1.0" /> |
|
| 6 | + | <meta name="theme-color" content="#121113" /> |
|
| 7 | + | <link rel="stylesheet" href="/assets/darkmatter.css" /> |
|
| 8 | + | <link rel="stylesheet" href="/static/styles.css" /> |
|
| 9 | + | <link rel="apple-touch-icon" sizes="180x180" href="/static/apple-touch-icon.png"> |
|
| 10 | + | <link rel="icon" type="image/png" sizes="32x32" href="/static/favicon-32x32.png"> |
|
| 11 | + | <link rel="icon" type="image/png" sizes="16x16" href="/static/favicon-16x16.png"> |
|
| 12 | + | <link rel="manifest" href="/static/site.webmanifest"> |
|
| 13 | + | <title>Feeds | Admin</title> |
|
| 14 | + | </head> |
|
| 15 | + | <body> |
|
| 16 | + | <div class="header"> |
|
| 17 | + | <a href="/" class="logo"><h1>FEEDS</h1></a> |
|
| 18 | + | <nav class="links"><a href="/feeds?format=opml">opml</a><a href="/admin/logout">logout</a></nav> |
|
| 19 | + | </div> |
|
| 20 | + | ||
| 21 | + | {{if .Success}}<p class="success">{{.Success}}</p>{{end}} |
|
| 22 | + | {{if .Error}}<p class="error">{{.Error}}</p>{{end}} |
|
| 23 | + | ||
| 24 | + | <section class="admin-form"> |
|
| 25 | + | <h3>Discover</h3> |
|
| 26 | + | <div class="discover-row"> |
|
| 27 | + | <input type="url" id="base_url" placeholder="https://example.com" /> |
|
| 28 | + | <button type="button" id="discover-btn" onclick="discoverFeeds()">Discover</button> |
|
| 29 | + | </div> |
|
| 30 | + | <div id="discover-status" class="discover-status" style="display:none;"></div> |
|
| 31 | + | <div id="discover-results" class="discover-results" style="display:none;"></div> |
|
| 32 | + | </section> |
|
| 33 | + | ||
| 34 | + | <form class="admin-form" id="add-feed-form" method="POST" action="/admin/add-feed"> |
|
| 35 | + | <h3>Add Feed</h3> |
|
| 36 | + | <label for="feed_url">Feed URL</label> |
|
| 37 | + | <input type="url" id="feed_url" name="feed_url" placeholder="https://example.com/feed.xml" required /> |
|
| 38 | + | <label for="category_name">Category (optional)</label> |
|
| 39 | + | <input type="text" id="category_name" name="category_name" placeholder="Tech" list="categories-list" /> |
|
| 40 | + | <datalist id="categories-list">{{range .Categories}}<option value="{{.Name}}"></option>{{end}}</datalist> |
|
| 41 | + | <button type="submit" id="add-feed-submit"><span id="add-feed-label">Add Feed</span></button> |
|
| 42 | + | </form> |
|
| 43 | + | ||
| 44 | + | <form class="admin-form" id="opml-form" method="POST" action="/admin/import-opml" enctype="multipart/form-data"> |
|
| 45 | + | <h3>Import OPML</h3> |
|
| 46 | + | <input type="file" name="file" accept=".opml,.xml,application/xml,text/xml" required /> |
|
| 47 | + | <button type="submit" id="opml-submit"><span id="opml-submit-label">Import</span></button> |
|
| 48 | + | </form> |
|
| 49 | + | ||
| 50 | + | <form class="admin-form" method="POST" action="/admin/settings"> |
|
| 51 | + | <h3>Settings</h3> |
|
| 52 | + | <label for="poll_interval_minutes">Poll interval (minutes)</label> |
|
| 53 | + | <input type="number" id="poll_interval_minutes" name="poll_interval_minutes" min="1" max="1440" value="{{.PollIntervalMinutes}}" required /> |
|
| 54 | + | <p class="hint">Item cap per feed: {{.ItemCap}} (set via ITEM_CAP_PER_FEED)</p> |
|
| 55 | + | <p class="hint">API key: {{if .APIKeyConfigured}}configured{{else}}not set{{end}}</p> |
|
| 56 | + | <button type="submit">Save</button> |
|
| 57 | + | </form> |
|
| 58 | + | ||
| 59 | + | <section class="admin-subs"> |
|
| 60 | + | <h3>Categories ({{len .Categories}})</h3> |
|
| 61 | + | <form class="admin-form inline" method="POST" action="/admin/categories"> |
|
| 62 | + | <input type="text" name="name" placeholder="New category" required /> |
|
| 63 | + | <button type="submit">Add</button> |
|
| 64 | + | </form> |
|
| 65 | + | <ul class="category-list"> |
|
| 66 | + | {{range .Categories}} |
|
| 67 | + | <li> |
|
| 68 | + | <span>{{.Name}}</span> |
|
| 69 | + | <form method="POST" action="/admin/categories/{{.ID}}/delete" class="inline"><button type="submit" class="danger">Delete</button></form> |
|
| 70 | + | </li> |
|
| 71 | + | {{end}} |
|
| 72 | + | </ul> |
|
| 73 | + | </section> |
|
| 74 | + | ||
| 75 | + | <section class="admin-subs"> |
|
| 76 | + | <h3>Subscriptions ({{len .Subscriptions}})</h3> |
|
| 77 | + | <div class="feeds-list"> |
|
| 78 | + | {{range .Subscriptions}} |
|
| 79 | + | <div class="feed-item"> |
|
| 80 | + | <h3 class="feed-title"><a href="{{.SiteURL}}" target="_blank" rel="noopener noreferrer">{{.Title}}</a></h3> |
|
| 81 | + | {{if .LastFetchedAt}}<p class="feed-meta"><span class="feed-date">last: {{.LastFetchedAt}}</span>{{if .LastError}} <span class="error">· {{.LastError}}</span>{{end}}</p>{{end}} |
|
| 82 | + | <form method="POST" action="/admin/feeds/{{.ID}}/category" class="inline"> |
|
| 83 | + | <input type="text" name="category_name" placeholder="category" list="categories-list" value="{{.CategoryName}}" /> |
|
| 84 | + | <button type="submit">Save</button> |
|
| 85 | + | </form> |
|
| 86 | + | <form method="POST" action="/admin/feeds/{{.ID}}/delete" class="inline"><button type="submit" class="danger">Delete</button></form> |
|
| 87 | + | </div> |
|
| 88 | + | {{end}} |
|
| 89 | + | </div> |
|
| 90 | + | </section> |
|
| 91 | + | ||
| 92 | + | <script> |
|
| 93 | + | (function() { |
|
| 94 | + | const form = document.getElementById('add-feed-form'); |
|
| 95 | + | if (!form) return; |
|
| 96 | + | form.addEventListener('submit', function() { |
|
| 97 | + | const btn = document.getElementById('add-feed-submit'); |
|
| 98 | + | const label = document.getElementById('add-feed-label'); |
|
| 99 | + | btn.disabled = true; |
|
| 100 | + | btn.classList.add('loading'); |
|
| 101 | + | label.innerHTML = 'Adding <span class="spinner"></span>'; |
|
| 102 | + | }); |
|
| 103 | + | })(); |
|
| 104 | + | ||
| 105 | + | (function() { |
|
| 106 | + | const form = document.getElementById('opml-form'); |
|
| 107 | + | if (!form) return; |
|
| 108 | + | form.addEventListener('submit', function() { |
|
| 109 | + | const btn = document.getElementById('opml-submit'); |
|
| 110 | + | const label = document.getElementById('opml-submit-label'); |
|
| 111 | + | btn.disabled = true; |
|
| 112 | + | btn.classList.add('loading'); |
|
| 113 | + | label.innerHTML = 'Importing <span class="spinner"></span>'; |
|
| 114 | + | }); |
|
| 115 | + | })(); |
|
| 116 | + | ||
| 117 | + | async function discoverFeeds() { |
|
| 118 | + | const baseUrl = document.getElementById('base_url').value.trim(); |
|
| 119 | + | if (!baseUrl) return; |
|
| 120 | + | const btn = document.getElementById('discover-btn'); |
|
| 121 | + | const status = document.getElementById('discover-status'); |
|
| 122 | + | const results = document.getElementById('discover-results'); |
|
| 123 | + | const feedInput = document.getElementById('feed_url'); |
|
| 124 | + | btn.disabled = true; |
|
| 125 | + | btn.textContent = 'Searching...'; |
|
| 126 | + | status.style.display = 'none'; |
|
| 127 | + | results.style.display = 'none'; |
|
| 128 | + | results.innerHTML = ''; |
|
| 129 | + | try { |
|
| 130 | + | const body = new URLSearchParams({ base_url: baseUrl }); |
|
| 131 | + | const resp = await fetch('/admin/discover-feeds', { method: 'POST', body }); |
|
| 132 | + | const data = await resp.json(); |
|
| 133 | + | if (!resp.ok) { |
|
| 134 | + | status.textContent = data.error || 'No feeds found'; |
|
| 135 | + | status.className = 'discover-status error'; |
|
| 136 | + | status.style.display = 'block'; |
|
| 137 | + | return; |
|
| 138 | + | } |
|
| 139 | + | feedInput.value = data[0]; |
|
| 140 | + | status.textContent = data.length + ' feed(s) found'; |
|
| 141 | + | status.className = 'discover-status success'; |
|
| 142 | + | status.style.display = 'block'; |
|
| 143 | + | if (data.length > 1) { |
|
| 144 | + | results.style.display = 'flex'; |
|
| 145 | + | data.forEach(function(url) { |
|
| 146 | + | const item = document.createElement('button'); |
|
| 147 | + | item.type = 'button'; |
|
| 148 | + | item.className = 'discover-result-item' + (url === data[0] ? ' active' : ''); |
|
| 149 | + | item.textContent = url; |
|
| 150 | + | item.onclick = function() { |
|
| 151 | + | feedInput.value = url; |
|
| 152 | + | results.querySelectorAll('.discover-result-item').forEach(function(el) { el.classList.remove('active'); }); |
|
| 153 | + | item.classList.add('active'); |
|
| 154 | + | }; |
|
| 155 | + | results.appendChild(item); |
|
| 156 | + | }); |
|
| 157 | + | } |
|
| 158 | + | } catch (e) { |
|
| 159 | + | status.textContent = 'Request failed'; |
|
| 160 | + | status.className = 'discover-status error'; |
|
| 161 | + | status.style.display = 'block'; |
|
| 162 | + | } finally { |
|
| 163 | + | btn.disabled = false; |
|
| 164 | + | btn.textContent = 'Discover'; |
|
| 165 | + | } |
|
| 166 | + | } |
|
| 167 | + | </script> |
|
| 168 | + | </body> |
|
| 169 | + | </html> |
| 1 | + | <!doctype html> |
|
| 2 | + | <html lang="en"> |
|
| 3 | + | <head> |
|
| 4 | + | <meta charset="UTF-8" /> |
|
| 5 | + | <meta name="viewport" content="width=device-width, initial-scale=1.0" /> |
|
| 6 | + | <meta name="theme-color" content="#121113" /> |
|
| 7 | + | <link rel="stylesheet" href="/assets/darkmatter.css" /> |
|
| 8 | + | <link rel="stylesheet" href="/static/styles.css" /> |
|
| 9 | + | <link rel="apple-touch-icon" sizes="180x180" href="/static/apple-touch-icon.png"> |
|
| 10 | + | <link rel="icon" type="image/png" sizes="32x32" href="/static/favicon-32x32.png"> |
|
| 11 | + | <link rel="icon" type="image/png" sizes="16x16" href="/static/favicon-16x16.png"> |
|
| 12 | + | <link rel="manifest" href="/static/site.webmanifest"> |
|
| 13 | + | <title>Feeds</title> |
|
| 14 | + | <meta name="description" content="Minimal RSS Reading"> |
|
| 15 | + | <meta property="og:url" content="{{.BaseURL}}"> |
|
| 16 | + | <meta property="og:type" content="website"> |
|
| 17 | + | <meta property="og:title" content="Feeds"> |
|
| 18 | + | <meta property="og:description" content="Minimal RSS Reading"> |
|
| 19 | + | <meta property="og:image" content="{{.BaseURL}}/static/og.png"> |
|
| 20 | + | </head> |
|
| 21 | + | <body> |
|
| 22 | + | <div class="header"> |
|
| 23 | + | <a href="/" class="logo"><h1>FEEDS</h1></a> |
|
| 24 | + | <nav class="links"><a href="/admin">add</a></nav> |
|
| 25 | + | </div> |
|
| 26 | + | ||
| 27 | + | {{if .FeedURLs}} |
|
| 28 | + | <div id="feed-urls"> |
|
| 29 | + | {{range .FeedURLs}}{{.}}<br>{{end}} |
|
| 30 | + | </div> |
|
| 31 | + | {{end}} |
|
| 32 | + | ||
| 33 | + | {{if .Error}} |
|
| 34 | + | <div id="error" class="error"><p>{{.Error}}</p></div> |
|
| 35 | + | {{else if not .Items}} |
|
| 36 | + | <p class="no-feeds">No feeds available</p> |
|
| 37 | + | {{else}} |
|
| 38 | + | <div id="feeds-container"> |
|
| 39 | + | <div class="feeds-list"> |
|
| 40 | + | {{range .Items}} |
|
| 41 | + | <article class="feed-item"> |
|
| 42 | + | <div class="feed-meta"><span class="feed-date">{{.FormattedDate}}</span></div> |
|
| 43 | + | <h3 class="feed-title"><a href="{{.Link}}" target="_blank" rel="noopener noreferrer">{{.Title}}</a></h3> |
|
| 44 | + | {{if .Author}}<p class="feed-author">{{.Author}}</p>{{end}} |
|
| 45 | + | </article> |
|
| 46 | + | {{end}} |
|
| 47 | + | </div> |
|
| 48 | + | </div> |
|
| 49 | + | {{end}} |
|
| 50 | + | </body> |
|
| 51 | + | </html> |
| 1 | + | <!doctype html> |
|
| 2 | + | <html lang="en"> |
|
| 3 | + | <head> |
|
| 4 | + | <meta charset="UTF-8" /> |
|
| 5 | + | <meta name="viewport" content="width=device-width, initial-scale=1.0" /> |
|
| 6 | + | <meta name="theme-color" content="#121113" /> |
|
| 7 | + | <link rel="stylesheet" href="/assets/darkmatter.css" /> |
|
| 8 | + | <link rel="stylesheet" href="/static/styles.css" /> |
|
| 9 | + | <link rel="apple-touch-icon" sizes="180x180" href="/static/apple-touch-icon.png"> |
|
| 10 | + | <link rel="icon" type="image/png" sizes="32x32" href="/static/favicon-32x32.png"> |
|
| 11 | + | <link rel="icon" type="image/png" sizes="16x16" href="/static/favicon-16x16.png"> |
|
| 12 | + | <link rel="manifest" href="/static/site.webmanifest"> |
|
| 13 | + | <title>Feeds | Login</title> |
|
| 14 | + | </head> |
|
| 15 | + | <body> |
|
| 16 | + | <a href="/" class="header"><h1>FEEDS</h1></a> |
|
| 17 | + | {{if .Error}}<p class="error">{{.Error}}</p>{{end}} |
|
| 18 | + | <form class="admin-form" method="POST" action="/admin/login"> |
|
| 19 | + | <label for="password">Password</label> |
|
| 20 | + | <input type="password" id="password" name="password" required autofocus /> |
|
| 21 | + | <button type="submit">Login</button> |
|
| 22 | + | </form> |
|
| 23 | + | </body> |
|
| 24 | + | </html> |
| 1 | + | package main |
|
| 2 | + | ||
| 3 | + | import ( |
|
| 4 | + | "database/sql" |
|
| 5 | + | "fmt" |
|
| 6 | + | "net/http" |
|
| 7 | + | "os" |
|
| 8 | + | "strconv" |
|
| 9 | + | "strings" |
|
| 10 | + | "time" |
|
| 11 | + | ) |
|
| 12 | + | ||
| 13 | + | func pathInt64(r *http.Request, name string) (int64, bool) { |
|
| 14 | + | id, err := strconv.ParseInt(r.PathValue(name), 10, 64) |
|
| 15 | + | if err != nil || id <= 0 { |
|
| 16 | + | return 0, false |
|
| 17 | + | } |
|
| 18 | + | return id, true |
|
| 19 | + | } |
|
| 20 | + | ||
| 21 | + | func formatDate(ts int64) string { |
|
| 22 | + | if ts <= 0 { |
|
| 23 | + | return "" |
|
| 24 | + | } |
|
| 25 | + | return time.Unix(ts, 0).UTC().Format("Jan 2, 2006") |
|
| 26 | + | } |
|
| 27 | + | ||
| 28 | + | func getenv(key, fallback string) string { |
|
| 29 | + | if v := strings.TrimSpace(os.Getenv(key)); v != "" { |
|
| 30 | + | return v |
|
| 31 | + | } |
|
| 32 | + | return fallback |
|
| 33 | + | } |
|
| 34 | + | ||
| 35 | + | func getenvInt(key string, fallback int) int { |
|
| 36 | + | if v, err := strconv.Atoi(strings.TrimSpace(os.Getenv(key))); err == nil { |
|
| 37 | + | return v |
|
| 38 | + | } |
|
| 39 | + | return fallback |
|
| 40 | + | } |
|
| 41 | + | ||
| 42 | + | func parseIntDefault(s string, fallback int) int { |
|
| 43 | + | if v, err := strconv.Atoi(s); err == nil { |
|
| 44 | + | return v |
|
| 45 | + | } |
|
| 46 | + | return fallback |
|
| 47 | + | } |
|
| 48 | + | ||
| 49 | + | func parsePositiveInt(s string) (int, error) { |
|
| 50 | + | v, err := strconv.Atoi(strings.TrimSpace(s)) |
|
| 51 | + | if err != nil || v < 1 { |
|
| 52 | + | return 0, fmt.Errorf("invalid integer") |
|
| 53 | + | } |
|
| 54 | + | return v, nil |
|
| 55 | + | } |
|
| 56 | + | ||
| 57 | + | func validPollMinutes(v int) bool { |
|
| 58 | + | return v >= 1 && v <= 1440 |
|
| 59 | + | } |
|
| 60 | + | ||
| 61 | + | func formPollMinutes(r *http.Request) (int, bool) { |
|
| 62 | + | mins, err := strconv.Atoi(r.FormValue("poll_interval_minutes")) |
|
| 63 | + | return mins, err == nil && validPollMinutes(mins) |
|
| 64 | + | } |
|
| 65 | + | ||
| 66 | + | func itemFilterFromRequest(r *http.Request) ListItemsFilter { |
|
| 67 | + | filter := ListItemsFilter{Limit: parseIntDefault(r.URL.Query().Get("limit"), 100), UnreadOnly: r.URL.Query().Get("unread") == "true"} |
|
| 68 | + | if id, ok := queryInt64(r, "category_id"); ok { |
|
| 69 | + | filter.CategoryID = &id |
|
| 70 | + | } |
|
| 71 | + | if id, ok := queryInt64(r, "subscription_id"); ok { |
|
| 72 | + | filter.SubscriptionID = &id |
|
| 73 | + | } |
|
| 74 | + | return filter |
|
| 75 | + | } |
|
| 76 | + | ||
| 77 | + | func queryInt64(r *http.Request, key string) (int64, bool) { |
|
| 78 | + | v := strings.TrimSpace(r.URL.Query().Get(key)) |
|
| 79 | + | if v == "" { |
|
| 80 | + | return 0, false |
|
| 81 | + | } |
|
| 82 | + | id, err := strconv.ParseInt(v, 10, 64) |
|
| 83 | + | if err != nil { |
|
| 84 | + | return 0, false |
|
| 85 | + | } |
|
| 86 | + | return id, true |
|
| 87 | + | } |
|
| 88 | + | ||
| 89 | + | func splitAndTrim(s string) []string { |
|
| 90 | + | parts := strings.Split(s, ",") |
|
| 91 | + | out := []string{} |
|
| 92 | + | for _, part := range parts { |
|
| 93 | + | if trimmed := strings.TrimSpace(part); trimmed != "" { |
|
| 94 | + | out = append(out, trimmed) |
|
| 95 | + | } |
|
| 96 | + | } |
|
| 97 | + | return out |
|
| 98 | + | } |
|
| 99 | + | ||
| 100 | + | func nullStringValue(v sql.NullString) string { |
|
| 101 | + | if v.Valid { |
|
| 102 | + | return v.String |
|
| 103 | + | } |
|
| 104 | + | return "" |
|
| 105 | + | } |
|
| 106 | + | ||
| 107 | + | func nullStringPointer(v sql.NullString) *string { |
|
| 108 | + | if v.Valid { |
|
| 109 | + | return &v.String |
|
| 110 | + | } |
|
| 111 | + | return nil |
|
| 112 | + | } |
|
| 113 | + | ||
| 114 | + | func toSubscriptionView(s Subscription) subscriptionView { |
|
| 115 | + | return subscriptionView{ID: s.ID, FeedURL: s.FeedURL, Title: s.Title, SiteURL: nullStringPointer(s.SiteURL), FaviconURL: nullStringPointer(s.FaviconURL), CategoryID: func() *int64 { |
|
| 116 | + | if s.CategoryID.Valid { |
|
| 117 | + | return &s.CategoryID.Int64 |
|
| 118 | + | } |
|
| 119 | + | return nil |
|
| 120 | + | }(), ETag: nullStringPointer(s.ETag), LastModified: nullStringPointer(s.LastModified), LastFetchedAt: nullStringPointer(s.LastFetchedAt), LastError: nullStringPointer(s.LastError), AddedAt: s.AddedAt} |
|
| 121 | + | } |
|
| 122 | + | ||
| 123 | + | func loadDotEnv(path string) { |
|
| 124 | + | data, err := os.ReadFile(path) |
|
| 125 | + | if err != nil { |
|
| 126 | + | return |
|
| 127 | + | } |
|
| 128 | + | for _, line := range strings.Split(string(data), "\n") { |
|
| 129 | + | line = strings.TrimSpace(line) |
|
| 130 | + | if line == "" || strings.HasPrefix(line, "#") || !strings.Contains(line, "=") { |
|
| 131 | + | continue |
|
| 132 | + | } |
|
| 133 | + | parts := strings.SplitN(line, "=", 2) |
|
| 134 | + | key := strings.TrimSpace(parts[0]) |
|
| 135 | + | val := strings.Trim(strings.TrimSpace(parts[1]), `"'`) |
|
| 136 | + | if os.Getenv(key) == "" { |
|
| 137 | + | _ = os.Setenv(key, val) |
|
| 138 | + | } |
|
| 139 | + | } |
|
| 140 | + | } |
|
| 141 | + | ||
| 142 | + | func itoa(v int) string { |
|
| 143 | + | return strconv.Itoa(v) |
|
| 144 | + | } |
| 1 | + | package main |
|
| 2 | + | ||
| 3 | + | import ( |
|
| 4 | + | "encoding/json" |
|
| 5 | + | "mime" |
|
| 6 | + | "net/http" |
|
| 7 | + | "net/url" |
|
| 8 | + | "path/filepath" |
|
| 9 | + | "strings" |
|
| 10 | + | ||
| 11 | + | "golang.org/x/crypto/bcrypt" |
|
| 12 | + | ) |
|
| 13 | + | ||
| 14 | + | func (a *App) embeddedHandler(prefix string) http.HandlerFunc { |
|
| 15 | + | return func(w http.ResponseWriter, r *http.Request) { |
|
| 16 | + | name := strings.TrimPrefix(r.URL.Path, "/"+prefix+"/") |
|
| 17 | + | path := filepath.ToSlash(filepath.Join(prefix, name)) |
|
| 18 | + | data, err := appFS.ReadFile(path) |
|
| 19 | + | if err != nil { |
|
| 20 | + | http.NotFound(w, r) |
|
| 21 | + | return |
|
| 22 | + | } |
|
| 23 | + | if ct := mime.TypeByExtension(filepath.Ext(path)); ct != "" { |
|
| 24 | + | w.Header().Set("Content-Type", ct) |
|
| 25 | + | } |
|
| 26 | + | _, _ = w.Write(data) |
|
| 27 | + | } |
|
| 28 | + | } |
|
| 29 | + | ||
| 30 | + | func (a *App) render(w http.ResponseWriter, name string, data any) { |
|
| 31 | + | w.Header().Set("Content-Type", "text/html; charset=utf-8") |
|
| 32 | + | if err := a.Templates.ExecuteTemplate(w, name, data); err != nil { |
|
| 33 | + | a.Log.Error("template render failed", "name", name, "err", err) |
|
| 34 | + | http.Error(w, "template error", http.StatusInternalServerError) |
|
| 35 | + | } |
|
| 36 | + | } |
|
| 37 | + | ||
| 38 | + | func writeJSON(w http.ResponseWriter, status int, data any) { |
|
| 39 | + | w.Header().Set("Content-Type", "application/json") |
|
| 40 | + | w.WriteHeader(status) |
|
| 41 | + | _ = json.NewEncoder(w).Encode(data) |
|
| 42 | + | } |
|
| 43 | + | ||
| 44 | + | func decodeJSONBody(w http.ResponseWriter, r *http.Request, dst any) bool { |
|
| 45 | + | defer r.Body.Close() |
|
| 46 | + | if err := json.NewDecoder(r.Body).Decode(dst); err != nil { |
|
| 47 | + | writeJSON(w, http.StatusBadRequest, map[string]any{"error": "invalid JSON"}) |
|
| 48 | + | return false |
|
| 49 | + | } |
|
| 50 | + | return true |
|
| 51 | + | } |
|
| 52 | + | ||
| 53 | + | func verifyPassword(input, expected string) bool { |
|
| 54 | + | if strings.HasPrefix(expected, "$2") { |
|
| 55 | + | return bcrypt.CompareHashAndPassword([]byte(expected), []byte(input)) == nil |
|
| 56 | + | } |
|
| 57 | + | return input == expected |
|
| 58 | + | } |
|
| 59 | + | ||
| 60 | + | func verifyAPIKey(input, expected string) bool { return input == expected } |
|
| 61 | + | ||
| 62 | + | func redirectAdminError(w http.ResponseWriter, r *http.Request, msg string) { |
|
| 63 | + | http.Redirect(w, r, "/admin?error="+url.QueryEscape(msg), http.StatusSeeOther) |
|
| 64 | + | } |
|
| 65 | + | ||
| 66 | + | func redirectAdminSuccess(w http.ResponseWriter, r *http.Request, msg string) { |
|
| 67 | + | http.Redirect(w, r, "/admin?success="+url.QueryEscape(msg), http.StatusSeeOther) |
|
| 68 | + | } |