chore: init feeds rewrite cca232d1
Steve · 2026-05-15 20:52 35 file(s) · +3439 −0
apps/feeds-go/.env.example (added) +9 −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
apps/feeds-go/.gitignore (added) +2 −0
1 +
feeds-go.sqlite
2 +
.env
apps/feeds-go/Dockerfile (added) +14 −0
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"]
apps/feeds-go/README.md (added) +32 −0
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
apps/feeds-go/app.go (added) +106 −0
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 +
}
apps/feeds-go/assets/darkmatter.css (added) +648 −0
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 +
}
apps/feeds-go/assets/fonts/CommitMono-400-Regular.otf (added) +0 −0

Binary file — no preview.

apps/feeds-go/assets/fonts/CommitMono-700-Regular.otf (added) +0 −0

Binary file — no preview.

apps/feeds-go/db.go (added) +443 −0
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 +
}
apps/feeds-go/docker-compose.yml (added) +23 −0
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:
apps/feeds-go/feeds.go (added) +422 −0
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 +
}
apps/feeds-go/go.mod (added) +30 −0
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 +
)
apps/feeds-go/go.sum (added) +86 −0
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=
apps/feeds-go/handlers_admin.go (added) +191 −0
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 +
}
apps/feeds-go/handlers_api.go (added) +188 −0
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 +
}
apps/feeds-go/handlers_public.go (added) +196 −0
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 +
}
apps/feeds-go/main.go (added) +52 −0
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 +
}
apps/feeds-go/middleware.go (added) +58 −0
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 +
}
apps/feeds-go/routes.go (added) +43 −0
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 +
}
apps/feeds-go/static/android-chrome-192x192.png (added) +0 −0

Binary file — no preview.

apps/feeds-go/static/android-chrome-512x512.png (added) +0 −0

Binary file — no preview.

apps/feeds-go/static/apple-touch-icon.png (added) +0 −0

Binary file — no preview.

apps/feeds-go/static/favicon-16x16.png (added) +0 −0

Binary file — no preview.

apps/feeds-go/static/favicon-32x32.png (added) +0 −0

Binary file — no preview.

apps/feeds-go/static/favicon.ico (added) +0 −0

Binary file — no preview.

apps/feeds-go/static/icon.png (added) +0 −0

Binary file — no preview.

apps/feeds-go/static/og.png (added) +0 −0

Binary file — no preview.

apps/feeds-go/static/site.webmanifest (added) +1 −0
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"}
apps/feeds-go/static/styles.css (added) +221 −0
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 +
}
apps/feeds-go/subscriptions.go (added) +218 −0
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 +
}
apps/feeds-go/templates/admin.html (added) +169 −0
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>
apps/feeds-go/templates/index.html (added) +51 −0
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>
apps/feeds-go/templates/login.html (added) +24 −0
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>
apps/feeds-go/util.go (added) +144 −0
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 +
}
apps/feeds-go/web.go (added) +68 −0
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 +
}