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