repos / pico

pico services mono repo
git clone https://github.com/picosh/pico.git

pico / pkg / apps / feeds
Eric Bower  ·  2026-01-25

api.go

  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 keepAliveHandler(w http.ResponseWriter, r *http.Request) {
 16	dbpool := router.GetDB(r)
 17	logger := router.GetLogger(r)
 18	postID, _ := url.PathUnescape(router.GetField(r, 0))
 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 unsubHandler(w http.ResponseWriter, r *http.Request) {
 64	dbpool := router.GetDB(r)
 65	logger := router.GetLogger(r)
 66	postID, _ := url.PathUnescape(router.GetField(r, 0))
 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
100func createMainRoutes(staticRoutes []router.Route) []router.Route {
101	routes := []router.Route{
102		router.NewRoute("GET", "/", router.CreatePageHandler("html/marketing.page.tmpl")),
103		router.NewRoute("GET", "/keep-alive/(.+)", keepAliveHandler),
104		router.NewRoute("GET", "/unsub/(.+)", unsubHandler),
105		router.NewRoute("GET", "/_metrics", promhttp.Handler().ServeHTTP),
106	}
107
108	routes = append(
109		routes,
110		staticRoutes...,
111	)
112
113	return routes
114}
115
116func createStaticRoutes() []router.Route {
117	return []router.Route{
118		router.NewRoute("GET", "/main.css", router.ServeFile("main.css", "text/css")),
119		router.NewRoute("GET", "/card.png", router.ServeFile("card.png", "image/png")),
120		router.NewRoute("GET", "/favicon-16x16.png", router.ServeFile("favicon-16x16.png", "image/png")),
121		router.NewRoute("GET", "/favicon-32x32.png", router.ServeFile("favicon-32x32.png", "image/png")),
122		router.NewRoute("GET", "/apple-touch-icon.png", router.ServeFile("apple-touch-icon.png", "image/png")),
123		router.NewRoute("GET", "/favicon.ico", router.ServeFile("favicon.ico", "image/x-icon")),
124		router.NewRoute("GET", "/robots.txt", router.ServeFile("robots.txt", "text/plain")),
125	}
126}
127
128func StartApiServer() {
129	cfg := NewConfigSite("feeds-web")
130	db := postgres.NewDB(cfg.DbURL, cfg.Logger)
131	defer func() {
132		_ = db.Close()
133	}()
134	logger := cfg.Logger
135
136	// cron daily digest
137	fetcher := NewFetcher(db, cfg)
138	go fetcher.Loop()
139
140	staticRoutes := createStaticRoutes()
141
142	if cfg.Debug {
143		staticRoutes = router.CreatePProfRoutes(staticRoutes)
144	}
145
146	mainRoutes := createMainRoutes(staticRoutes)
147
148	apiConfig := &router.ApiConfig{
149		Cfg:    cfg,
150		Dbpool: db,
151	}
152	handler := router.CreateServe(mainRoutes, []router.Route{}, apiConfig)
153	router := http.HandlerFunc(handler)
154
155	portStr := fmt.Sprintf(":%s", cfg.Port)
156	logger.Info(
157		"Starting server on port",
158		"port", cfg.Port,
159		"domain", cfg.Domain,
160	)
161
162	logger.Error(http.ListenAndServe(portStr, router).Error())
163}