chore: fixed template issue across go apps
8c324a1d
14 file(s) · +460 −63
| 13 | 13 | var appFS embed.FS |
|
| 14 | 14 | ||
| 15 | 15 | type App struct { |
|
| 16 | - | DB *sql.DB |
|
| 17 | - | Log *slog.Logger |
|
| 18 | - | Templates *template.Template |
|
| 19 | - | HTTP *http.Client |
|
| 20 | - | TZ *time.Location |
|
| 21 | - | TZName string |
|
| 22 | - | Classifications []string |
|
| 23 | - | ExcludeTerms []string |
|
| 24 | - | BackfillDays int |
|
| 25 | - | MaxDedupRetries int |
|
| 26 | - | BaseURL string |
|
| 16 | + | DB *sql.DB |
|
| 17 | + | Log *slog.Logger |
|
| 18 | + | Templates map[string]*template.Template |
|
| 19 | + | HTTP *http.Client |
|
| 20 | + | TZ *time.Location |
|
| 21 | + | TZName string |
|
| 22 | + | Classifications []string |
|
| 23 | + | ExcludeTerms []string |
|
| 24 | + | BackfillDays int |
|
| 25 | + | MaxDedupRetries int |
|
| 26 | + | BaseURL string |
|
| 27 | 27 | } |
|
| 28 | 28 | ||
| 29 | 29 | type artworkView struct { |
| 3 | 3 | import ( |
|
| 4 | 4 | "html/template" |
|
| 5 | 5 | "net/http" |
|
| 6 | - | ||
| 7 | - | "github.com/stevedylandev/andromeda/crates-go/web" |
|
| 8 | 6 | ) |
|
| 9 | 7 | ||
| 10 | 8 | func iiifURL(imageID string) string { |
|
| 61 | 59 | d, err := getDaily(a.DB, today) |
|
| 62 | 60 | if err != nil { |
|
| 63 | 61 | a.Log.Error("index db error", "err", err) |
|
| 64 | - | web.Render(a.Templates, w, "error.html", errorPageData{Title: "Error", Message: "Could not load today's artwork."}, a.Log) |
|
| 62 | + | a.renderPageStatus(w, http.StatusInternalServerError, "error.html", errorPageData{Title: "Error", Message: "Could not load today's artwork."}) |
|
| 65 | 63 | return |
|
| 66 | 64 | } |
|
| 67 | 65 | data := indexPageData{TodayDate: today} |
|
| 69 | 67 | v := toArtworkView(*d) |
|
| 70 | 68 | data.Artwork = &v |
|
| 71 | 69 | } |
|
| 72 | - | web.Render(a.Templates, w, "index.html", data, a.Log) |
|
| 70 | + | a.renderPage(w, "index.html", data) |
|
| 73 | 71 | } |
|
| 74 | 72 | ||
| 75 | 73 | func (a *App) dayHandler(w http.ResponseWriter, r *http.Request) { |
|
| 76 | 74 | date := r.PathValue("date") |
|
| 77 | 75 | if _, ok := parseDate(date); !ok { |
|
| 78 | - | w.WriteHeader(http.StatusBadRequest) |
|
| 79 | - | web.Render(a.Templates, w, "error.html", errorPageData{Title: "Invalid date", Message: "'" + date + "' is not a valid YYYY-MM-DD date."}, a.Log) |
|
| 76 | + | a.renderPageStatus(w, http.StatusBadRequest, "error.html", errorPageData{Title: "Invalid date", Message: "'" + date + "' is not a valid YYYY-MM-DD date."}) |
|
| 80 | 77 | return |
|
| 81 | 78 | } |
|
| 82 | 79 | today := a.todayInTZ() |
|
| 83 | 80 | if date > today { |
|
| 84 | - | w.WriteHeader(http.StatusNotFound) |
|
| 85 | - | web.Render(a.Templates, w, "error.html", errorPageData{Title: "Not yet", Message: date + " is in the future."}, a.Log) |
|
| 81 | + | a.renderPageStatus(w, http.StatusNotFound, "error.html", errorPageData{Title: "Not yet", Message: date + " is in the future."}) |
|
| 86 | 82 | return |
|
| 87 | 83 | } |
|
| 88 | 84 | d, err := getDaily(a.DB, date) |
|
| 89 | 85 | if err != nil { |
|
| 90 | 86 | a.Log.Error("day db error", "err", err) |
|
| 91 | - | w.WriteHeader(http.StatusInternalServerError) |
|
| 92 | - | web.Render(a.Templates, w, "error.html", errorPageData{Title: "Error", Message: "Database error."}, a.Log) |
|
| 87 | + | a.renderPageStatus(w, http.StatusInternalServerError, "error.html", errorPageData{Title: "Error", Message: "Database error."}) |
|
| 93 | 88 | return |
|
| 94 | 89 | } |
|
| 95 | 90 | if d == nil { |
|
| 96 | - | w.WriteHeader(http.StatusNotFound) |
|
| 97 | - | web.Render(a.Templates, w, "error.html", errorPageData{Title: "Not found", Message: "No artwork stored for " + date + "."}, a.Log) |
|
| 91 | + | a.renderPageStatus(w, http.StatusNotFound, "error.html", errorPageData{Title: "Not found", Message: "No artwork stored for " + date + "."}) |
|
| 98 | 92 | return |
|
| 99 | 93 | } |
|
| 100 | - | web.Render(a.Templates, w, "day.html", dayPageData{Date: date, Artwork: toArtworkView(*d)}, a.Log) |
|
| 94 | + | a.renderPage(w, "day.html", dayPageData{Date: date, Artwork: toArtworkView(*d)}) |
|
| 101 | 95 | } |
|
| 102 | 96 | ||
| 103 | 97 | func (a *App) archiveHandler(w http.ResponseWriter, r *http.Request) { |
|
| 110 | 104 | } |
|
| 111 | 105 | rows = append(rows, archiveRow{Date: it.Date, Title: it.Title, Artist: artist}) |
|
| 112 | 106 | } |
|
| 113 | - | web.Render(a.Templates, w, "archive.html", archivePageData{Archive: rows}, a.Log) |
|
| 107 | + | a.renderPage(w, "archive.html", archivePageData{Archive: rows}) |
|
| 114 | 108 | } |
|
| 2 | 2 | ||
| 3 | 3 | import ( |
|
| 4 | 4 | "context" |
|
| 5 | - | "html/template" |
|
| 6 | 5 | "log" |
|
| 7 | 6 | "log/slog" |
|
| 8 | 7 | "net/http" |
|
| 49 | 48 | } |
|
| 50 | 49 | excludeTerms := splitCommaTrim(config.Getenv("EASEL_EXCLUDE_TERMS", "erotic,erotica,shunga")) |
|
| 51 | 50 | ||
| 52 | - | tmpl := template.Must(template.ParseFS(appFS, "templates/*.html")) |
|
| 51 | + | tmpl, err := buildTemplates() |
|
| 52 | + | if err != nil { |
|
| 53 | + | log.Fatal(err) |
|
| 54 | + | } |
|
| 53 | 55 | app := &App{ |
|
| 54 | 56 | DB: db, |
|
| 55 | 57 | Log: logger, |
|
| 1 | + | package main |
|
| 2 | + | ||
| 3 | + | import ( |
|
| 4 | + | "bytes" |
|
| 5 | + | "fmt" |
|
| 6 | + | "html/template" |
|
| 7 | + | "io/fs" |
|
| 8 | + | "net/http" |
|
| 9 | + | "path" |
|
| 10 | + | "strings" |
|
| 11 | + | ) |
|
| 12 | + | ||
| 13 | + | func buildTemplates() (map[string]*template.Template, error) { |
|
| 14 | + | pages, err := fs.Glob(appFS, "templates/*.html") |
|
| 15 | + | if err != nil { |
|
| 16 | + | return nil, err |
|
| 17 | + | } |
|
| 18 | + | ||
| 19 | + | out := make(map[string]*template.Template, len(pages)) |
|
| 20 | + | for _, page := range pages { |
|
| 21 | + | if strings.HasSuffix(page, "/base.html") { |
|
| 22 | + | continue |
|
| 23 | + | } |
|
| 24 | + | tmpl, err := template.ParseFS(appFS, "templates/base.html", page) |
|
| 25 | + | if err != nil { |
|
| 26 | + | return nil, fmt.Errorf("parse %s: %w", page, err) |
|
| 27 | + | } |
|
| 28 | + | out[path.Base(page)] = tmpl |
|
| 29 | + | } |
|
| 30 | + | return out, nil |
|
| 31 | + | } |
|
| 32 | + | ||
| 33 | + | func (a *App) renderPage(w http.ResponseWriter, name string, data any) { |
|
| 34 | + | a.renderPageStatus(w, http.StatusOK, name, data) |
|
| 35 | + | } |
|
| 36 | + | ||
| 37 | + | func (a *App) renderPageStatus(w http.ResponseWriter, status int, name string, data any) { |
|
| 38 | + | tmpl, ok := a.Templates[name] |
|
| 39 | + | if !ok { |
|
| 40 | + | a.Log.Error("template missing", "name", name) |
|
| 41 | + | http.Error(w, "template missing", http.StatusInternalServerError) |
|
| 42 | + | return |
|
| 43 | + | } |
|
| 44 | + | ||
| 45 | + | var buf bytes.Buffer |
|
| 46 | + | if err := tmpl.ExecuteTemplate(&buf, name, data); err != nil { |
|
| 47 | + | a.Log.Error("template render failed", "name", name, "err", err) |
|
| 48 | + | http.Error(w, "template error", http.StatusInternalServerError) |
|
| 49 | + | return |
|
| 50 | + | } |
|
| 51 | + | ||
| 52 | + | w.Header().Set("Content-Type", "text/html; charset=utf-8") |
|
| 53 | + | w.WriteHeader(status) |
|
| 54 | + | _, _ = w.Write(buf.Bytes()) |
|
| 55 | + | } |
| 1 | + | package main |
|
| 2 | + | ||
| 3 | + | import ( |
|
| 4 | + | "database/sql" |
|
| 5 | + | "io" |
|
| 6 | + | "log/slog" |
|
| 7 | + | "net/http" |
|
| 8 | + | "net/http/httptest" |
|
| 9 | + | "strings" |
|
| 10 | + | "testing" |
|
| 11 | + | ||
| 12 | + | sharedsqlite "github.com/stevedylandev/andromeda/crates-go/sqlite" |
|
| 13 | + | ) |
|
| 14 | + | ||
| 15 | + | func newTestDB(t *testing.T) *sql.DB { |
|
| 16 | + | t.Helper() |
|
| 17 | + | db, err := sharedsqlite.Open("file::memory:?cache=shared", feedsSchema) |
|
| 18 | + | if err != nil { |
|
| 19 | + | t.Fatal(err) |
|
| 20 | + | } |
|
| 21 | + | t.Cleanup(func() { _ = db.Close() }) |
|
| 22 | + | return db |
|
| 23 | + | } |
|
| 24 | + | ||
| 25 | + | func newTestApp(t *testing.T) *App { |
|
| 26 | + | t.Helper() |
|
| 27 | + | return &App{ |
|
| 28 | + | DB: newTestDB(t), |
|
| 29 | + | Log: slog.New(slog.NewTextHandler(io.Discard, nil)), |
|
| 30 | + | DefaultPollMinutes: 30, |
|
| 31 | + | ItemCap: 2, |
|
| 32 | + | } |
|
| 33 | + | } |
|
| 34 | + | ||
| 35 | + | func seedSubscriptionForTest(t *testing.T, db *sql.DB, feedURL, title string, categoryID *int64) *Subscription { |
|
| 36 | + | t.Helper() |
|
| 37 | + | sub, err := insertSubscription(db, feedURL, title, nil, categoryID) |
|
| 38 | + | if err != nil { |
|
| 39 | + | t.Fatal(err) |
|
| 40 | + | } |
|
| 41 | + | return sub |
|
| 42 | + | } |
|
| 43 | + | ||
| 44 | + | func TestParseOPMLHandlesNestedCategories(t *testing.T) { |
|
| 45 | + | content := `<?xml version="1.0" encoding="UTF-8"?> |
|
| 46 | + | <opml version="2.0"> |
|
| 47 | + | <body> |
|
| 48 | + | <outline text="Tech"> |
|
| 49 | + | <outline text="Go Blog" xmlUrl="https://go.dev/feed.xml" htmlUrl="https://go.dev/blog/" /> |
|
| 50 | + | <outline text="News"> |
|
| 51 | + | <outline title="Hacker News" xmlUrl="https://hnrss.org/frontpage" htmlUrl="https://news.ycombinator.com/" /> |
|
| 52 | + | </outline> |
|
| 53 | + | </outline> |
|
| 54 | + | <outline text="Standalone" xmlUrl="https://example.com/rss.xml" /> |
|
| 55 | + | </body> |
|
| 56 | + | </opml>` |
|
| 57 | + | ||
| 58 | + | got := parseOPML(content) |
|
| 59 | + | if len(got) != 3 { |
|
| 60 | + | t.Fatalf("expected 3 entries, got %d", len(got)) |
|
| 61 | + | } |
|
| 62 | + | if got[0].Category != "Tech" || got[0].Title != "Go Blog" { |
|
| 63 | + | t.Fatalf("unexpected first entry: %+v", got[0]) |
|
| 64 | + | } |
|
| 65 | + | if got[1].Category != "News" || got[1].Title != "Hacker News" { |
|
| 66 | + | t.Fatalf("unexpected nested entry: %+v", got[1]) |
|
| 67 | + | } |
|
| 68 | + | if got[2].Category != "" || got[2].Title != "Standalone" { |
|
| 69 | + | t.Fatalf("unexpected standalone entry: %+v", got[2]) |
|
| 70 | + | } |
|
| 71 | + | } |
|
| 72 | + | ||
| 73 | + | func TestParseOPMLInvalidReturnsNil(t *testing.T) { |
|
| 74 | + | if got := parseOPML("<opml><body>"); got != nil { |
|
| 75 | + | t.Fatalf("expected nil for invalid OPML, got %+v", got) |
|
| 76 | + | } |
|
| 77 | + | } |
|
| 78 | + | ||
| 79 | + | func TestDeriveTitleFromHTMLStripsMarkupAndTruncates(t *testing.T) { |
|
| 80 | + | src := `<p>Hello <strong>world</strong> & friends.</p>` |
|
| 81 | + | if got := deriveTitleFromHTML(src); got != "Hello world & friends." { |
|
| 82 | + | t.Fatalf("unexpected title: %q", got) |
|
| 83 | + | } |
|
| 84 | + | ||
| 85 | + | long := strings.Repeat("word ", 30) |
|
| 86 | + | got := deriveTitleFromHTML("<div>" + long + "</div>") |
|
| 87 | + | if !strings.HasSuffix(got, "…") { |
|
| 88 | + | t.Fatalf("expected ellipsis, got %q", got) |
|
| 89 | + | } |
|
| 90 | + | } |
|
| 91 | + | ||
| 92 | + | func TestFindAlternateFeedLinksAndFavicon(t *testing.T) { |
|
| 93 | + | doc := ` |
|
| 94 | + | <html><head> |
|
| 95 | + | <link rel="alternate" type="application/rss+xml" href="/rss.xml"> |
|
| 96 | + | <link rel="icon" type="image/png" href="/favicon.png"> |
|
| 97 | + | <link rel="alternate stylesheet" type="application/atom+xml" href="https://example.com/atom.xml"> |
|
| 98 | + | </head></html>` |
|
| 99 | + | ||
| 100 | + | links := findAlternateFeedLinks(doc) |
|
| 101 | + | if len(links) != 2 { |
|
| 102 | + | t.Fatalf("expected 2 feed links, got %d (%v)", len(links), links) |
|
| 103 | + | } |
|
| 104 | + | if links[0] != "/rss.xml" || links[1] != "https://example.com/atom.xml" { |
|
| 105 | + | t.Fatalf("unexpected links: %v", links) |
|
| 106 | + | } |
|
| 107 | + | if href := findLinkHref(doc, func(rel, typ string) bool { return strings.Contains(strings.ToLower(rel), "icon") }); href != "/favicon.png" { |
|
| 108 | + | t.Fatalf("unexpected favicon href: %q", href) |
|
| 109 | + | } |
|
| 110 | + | } |
|
| 111 | + | ||
| 112 | + | func TestItemFilterFromRequestParsesValues(t *testing.T) { |
|
| 113 | + | req := httptest.NewRequest(http.MethodGet, "/?limit=25&unread=true&category_id=5&subscription_id=8", nil) |
|
| 114 | + | filter := itemFilterFromRequest(req) |
|
| 115 | + | if filter.Limit != 25 || !filter.UnreadOnly { |
|
| 116 | + | t.Fatalf("unexpected base filter: %+v", filter) |
|
| 117 | + | } |
|
| 118 | + | if filter.CategoryID == nil || *filter.CategoryID != 5 { |
|
| 119 | + | t.Fatalf("unexpected category id: %+v", filter.CategoryID) |
|
| 120 | + | } |
|
| 121 | + | if filter.SubscriptionID == nil || *filter.SubscriptionID != 8 { |
|
| 122 | + | t.Fatalf("unexpected subscription id: %+v", filter.SubscriptionID) |
|
| 123 | + | } |
|
| 124 | + | } |
|
| 125 | + | ||
| 126 | + | func TestFormPollMinutesValidation(t *testing.T) { |
|
| 127 | + | good := httptest.NewRequest(http.MethodPost, "/", strings.NewReader("poll_interval_minutes=60")) |
|
| 128 | + | good.Header.Set("Content-Type", "application/x-www-form-urlencoded") |
|
| 129 | + | if mins, ok := formPollMinutes(good); !ok || mins != 60 { |
|
| 130 | + | t.Fatalf("expected valid poll minutes, got %d %v", mins, ok) |
|
| 131 | + | } |
|
| 132 | + | ||
| 133 | + | bad := httptest.NewRequest(http.MethodPost, "/", strings.NewReader("poll_interval_minutes=0")) |
|
| 134 | + | bad.Header.Set("Content-Type", "application/x-www-form-urlencoded") |
|
| 135 | + | if _, ok := formPollMinutes(bad); ok { |
|
| 136 | + | t.Fatal("expected invalid poll minutes") |
|
| 137 | + | } |
|
| 138 | + | } |
|
| 139 | + | ||
| 140 | + | func TestWithCORSHandlesOptions(t *testing.T) { |
|
| 141 | + | app := &App{} |
|
| 142 | + | called := false |
|
| 143 | + | h := app.withCORS(func(w http.ResponseWriter, r *http.Request) { |
|
| 144 | + | called = true |
|
| 145 | + | w.WriteHeader(http.StatusCreated) |
|
| 146 | + | }) |
|
| 147 | + | ||
| 148 | + | rec := httptest.NewRecorder() |
|
| 149 | + | h(rec, httptest.NewRequest(http.MethodOptions, "/api/items", nil)) |
|
| 150 | + | if called { |
|
| 151 | + | t.Fatal("handler should not be called for OPTIONS") |
|
| 152 | + | } |
|
| 153 | + | if rec.Code != http.StatusNoContent { |
|
| 154 | + | t.Fatalf("expected 204, got %d", rec.Code) |
|
| 155 | + | } |
|
| 156 | + | if rec.Header().Get("Access-Control-Allow-Origin") != "*" { |
|
| 157 | + | t.Fatalf("missing CORS header: %v", rec.Header()) |
|
| 158 | + | } |
|
| 159 | + | } |
|
| 160 | + | ||
| 161 | + | func TestGetOrCreateCategoryTrimsAndReuses(t *testing.T) { |
|
| 162 | + | db := newTestDB(t) |
|
| 163 | + | first, err := getOrCreateCategory(db, " News ") |
|
| 164 | + | if err != nil { |
|
| 165 | + | t.Fatal(err) |
|
| 166 | + | } |
|
| 167 | + | second, err := getOrCreateCategory(db, "News") |
|
| 168 | + | if err != nil { |
|
| 169 | + | t.Fatal(err) |
|
| 170 | + | } |
|
| 171 | + | if first == nil || second == nil || first.ID != second.ID { |
|
| 172 | + | t.Fatalf("expected same category, got first=%+v second=%+v", first, second) |
|
| 173 | + | } |
|
| 174 | + | } |
|
| 175 | + | ||
| 176 | + | func TestListItemsAndPruneSubscription(t *testing.T) { |
|
| 177 | + | app := newTestApp(t) |
|
| 178 | + | cat, err := getOrCreateCategory(app.DB, "Tech") |
|
| 179 | + | if err != nil { |
|
| 180 | + | t.Fatal(err) |
|
| 181 | + | } |
|
| 182 | + | sub := seedSubscriptionForTest(t, app.DB, "https://example.com/feed.xml", "Example Feed", &cat.ID) |
|
| 183 | + | ||
| 184 | + | items := []NewItem{ |
|
| 185 | + | {SubscriptionID: sub.ID, GUID: "1", Title: "Old", Link: "https://example.com/1", Author: "Ron", PublishedAt: 10}, |
|
| 186 | + | {SubscriptionID: sub.ID, GUID: "2", Title: "Mid", Link: "https://example.com/2", Author: "", PublishedAt: 20}, |
|
| 187 | + | {SubscriptionID: sub.ID, GUID: "3", Title: "New", Link: "https://example.com/3", Author: "Leslie", PublishedAt: 30}, |
|
| 188 | + | } |
|
| 189 | + | for _, item := range items { |
|
| 190 | + | ok, err := insertItemIgnoreDup(app.DB, item) |
|
| 191 | + | if err != nil || !ok { |
|
| 192 | + | t.Fatalf("insert failed for %+v: ok=%v err=%v", item, ok, err) |
|
| 193 | + | } |
|
| 194 | + | } |
|
| 195 | + | if ok, err := insertItemIgnoreDup(app.DB, items[0]); err != nil || ok { |
|
| 196 | + | t.Fatalf("expected duplicate insert to be ignored, ok=%v err=%v", ok, err) |
|
| 197 | + | } |
|
| 198 | + | if _, err := markItemRead(app.DB, 1, true); err != nil { |
|
| 199 | + | t.Fatal(err) |
|
| 200 | + | } |
|
| 201 | + | if err := pruneSubscription(app.DB, sub.ID, 2); err != nil { |
|
| 202 | + | t.Fatal(err) |
|
| 203 | + | } |
|
| 204 | + | ||
| 205 | + | listed, err := listItems(app.DB, ListItemsFilter{Limit: 10}) |
|
| 206 | + | if err != nil { |
|
| 207 | + | t.Fatal(err) |
|
| 208 | + | } |
|
| 209 | + | if len(listed) != 2 { |
|
| 210 | + | t.Fatalf("expected 2 items after prune, got %d", len(listed)) |
|
| 211 | + | } |
|
| 212 | + | if listed[0].Title != "New" || listed[1].Title != "Mid" { |
|
| 213 | + | t.Fatalf("unexpected order after prune: %+v", listed) |
|
| 214 | + | } |
|
| 215 | + | if listed[0].Author == nil || *listed[0].Author != "Leslie" { |
|
| 216 | + | t.Fatalf("expected author pointer on newest item, got %+v", listed[0].Author) |
|
| 217 | + | } |
|
| 218 | + | if listed[1].Author != nil { |
|
| 219 | + | t.Fatalf("expected nil author on blank author item, got %+v", listed[1].Author) |
|
| 220 | + | } |
|
| 221 | + | if listed[0].CategoryName == nil || *listed[0].CategoryName != "Tech" { |
|
| 222 | + | t.Fatalf("expected category name, got %+v", listed[0].CategoryName) |
|
| 223 | + | } |
|
| 224 | + | ||
| 225 | + | filtered, err := listItems(app.DB, ListItemsFilter{Limit: 10, UnreadOnly: true}) |
|
| 226 | + | if err != nil { |
|
| 227 | + | t.Fatal(err) |
|
| 228 | + | } |
|
| 229 | + | if len(filtered) != 2 { |
|
| 230 | + | t.Fatalf("expected both remaining items to be unread, got %d", len(filtered)) |
|
| 231 | + | } |
|
| 232 | + | } |
|
| 233 | + | ||
| 234 | + | func TestPollIntervalMinutesUsesFallbackForMissingOrInvalidSetting(t *testing.T) { |
|
| 235 | + | app := newTestApp(t) |
|
| 236 | + | if got := app.pollIntervalMinutes(); got != 30 { |
|
| 237 | + | t.Fatalf("expected default poll interval, got %d", got) |
|
| 238 | + | } |
|
| 239 | + | if err := setSetting(app.DB, "poll_interval_minutes", "45"); err != nil { |
|
| 240 | + | t.Fatal(err) |
|
| 241 | + | } |
|
| 242 | + | if got := app.pollIntervalMinutes(); got != 45 { |
|
| 243 | + | t.Fatalf("expected stored poll interval, got %d", got) |
|
| 244 | + | } |
|
| 245 | + | if err := setSetting(app.DB, "poll_interval_minutes", "nonsense"); err != nil { |
|
| 246 | + | t.Fatal(err) |
|
| 247 | + | } |
|
| 248 | + | if got := app.pollIntervalMinutes(); got != 30 { |
|
| 249 | + | t.Fatalf("expected fallback for invalid setting, got %d", got) |
|
| 250 | + | } |
|
| 251 | + | } |
| 11 | 11 | ||
| 12 | 12 | type App struct { |
|
| 13 | 13 | Log *slog.Logger |
|
| 14 | - | Templates *template.Template |
|
| 14 | + | Templates map[string]*template.Template |
|
| 15 | 15 | } |
|
| 16 | 16 | ||
| 17 | 17 | type tagKV struct { |
| 4 | 4 | "net/http" |
|
| 5 | 5 | "slices" |
|
| 6 | 6 | "strings" |
|
| 7 | - | ||
| 8 | - | "github.com/stevedylandev/andromeda/crates-go/web" |
|
| 9 | 7 | ) |
|
| 10 | 8 | ||
| 11 | 9 | var commonTags = []string{"og:title", "og:description", "og:image", "og:url", "og:type"} |
|
| 12 | 10 | ||
| 13 | 11 | func (a *App) indexHandler(w http.ResponseWriter, r *http.Request) { |
|
| 14 | - | web.Render(a.Templates, w, "index.html", nil, a.Log) |
|
| 12 | + | a.renderPage(w, "index.html", nil) |
|
| 15 | 13 | } |
|
| 16 | 14 | ||
| 17 | 15 | func (a *App) checkHandler(w http.ResponseWriter, r *http.Request) { |
|
| 18 | 16 | if err := r.ParseForm(); err != nil { |
|
| 19 | - | web.Render(a.Templates, w, "results.html", resultsData{Error: "Bad request"}, a.Log) |
|
| 17 | + | a.renderPage(w, "results.html", resultsData{Error: "Bad request"}) |
|
| 20 | 18 | return |
|
| 21 | 19 | } |
|
| 22 | 20 | u := strings.TrimSpace(r.FormValue("url")) |
|
| 23 | 21 | if u == "" { |
|
| 24 | - | web.Render(a.Templates, w, "results.html", resultsData{Error: "Please enter a URL"}, a.Log) |
|
| 22 | + | a.renderPage(w, "results.html", resultsData{Error: "Please enter a URL"}) |
|
| 25 | 23 | return |
|
| 26 | 24 | } |
|
| 27 | 25 | if !strings.HasPrefix(u, "http://") && !strings.HasPrefix(u, "https://") { |
|
| 30 | 28 | ||
| 31 | 29 | res, err := fetchOGData(r.Context(), u) |
|
| 32 | 30 | if err != nil { |
|
| 33 | - | web.Render(a.Templates, w, "results.html", resultsData{URL: u, Error: err.Error()}, a.Log) |
|
| 31 | + | a.renderPage(w, "results.html", resultsData{URL: u, Error: err.Error()}) |
|
| 34 | 32 | return |
|
| 35 | 33 | } |
|
| 36 | 34 | ||
| 49 | 47 | data.FoundTags = append(data.FoundTags, tagKV{Key: key, Value: res.OGTags[key]}) |
|
| 50 | 48 | } |
|
| 51 | 49 | data.LinkTags = res.LinkTags |
|
| 52 | - | web.Render(a.Templates, w, "results.html", data, a.Log) |
|
| 50 | + | a.renderPage(w, "results.html", data) |
|
| 53 | 51 | } |
|
| 1 | 1 | package main |
|
| 2 | 2 | ||
| 3 | 3 | import ( |
|
| 4 | - | "html/template" |
|
| 5 | 4 | "log" |
|
| 6 | 5 | "log/slog" |
|
| 7 | 6 | "net/http" |
|
| 13 | 12 | func main() { |
|
| 14 | 13 | config.LoadDotEnv(".env") |
|
| 15 | 14 | logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo})) |
|
| 16 | - | tmpl := template.Must(template.ParseFS(appFS, "templates/*.html")) |
|
| 15 | + | tmpl, err := buildTemplates() |
|
| 16 | + | if err != nil { |
|
| 17 | + | log.Fatal(err) |
|
| 18 | + | } |
|
| 17 | 19 | app := &App{Log: logger, Templates: tmpl} |
|
| 18 | 20 | ||
| 19 | 21 | addr := config.Getenv("HOST", "0.0.0.0") + ":" + config.Getenv("PORT", "3000") |
|
| 1 | + | package main |
|
| 2 | + | ||
| 3 | + | import ( |
|
| 4 | + | "fmt" |
|
| 5 | + | "html/template" |
|
| 6 | + | "io/fs" |
|
| 7 | + | "net/http" |
|
| 8 | + | "path" |
|
| 9 | + | "strings" |
|
| 10 | + | ||
| 11 | + | "github.com/stevedylandev/andromeda/crates-go/web" |
|
| 12 | + | ) |
|
| 13 | + | ||
| 14 | + | func buildTemplates() (map[string]*template.Template, error) { |
|
| 15 | + | pages, err := fs.Glob(appFS, "templates/*.html") |
|
| 16 | + | if err != nil { |
|
| 17 | + | return nil, err |
|
| 18 | + | } |
|
| 19 | + | ||
| 20 | + | out := make(map[string]*template.Template, len(pages)) |
|
| 21 | + | for _, page := range pages { |
|
| 22 | + | if strings.HasSuffix(page, "/base.html") { |
|
| 23 | + | continue |
|
| 24 | + | } |
|
| 25 | + | tmpl, err := template.ParseFS(appFS, "templates/base.html", page) |
|
| 26 | + | if err != nil { |
|
| 27 | + | return nil, fmt.Errorf("parse %s: %w", page, err) |
|
| 28 | + | } |
|
| 29 | + | out[path.Base(page)] = tmpl |
|
| 30 | + | } |
|
| 31 | + | return out, nil |
|
| 32 | + | } |
|
| 33 | + | ||
| 34 | + | func (a *App) renderPage(w http.ResponseWriter, name string, data any) { |
|
| 35 | + | tmpl, ok := a.Templates[name] |
|
| 36 | + | if !ok { |
|
| 37 | + | a.Log.Error("template missing", "name", name) |
|
| 38 | + | http.Error(w, "template missing", http.StatusInternalServerError) |
|
| 39 | + | return |
|
| 40 | + | } |
|
| 41 | + | web.Render(tmpl, w, name, data, a.Log) |
|
| 42 | + | } |
| 17 | 17 | type App struct { |
|
| 18 | 18 | DB *sql.DB |
|
| 19 | 19 | Log *slog.Logger |
|
| 20 | - | Templates *template.Template |
|
| 20 | + | Templates map[string]*template.Template |
|
| 21 | 21 | Sessions *auth.Store |
|
| 22 | 22 | AppPassword string |
|
| 23 | 23 | CookieSecure bool |
| 10 | 10 | "strings" |
|
| 11 | 11 | ||
| 12 | 12 | "github.com/stevedylandev/andromeda/crates-go/auth" |
|
| 13 | - | "github.com/stevedylandev/andromeda/crates-go/web" |
|
| 14 | 13 | ) |
|
| 15 | 14 | ||
| 16 | 15 | const importMaxBytes = 50 * 1024 * 1024 |
|
| 18 | 17 | const bodyLimit = 51 * 1024 * 1024 |
|
| 19 | 18 | ||
| 20 | 19 | func (a *App) loginGet(w http.ResponseWriter, r *http.Request) { |
|
| 21 | - | web.Render(a.Templates, w, "login.html", loginPageData{Error: r.URL.Query().Get("error")}, a.Log) |
|
| 20 | + | a.renderPage(w, "login.html", loginPageData{Error: r.URL.Query().Get("error")}) |
|
| 22 | 21 | } |
|
| 23 | 22 | ||
| 24 | 23 | func (a *App) loginPost(w http.ResponseWriter, r *http.Request) { |
|
| 55 | 54 | http.Error(w, "Server error", http.StatusInternalServerError) |
|
| 56 | 55 | return |
|
| 57 | 56 | } |
|
| 58 | - | web.Render(a.Templates, w, "admin_index.html", adminIndexPageData{Posts: posts}, a.Log) |
|
| 57 | + | a.renderPage(w, "admin_index.html", adminIndexPageData{Posts: posts}) |
|
| 59 | 58 | } |
|
| 60 | 59 | ||
| 61 | 60 | func (a *App) adminNewPost(w http.ResponseWriter, r *http.Request) { |
|
| 62 | - | web.Render(a.Templates, w, "admin_post_form.html", adminPostFormPageData{Error: r.URL.Query().Get("error")}, a.Log) |
|
| 61 | + | a.renderPage(w, "admin_post_form.html", adminPostFormPageData{Error: r.URL.Query().Get("error")}) |
|
| 63 | 62 | } |
|
| 64 | 63 | ||
| 65 | 64 | func (a *App) adminCreatePost(w http.ResponseWriter, r *http.Request) { |
|
| 120 | 119 | http.Error(w, "Post not found", http.StatusNotFound) |
|
| 121 | 120 | return |
|
| 122 | 121 | } |
|
| 123 | - | web.Render(a.Templates, w, "admin_post_form.html", adminPostFormPageData{Post: post, Error: r.URL.Query().Get("error")}, a.Log) |
|
| 122 | + | a.renderPage(w, "admin_post_form.html", adminPostFormPageData{Post: post, Error: r.URL.Query().Get("error")}) |
|
| 124 | 123 | } |
|
| 125 | 124 | ||
| 126 | 125 | func (a *App) adminUpdatePost(w http.ResponseWriter, r *http.Request) { |
|
| 176 | 175 | http.Error(w, "Server error", http.StatusInternalServerError) |
|
| 177 | 176 | return |
|
| 178 | 177 | } |
|
| 179 | - | web.Render(a.Templates, w, "admin_pages.html", adminPagesPageData{Pages: pages}, a.Log) |
|
| 178 | + | a.renderPage(w, "admin_pages.html", adminPagesPageData{Pages: pages}) |
|
| 180 | 179 | } |
|
| 181 | 180 | ||
| 182 | 181 | func (a *App) adminNewPage(w http.ResponseWriter, r *http.Request) { |
|
| 183 | - | web.Render(a.Templates, w, "admin_page_form.html", adminPageFormPageData{Error: r.URL.Query().Get("error")}, a.Log) |
|
| 182 | + | a.renderPage(w, "admin_page_form.html", adminPageFormPageData{Error: r.URL.Query().Get("error")}) |
|
| 184 | 183 | } |
|
| 185 | 184 | ||
| 186 | 185 | func (a *App) adminCreatePage(w http.ResponseWriter, r *http.Request) { |
|
| 217 | 216 | http.Error(w, "Page not found", http.StatusNotFound) |
|
| 218 | 217 | return |
|
| 219 | 218 | } |
|
| 220 | - | web.Render(a.Templates, w, "admin_page_form.html", adminPageFormPageData{Page: page, Error: r.URL.Query().Get("error")}, a.Log) |
|
| 219 | + | a.renderPage(w, "admin_page_form.html", adminPageFormPageData{Page: page, Error: r.URL.Query().Get("error")}) |
|
| 221 | 220 | } |
|
| 222 | 221 | ||
| 223 | 222 | func (a *App) adminUpdatePage(w http.ResponseWriter, r *http.Request) { |
|
| 253 | 252 | func (a *App) adminGetSettings(w http.ResponseWriter, r *http.Request) { |
|
| 254 | 253 | get := func(k string) string { v, _ := getSetting(a.DB, k); return v } |
|
| 255 | 254 | defaultCSS, _ := appFS.ReadFile("static/styles.css") |
|
| 256 | - | web.Render(a.Templates, w, "admin_settings.html", adminSettingsPageData{ |
|
| 255 | + | a.renderPage(w, "admin_settings.html", adminSettingsPageData{ |
|
| 257 | 256 | BlogTitle: get("blog_title"), |
|
| 258 | 257 | BlogDescription: get("blog_description"), |
|
| 259 | 258 | IntroContent: get("intro_content"), |
|
| 265 | 264 | CustomHeader: get("custom_header"), |
|
| 266 | 265 | CustomFooter: get("custom_footer"), |
|
| 267 | 266 | Success: r.URL.Query().Get("success") == "true", |
|
| 268 | - | }, a.Log) |
|
| 267 | + | }) |
|
| 269 | 268 | } |
|
| 270 | 269 | ||
| 271 | 270 | func (a *App) adminPostSettings(w http.ResponseWriter, r *http.Request) { |
|
| 291 | 290 | http.Error(w, "Server error", http.StatusInternalServerError) |
|
| 292 | 291 | return |
|
| 293 | 292 | } |
|
| 294 | - | web.Render(a.Templates, w, "admin_files.html", adminFilesPageData{ |
|
| 293 | + | a.renderPage(w, "admin_files.html", adminFilesPageData{ |
|
| 295 | 294 | Files: files, SiteURL: a.SiteURL, |
|
| 296 | 295 | Error: r.URL.Query().Get("error"), |
|
| 297 | 296 | Success: r.URL.Query().Get("success") == "true", |
|
| 298 | - | }, a.Log) |
|
| 297 | + | }) |
|
| 299 | 298 | } |
|
| 300 | 299 | ||
| 301 | 300 | func (a *App) adminUploadFile(w http.ResponseWriter, r *http.Request) { |
|
| 435 | 434 | _, _ = fmt.Sscanf(v, "%d", &n) |
|
| 436 | 435 | data.Skipped = &n |
|
| 437 | 436 | } |
|
| 438 | - | web.Render(a.Templates, w, "admin_import.html", data, a.Log) |
|
| 437 | + | a.renderPage(w, "admin_import.html", data) |
|
| 439 | 438 | } |
|
| 440 | 439 | ||
| 441 | 440 | func (a *App) adminImportPosts(w http.ResponseWriter, r *http.Request) { |
|
| 4 | 4 | "html/template" |
|
| 5 | 5 | "net/http" |
|
| 6 | 6 | "strings" |
|
| 7 | - | ||
| 8 | - | "github.com/stevedylandev/andromeda/crates-go/web" |
|
| 9 | 7 | ) |
|
| 10 | 8 | ||
| 11 | 9 | func (a *App) site() siteContext { |
|
| 77 | 75 | introHTML = strings.ReplaceAll(introHTML, "{{latest_posts}}", embed) |
|
| 78 | 76 | } |
|
| 79 | 77 | ||
| 80 | - | web.Render(a.Templates, w, "index.html", indexPageData{ |
|
| 78 | + | a.renderPage(w, "index.html", indexPageData{ |
|
| 81 | 79 | BlogTitle: ctx.BlogTitle, BlogDescription: blogDesc, |
|
| 82 | 80 | IntroHTML: template.HTML(introHTML), |
|
| 83 | 81 | Posts: posts, |
|
| 84 | 82 | NavLinks: ctx.NavLinks, FaviconURL: ctx.FaviconURL, OGImageURL: ctx.OGImageURL, |
|
| 85 | 83 | SiteURL: ctx.SiteURL, HeaderHTML: ctx.HeaderHTML, FooterHTML: ctx.FooterHTML, |
|
| 86 | - | }, a.Log) |
|
| 84 | + | }) |
|
| 87 | 85 | } |
|
| 88 | 86 | ||
| 89 | 87 | func (a *App) publicPost(w http.ResponseWriter, r *http.Request) { |
|
| 99 | 97 | } |
|
| 100 | 98 | ctx := a.site() |
|
| 101 | 99 | rendered := renderMarkdown(post.Content) |
|
| 102 | - | web.Render(a.Templates, w, "post.html", postPageData{ |
|
| 100 | + | a.renderPage(w, "post.html", postPageData{ |
|
| 103 | 101 | BlogTitle: ctx.BlogTitle, NavLinks: ctx.NavLinks, Post: *post, |
|
| 104 | 102 | RenderedContent: template.HTML(rendered), |
|
| 105 | 103 | FaviconURL: ctx.FaviconURL, OGImageURL: ctx.OGImageURL, |
|
| 106 | 104 | SiteURL: ctx.SiteURL, HeaderHTML: ctx.HeaderHTML, FooterHTML: ctx.FooterHTML, |
|
| 107 | - | }, a.Log) |
|
| 105 | + | }) |
|
| 108 | 106 | } |
|
| 109 | 107 | ||
| 110 | 108 | func (a *App) publicPage(w http.ResponseWriter, r *http.Request) { |
|
| 125 | 123 | } |
|
| 126 | 124 | ctx := a.site() |
|
| 127 | 125 | rendered := renderMarkdown(page.Content) |
|
| 128 | - | web.Render(a.Templates, w, "page.html", pagePageData{ |
|
| 126 | + | a.renderPage(w, "page.html", pagePageData{ |
|
| 129 | 127 | BlogTitle: ctx.BlogTitle, NavLinks: ctx.NavLinks, Page: *page, |
|
| 130 | 128 | RenderedContent: template.HTML(rendered), |
|
| 131 | 129 | FaviconURL: ctx.FaviconURL, OGImageURL: ctx.OGImageURL, |
|
| 132 | 130 | SiteURL: ctx.SiteURL, HeaderHTML: ctx.HeaderHTML, FooterHTML: ctx.FooterHTML, |
|
| 133 | - | }, a.Log) |
|
| 131 | + | }) |
|
| 134 | 132 | } |
|
| 135 | 133 | ||
| 136 | 134 | func (a *App) publicPostsList(w http.ResponseWriter, r *http.Request) { |
|
| 140 | 138 | http.Error(w, "Server error", http.StatusInternalServerError) |
|
| 141 | 139 | return |
|
| 142 | 140 | } |
|
| 143 | - | web.Render(a.Templates, w, "posts.html", postsListPageData{ |
|
| 141 | + | a.renderPage(w, "posts.html", postsListPageData{ |
|
| 144 | 142 | BlogTitle: ctx.BlogTitle, NavLinks: ctx.NavLinks, Posts: posts, |
|
| 145 | 143 | FaviconURL: ctx.FaviconURL, OGImageURL: ctx.OGImageURL, |
|
| 146 | 144 | SiteURL: ctx.SiteURL, HeaderHTML: ctx.HeaderHTML, FooterHTML: ctx.FooterHTML, |
|
| 147 | - | }, a.Log) |
|
| 145 | + | }) |
|
| 148 | 146 | } |
|
| 149 | 147 | ||
| 150 | 148 | func (a *App) customCSS(w http.ResponseWriter, r *http.Request) { |
|
| 1 | 1 | package main |
|
| 2 | 2 | ||
| 3 | 3 | import ( |
|
| 4 | - | "html/template" |
|
| 5 | 4 | "log" |
|
| 6 | 5 | "log/slog" |
|
| 7 | 6 | "net/http" |
|
| 60 | 59 | } |
|
| 61 | 60 | sessions.PruneExpired() |
|
| 62 | 61 | ||
| 63 | - | tmpl := template.Must(template.ParseFS(appFS, "templates/*.html")) |
|
| 62 | + | tmpl, err := buildTemplates() |
|
| 63 | + | if err != nil { |
|
| 64 | + | log.Fatal(err) |
|
| 65 | + | } |
|
| 64 | 66 | app := &App{ |
|
| 65 | 67 | DB: db, |
|
| 66 | 68 | Log: logger, |
|
| 1 | + | package main |
|
| 2 | + | ||
| 3 | + | import ( |
|
| 4 | + | "fmt" |
|
| 5 | + | "html/template" |
|
| 6 | + | "io/fs" |
|
| 7 | + | "net/http" |
|
| 8 | + | "path" |
|
| 9 | + | "strings" |
|
| 10 | + | ||
| 11 | + | "github.com/stevedylandev/andromeda/crates-go/web" |
|
| 12 | + | ) |
|
| 13 | + | ||
| 14 | + | func buildTemplates() (map[string]*template.Template, error) { |
|
| 15 | + | pages, err := fs.Glob(appFS, "templates/*.html") |
|
| 16 | + | if err != nil { |
|
| 17 | + | return nil, err |
|
| 18 | + | } |
|
| 19 | + | ||
| 20 | + | out := make(map[string]*template.Template, len(pages)) |
|
| 21 | + | for _, page := range pages { |
|
| 22 | + | name := path.Base(page) |
|
| 23 | + | if name == "base.html" || name == "admin_base.html" { |
|
| 24 | + | continue |
|
| 25 | + | } |
|
| 26 | + | ||
| 27 | + | patterns := []string{page} |
|
| 28 | + | switch { |
|
| 29 | + | case strings.HasPrefix(name, "admin_"): |
|
| 30 | + | patterns = append([]string{"templates/admin_base.html"}, patterns...) |
|
| 31 | + | case name == "login.html": |
|
| 32 | + | // standalone template |
|
| 33 | + | default: |
|
| 34 | + | patterns = append([]string{"templates/base.html"}, patterns...) |
|
| 35 | + | } |
|
| 36 | + | ||
| 37 | + | tmpl, err := template.ParseFS(appFS, patterns...) |
|
| 38 | + | if err != nil { |
|
| 39 | + | return nil, fmt.Errorf("parse %s: %w", page, err) |
|
| 40 | + | } |
|
| 41 | + | out[name] = tmpl |
|
| 42 | + | } |
|
| 43 | + | return out, nil |
|
| 44 | + | } |
|
| 45 | + | ||
| 46 | + | func (a *App) renderPage(w http.ResponseWriter, name string, data any) { |
|
| 47 | + | tmpl, ok := a.Templates[name] |
|
| 48 | + | if !ok { |
|
| 49 | + | a.Log.Error("template missing", "name", name) |
|
| 50 | + | http.Error(w, "template missing", http.StatusInternalServerError) |
|
| 51 | + | return |
|
| 52 | + | } |
|
| 53 | + | web.Render(tmpl, w, name, data, a.Log) |
|
| 54 | + | } |