main pico / pkg / apps / feeds / api.go
Antonio Mika  ·  2026-10-25
  1package feeds
  2
  3import (
  4	"fmt"
  5	"net/http"
  6	"net/url"
  7	"time"
  8
  9	"github.com/picosh/pico/pkg/db/postgres"
 10	"github.com/picosh/pico/pkg/metrics"
 11	"github.com/picosh/pico/pkg/shared"
 12	"github.com/picosh/pico/pkg/shared/router"
 13	"github.com/prometheus/client_golang/prometheus"
 14	"github.com/prometheus/client_golang/prometheus/promhttp"
 15)
 16
 17func (web *webRouter) keepAliveHandler(w http.ResponseWriter, r *http.Request) {
 18	dbpool := web.db
 19	logger := web.cfg.Logger
 20	postID, _ := url.PathUnescape(r.PathValue("id"))
 21
 22	post, err := dbpool.FindPost(postID)
 23	if err != nil {
 24		logger.Error("post not found", "err", err)
 25		http.Error(w, "post not found", http.StatusNotFound)
 26		return
 27	}
 28
 29	user, err := dbpool.FindUser(post.UserID)
 30	if err != nil {
 31		logger.Error("user not found", "err", err)
 32		http.Error(w, "user not found", http.StatusNotFound)
 33		return
 34	}
 35	logger = shared.LoggerWithUser(logger, user)
 36	logger = logger.With("post", post.ID, "filename", post.Filename)
 37
 38	now := time.Now()
 39	expiresAt := now.AddDate(0, 3, 0)
 40	post.ExpiresAt = &expiresAt
 41	_, err = dbpool.UpdatePost(post)
 42	if err != nil {
 43		logger.Error("could not update post", "err", err.Error())
 44		http.Error(w, "server error", 500)
 45		return
 46	}
 47
 48	w.Header().Add("Content-Type", "text/plain")
 49
 50	logger.Info(
 51		"Success! This feed will stay active until %s or by clicking the link in your digest email again",
 52		"expiresAt", now,
 53	)
 54	txt := fmt.Sprintf(
 55		"Success! This feed will stay active until %s or by clicking the link in your digest email again",
 56		now,
 57	)
 58	_, err = w.Write([]byte(txt))
 59	if err != nil {
 60		logger.Error("could not write to writer", "err", err.Error())
 61		http.Error(w, "server error", 500)
 62	}
 63}
 64
 65func (web *webRouter) unsubHandler(w http.ResponseWriter, r *http.Request) {
 66	dbpool := web.db
 67	logger := web.cfg.Logger
 68	postID, _ := url.PathUnescape(r.PathValue("id"))
 69
 70	post, err := dbpool.FindPost(postID)
 71	if err != nil {
 72		logger.Error("post not found", "err", err)
 73		http.Error(w, "post not found", http.StatusNotFound)
 74		return
 75	}
 76
 77	user, err := dbpool.FindUser(post.UserID)
 78	if err != nil {
 79		logger.Error("user not found", "err", err)
 80		http.Error(w, "user not found", http.StatusNotFound)
 81		return
 82	}
 83	logger = shared.LoggerWithUser(logger, user)
 84	logger = logger.With("post", post.ID, "filename", post.Filename)
 85
 86	logger.Info("unsubscribe")
 87	err = dbpool.RemovePosts([]string{post.ID})
 88	if err != nil {
 89		logger.Error("could not remove post", "err", err)
 90		http.Error(w, "could not remove post", http.StatusInternalServerError)
 91		return
 92	}
 93
 94	txt := "Success! This feed digest post has been removed from our system."
 95	_, err = w.Write([]byte(txt))
 96	if err != nil {
 97		logger.Error("could not write to writer", "err", err)
 98		http.Error(w, "server error", 500)
 99	}
100}
101
102type webRouter struct {
103	cfg *Config
104	db  DB
105}
106
107func (web *webRouter) mux() *http.ServeMux {
108	cfg := web.cfg
109	files := cfg.Static()
110	mux := http.NewServeMux()
111	mux.HandleFunc("GET /{$}", files.Page("html/marketing.page.tmpl"))
112	mux.HandleFunc("GET /keep-alive/{id...}", web.keepAliveHandler)
113	mux.HandleFunc("GET /unsub/{id...}", web.unsubHandler)
114	mux.Handle("GET /_metrics", promhttp.Handler())
115	mux.HandleFunc("GET /main.css", files.File("main.css", "text/css"))
116	mux.HandleFunc("GET /card.png", files.File("card.png", "image/png"))
117	mux.HandleFunc("GET /favicon-16x16.png", files.File("favicon-16x16.png", "image/png"))
118	mux.HandleFunc("GET /apple-touch-icon.png", files.File("apple-touch-icon.png", "image/png"))
119	mux.HandleFunc("GET /favicon.ico", files.File("favicon.ico", "image/x-icon"))
120	mux.HandleFunc("GET /robots.txt", files.File("robots.txt", "text/plain"))
121	if cfg.Debug {
122		router.CreatePProfRoutesMux(mux)
123	}
124	return mux
125}
126
127func StartApiServer() {
128	cfg := NewConfig("feeds-web")
129	db := NewDB(postgres.NewDB(cfg.DbURL, cfg.Logger))
130	defer func() {
131		_ = db.Close()
132	}()
133	logger := cfg.Logger
134
135	// cron daily digest
136	fetcher := NewFetcher(db, cfg)
137	go fetcher.Loop()
138
139	web := &webRouter{cfg: cfg, db: db}
140
141	portStr := fmt.Sprintf(":%s", cfg.Port)
142	logger.Info(
143		"Starting server on port",
144		"port", cfg.Port,
145		"domain", cfg.Domain,
146	)
147
148	metrics.RegisterServerInfo(prometheus.DefaultRegisterer, "feeds")
149	logger.Error(http.ListenAndServe(portStr, web.mux()).Error())
150}