Web UI: music channel, posted-to-Matrix glow, redesigned index, AVIF thumbnails

- Add music as a fourth channel (nav + theme color)
- Glowing themed border on cards that have been posted to Matrix
- Replace per-channel index sections with: "Pete just posted" strip,
  channel dashboard (last post, 24h count, totals), unified latest feed
- /img proxy: SSRF-guarded thumbnail re-encoder that resizes to 800px
  and runs avifenc -q 45, cached under data/img-cache
This commit is contained in:
prosolis
2026-05-24 23:07:17 -07:00
parent 0111a1b06d
commit bddd15f7d1
11 changed files with 438 additions and 47 deletions

1
go.mod
View File

@@ -33,6 +33,7 @@ require (
go.mau.fi/util v0.9.9 // indirect go.mau.fi/util v0.9.9 // indirect
golang.org/x/crypto v0.52.0 // indirect golang.org/x/crypto v0.52.0 // indirect
golang.org/x/exp v0.0.0-20260508232706-74f9aab9d74a // indirect golang.org/x/exp v0.0.0-20260508232706-74f9aab9d74a // indirect
golang.org/x/image v0.41.0 // indirect
golang.org/x/net v0.55.0 // indirect golang.org/x/net v0.55.0 // indirect
golang.org/x/sys v0.45.0 // indirect golang.org/x/sys v0.45.0 // indirect
golang.org/x/text v0.37.0 // indirect golang.org/x/text v0.37.0 // indirect

2
go.sum
View File

@@ -76,6 +76,8 @@ golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988=
golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc= golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc=
golang.org/x/exp v0.0.0-20260508232706-74f9aab9d74a h1:+3jdDGGB8NGb1Zktc737jlt3/A5f6UlwSzmvqUuufxw= golang.org/x/exp v0.0.0-20260508232706-74f9aab9d74a h1:+3jdDGGB8NGb1Zktc737jlt3/A5f6UlwSzmvqUuufxw=
golang.org/x/exp v0.0.0-20260508232706-74f9aab9d74a/go.mod h1:d2fgXJLVs4dYDHUk5lwMIfzRzSrWCfGZb0ZqeLa/Vcw= golang.org/x/exp v0.0.0-20260508232706-74f9aab9d74a/go.mod h1:d2fgXJLVs4dYDHUk5lwMIfzRzSrWCfGZb0ZqeLa/Vcw=
golang.org/x/image v0.41.0 h1:8wS72eGJMJaBxK6okTzd4WaXumUlTVlb753MlsSvTCo=
golang.org/x/image v0.41.0/go.mod h1:uIc348UZMSvS5Z65CVZ7iDPaNobNFEPeJ4kbqTOszmA=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=

View File

@@ -220,22 +220,76 @@ func GetNewestPostableStoryByChannel(channel string) (*Story, error) {
// channels (_discarded, _duplicate) are excluded. // channels (_discarded, _duplicate) are excluded.
func ListClassifiedByChannel(channel string, limit, offset int) ([]Story, error) { func ListClassifiedByChannel(channel string, limit, offset int) ([]Story, error) {
rows, err := Get().Query( rows, err := Get().Query(
`SELECT guid, headline, lede, image_url, article_url, source, platforms, channel, seen_at `SELECT s.guid, s.headline, s.lede, s.image_url, s.article_url, s.source, s.platforms, s.channel, s.seen_at,
FROM stories EXISTS(SELECT 1 FROM post_log p WHERE p.guid = s.guid) AS posted
WHERE classified = 1 FROM stories s
AND channel = ? WHERE s.classified = 1
ORDER BY seen_at DESC AND s.channel = ?
ORDER BY s.seen_at DESC
LIMIT ? OFFSET ?`, channel, limit, offset) LIMIT ? OFFSET ?`, channel, limit, offset)
if err != nil { if err != nil {
return nil, err return nil, err
} }
defer rows.Close() defer rows.Close()
var out []Story var out []Story
for rows.Next() {
var s Story
if err := rows.Scan(&s.GUID, &s.Headline, &s.Lede, &s.ImageURL, &s.ArticleURL, &s.Source, &s.Platforms, &s.Channel, &s.SeenAt, &s.Posted); err != nil {
return nil, err
}
out = append(out, s)
}
return out, rows.Err()
}
// ListAllClassified returns up to `limit` classified stories across all real
// channels, newest first. Sentinel channels are excluded.
func ListAllClassified(limit, offset int) ([]Story, error) {
rows, err := Get().Query(
`SELECT s.guid, s.headline, s.lede, s.image_url, s.article_url, s.source, s.platforms, s.channel, s.seen_at,
EXISTS(SELECT 1 FROM post_log p WHERE p.guid = s.guid) AS posted
FROM stories s
WHERE s.classified = 1
AND s.channel IS NOT NULL
AND s.channel NOT IN ('_discarded', '_duplicate')
ORDER BY s.seen_at DESC
LIMIT ? OFFSET ?`, limit, offset)
if err != nil {
return nil, err
}
defer rows.Close()
var out []Story
for rows.Next() {
var s Story
if err := rows.Scan(&s.GUID, &s.Headline, &s.Lede, &s.ImageURL, &s.ArticleURL, &s.Source, &s.Platforms, &s.Channel, &s.SeenAt, &s.Posted); err != nil {
return nil, err
}
out = append(out, s)
}
return out, rows.Err()
}
// ListRecentlyPosted returns up to `limit` stories that have been posted to
// Matrix, ordered by post time (newest first). Posted is always true.
func ListRecentlyPosted(limit int) ([]Story, error) {
rows, err := Get().Query(
`SELECT s.guid, s.headline, s.lede, s.image_url, s.article_url, s.source, s.platforms, s.channel, s.seen_at
FROM stories s
JOIN post_log p ON p.guid = s.guid
GROUP BY s.guid
ORDER BY MAX(p.posted_at) DESC
LIMIT ?`, limit)
if err != nil {
return nil, err
}
defer rows.Close()
var out []Story
for rows.Next() { for rows.Next() {
var s Story var s Story
if err := rows.Scan(&s.GUID, &s.Headline, &s.Lede, &s.ImageURL, &s.ArticleURL, &s.Source, &s.Platforms, &s.Channel, &s.SeenAt); err != nil { if err := rows.Scan(&s.GUID, &s.Headline, &s.Lede, &s.ImageURL, &s.ArticleURL, &s.Source, &s.Platforms, &s.Channel, &s.SeenAt); err != nil {
return nil, err return nil, err
} }
s.Posted = true
out = append(out, s) out = append(out, s)
} }
return out, rows.Err() return out, rows.Err()
@@ -250,6 +304,20 @@ func CountClassifiedByChannel(channel string) (int, error) {
return n, err return n, err
} }
// IsKnownImageURL reports whether any story has this exact image_url. Used
// by the thumbnail proxy to guard against arbitrary URL fetches.
func IsKnownImageURL(url string) bool {
if url == "" {
return false
}
var n int
if err := Get().QueryRow(`SELECT COUNT(*) FROM stories WHERE image_url = ? LIMIT 1`, url).Scan(&n); err != nil {
slog.Error("IsKnownImageURL query failed", "err", err)
return false
}
return n > 0
}
// GetRoundRobinState returns the last channel posted by the round-robin // GetRoundRobinState returns the last channel posted by the round-robin
// scheduler and the timestamp of that tick. Empty string + 0 if no state yet. // scheduler and the timestamp of that tick. Empty string + 0 if no state yet.
func GetRoundRobinState() (lastChannel string, lastTickAt int64, err error) { func GetRoundRobinState() (lastChannel string, lastTickAt int64, err error) {

View File

@@ -15,4 +15,5 @@ type Story struct {
Channel string Channel string
Classified bool Classified bool
SeenAt int64 SeenAt int64
Posted bool // true if this story has been posted to Matrix (joined from post_log)
} }

View File

@@ -21,6 +21,8 @@ type StoryView struct {
ArticleURL string ArticleURL string
Source string Source string
SeenAt time.Time SeenAt time.Time
Posted bool
Channel string // channel slug; also the theme key
} }
func toView(s storage.Story) StoryView { func toView(s storage.Story) StoryView {
@@ -31,6 +33,8 @@ func toView(s storage.Story) StoryView {
ArticleURL: s.ArticleURL, ArticleURL: s.ArticleURL,
Source: s.Source, Source: s.Source,
SeenAt: time.Unix(s.SeenAt, 0), SeenAt: time.Unix(s.SeenAt, 0),
Posted: s.Posted,
Channel: s.Channel,
} }
} }
@@ -54,12 +58,17 @@ type channelPage struct {
type indexPage struct { type indexPage struct {
pageData pageData
Sections []indexSection JustPosted []StoryView
Stats []channelStat
Latest []StoryView
} }
type indexSection struct { type channelStat struct {
Channel Channel Channel Channel
Stories []StoryView // top 3 per channel LastPostedAt time.Time // zero if never posted
HasLastPosted bool
PostsToday int
Total int
} }
func (s *Server) base() pageData { func (s *Server) base() pageData {
@@ -70,21 +79,56 @@ func (s *Server) base() pageData {
} }
func (s *Server) handleIndex(w http.ResponseWriter, _ *http.Request) { func (s *Server) handleIndex(w http.ResponseWriter, _ *http.Request) {
sections := make([]indexSection, 0, len(channels)) const (
for _, ch := range channels { justPostedLimit = 6
rows, err := storage.ListClassifiedByChannel(ch.Slug, 4, 0) latestLimit = 16
if err != nil { )
slog.Error("web: list failed", "channel", ch.Slug, "err", err)
http.Error(w, "internal error", http.StatusInternalServerError) postedRows, err := storage.ListRecentlyPosted(justPostedLimit)
return if err != nil {
} slog.Error("web: list recently posted failed", "err", err)
views := make([]StoryView, 0, len(rows)) http.Error(w, "internal error", http.StatusInternalServerError)
for _, r := range rows { return
views = append(views, toView(r)) }
} justPosted := make([]StoryView, 0, len(postedRows))
sections = append(sections, indexSection{Channel: ch, Stories: views}) for _, r := range postedRows {
justPosted = append(justPosted, toView(r))
}
latestRows, err := storage.ListAllClassified(latestLimit, 0)
if err != nil {
slog.Error("web: list all classified failed", "err", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
latest := make([]StoryView, 0, len(latestRows))
for _, r := range latestRows {
latest = append(latest, toView(r))
}
dayStart := time.Now().Add(-24 * time.Hour).Unix()
stats := make([]channelStat, 0, len(channels))
for _, ch := range channels {
total, _ := storage.CountClassifiedByChannel(ch.Slug)
lastTs := storage.GetLastPostTime(ch.Slug)
stat := channelStat{
Channel: ch,
PostsToday: storage.CountPostsInWindow(ch.Slug, dayStart),
Total: total,
}
if lastTs > 0 {
stat.LastPostedAt = time.Unix(lastTs, 0)
stat.HasLastPosted = true
}
stats = append(stats, stat)
}
data := indexPage{
pageData: s.base(),
JustPosted: justPosted,
Stats: stats,
Latest: latest,
} }
data := indexPage{pageData: s.base(), Sections: sections}
s.render(w, "index", data) s.render(w, "index", data)
} }
@@ -141,6 +185,15 @@ func (s *Server) render(w http.ResponseWriter, page string, data any) {
} }
var funcs = template.FuncMap{ var funcs = template.FuncMap{
"thumb": thumbURL,
"channel": func(slug string) Channel {
for _, ch := range channels {
if ch.Slug == slug {
return ch
}
}
return Channel{Slug: slug, Title: slug, Theme: slug}
},
"dict": func(pairs ...any) map[string]any { "dict": func(pairs ...any) map[string]any {
m := make(map[string]any, len(pairs)/2) m := make(map[string]any, len(pairs)/2)
for i := 0; i+1 < len(pairs); i += 2 { for i := 0; i+1 < len(pairs); i += 2 {

View File

@@ -34,6 +34,7 @@ var channels = []Channel{
{Slug: "gaming", Title: "Gaming", Theme: "gaming", Emoji: "🎮", Blurb: "Releases, platforms, and the people making the games."}, {Slug: "gaming", Title: "Gaming", Theme: "gaming", Emoji: "🎮", Blurb: "Releases, platforms, and the people making the games."},
{Slug: "tech", Title: "Tech", Theme: "tech", Emoji: "💻", Blurb: "Industry, products, and the wires that hold it all together."}, {Slug: "tech", Title: "Tech", Theme: "tech", Emoji: "💻", Blurb: "Industry, products, and the wires that hold it all together."},
{Slug: "politics", Title: "Politics", Theme: "politics", Emoji: "🏛️", Blurb: "Policy, power, and the news of the day."}, {Slug: "politics", Title: "Politics", Theme: "politics", Emoji: "🏛️", Blurb: "Policy, power, and the news of the day."},
{Slug: "music", Title: "Music", Theme: "music", Emoji: "🎵", Blurb: "Records, scenes, and the artists shaping the sound."},
} }
// Server is the HTTP server for Pete's web UI. // Server is the HTTP server for Pete's web UI.
@@ -68,6 +69,7 @@ func New(cfg config.WebConfig) (*Server, error) {
return nil, err return nil, err
} }
mux.Handle("GET /static/", http.StripPrefix("/static/", http.FileServer(http.FS(staticSub)))) mux.Handle("GET /static/", http.StripPrefix("/static/", http.FileServer(http.FS(staticSub))))
mux.HandleFunc("GET /img/{hash}.avif", s.handleImg)
mux.HandleFunc("GET /{$}", s.handleIndex) mux.HandleFunc("GET /{$}", s.handleIndex)
for _, ch := range channels { for _, ch := range channels {

View File

@@ -58,18 +58,33 @@ html[data-phase="night"] {
.bg-theme-gaming { background-color: #4caf7d; } .bg-theme-gaming { background-color: #4caf7d; }
.bg-theme-tech { background-color: #5aa9e6; } .bg-theme-tech { background-color: #5aa9e6; }
.bg-theme-politics { background-color: #e07a5f; } .bg-theme-politics { background-color: #e07a5f; }
.bg-theme-music { background-color: #b079d6; }
.text-theme-gaming { color: #2d8a5a; } .text-theme-gaming { color: #2d8a5a; }
.text-theme-tech { color: #2f7fb8; } .text-theme-tech { color: #2f7fb8; }
.text-theme-politics { color: #b8523a; } .text-theme-politics { color: #b8523a; }
.text-theme-music { color: #8a4fb8; }
.decoration-theme-gaming { text-decoration-color: #4caf7d; } .decoration-theme-gaming { text-decoration-color: #4caf7d; }
.decoration-theme-tech { text-decoration-color: #5aa9e6; } .decoration-theme-tech { text-decoration-color: #5aa9e6; }
.decoration-theme-politics { text-decoration-color: #e07a5f; } .decoration-theme-politics { text-decoration-color: #e07a5f; }
.decoration-theme-music { text-decoration-color: #b079d6; }
.border-theme-gaming { border-color: #4caf7d; } .border-theme-gaming { border-color: #4caf7d; }
.border-theme-tech { border-color: #5aa9e6; } .border-theme-tech { border-color: #5aa9e6; }
.border-theme-politics { border-color: #e07a5f; } .border-theme-politics { border-color: #e07a5f; }
.border-theme-music { border-color: #b079d6; }
/* "Posted to Matrix" glow — soft pulsing aura in the channel's theme color. */
.glow-theme-gaming { box-shadow: 0 0 0 2px rgba(76,175,125,0.35), 0 0 24px 4px rgba(76,175,125,0.45); animation: pete-glow-pulse 2.4s ease-in-out infinite; }
.glow-theme-tech { box-shadow: 0 0 0 2px rgba(90,169,230,0.35), 0 0 24px 4px rgba(90,169,230,0.45); animation: pete-glow-pulse 2.4s ease-in-out infinite; }
.glow-theme-politics { box-shadow: 0 0 0 2px rgba(224,122,95,0.35), 0 0 24px 4px rgba(224,122,95,0.45); animation: pete-glow-pulse 2.4s ease-in-out infinite; }
.glow-theme-music { box-shadow: 0 0 0 2px rgba(176,121,214,0.35), 0 0 24px 4px rgba(176,121,214,0.45); animation: pete-glow-pulse 2.4s ease-in-out infinite; }
@keyframes pete-glow-pulse {
0%, 100% { filter: brightness(1); }
50% { filter: brightness(1.08); }
}
.line-clamp-3 { .line-clamp-3 {
display: -webkit-box; display: -webkit-box;

File diff suppressed because one or more lines are too long

View File

@@ -1,17 +1,24 @@
{{define "card"}} {{define "card"}}
<a href="{{.Story.ArticleURL}}" target="_blank" rel="noopener noreferrer" <a href="{{.Story.ArticleURL}}" target="_blank" rel="noopener noreferrer"
class="group block rounded-3xl bg-[color:var(--card)] border-2 border-[color:var(--ink)]/10 shadow-pete overflow-hidden hover:-translate-y-1 hover:shadow-pete-lg transition"> class="group block rounded-3xl bg-[color:var(--card)] border-2 {{if .Story.Posted}}border-theme-{{.Theme}} glow-theme-{{.Theme}}{{else}}border-[color:var(--ink)]/10{{end}} shadow-pete overflow-hidden hover:-translate-y-1 hover:shadow-pete-lg transition">
{{if .Story.ImageURL}} {{if .Story.ImageURL}}
<div class="aspect-[16/10] w-full overflow-hidden bg-[color:var(--ink)]/5"> <div class="aspect-[16/10] w-full overflow-hidden bg-[color:var(--ink)]/5">
<img src="{{.Story.ImageURL}}" alt="" loading="lazy" <img src="{{thumb .Story.ImageURL}}" alt="" loading="lazy" decoding="async"
class="h-full w-full object-cover group-hover:scale-105 transition duration-500"> class="h-full w-full object-cover group-hover:scale-105 transition duration-500">
</div> </div>
{{end}} {{end}}
<div class="p-4 space-y-2"> <div class="p-4 space-y-2">
<div class="flex items-center gap-2 text-xs"> <div class="flex items-center gap-2 text-xs flex-wrap">
{{if .ShowChannel}}{{$ch := channel .Story.Channel}}
<span class="inline-flex items-center rounded-full bg-theme-{{$ch.Theme}} text-white px-2.5 py-0.5 font-semibold uppercase tracking-wider">
<span aria-hidden="true" class="mr-1">{{$ch.Emoji}}</span>{{$ch.Title}}
</span>
<span class="text-[color:var(--ink)]/60 font-semibold">{{.Story.Source}}</span>
{{else}}
<span class="inline-flex items-center rounded-full bg-theme-{{.Theme}} text-white px-2.5 py-0.5 font-semibold uppercase tracking-wider"> <span class="inline-flex items-center rounded-full bg-theme-{{.Theme}} text-white px-2.5 py-0.5 font-semibold uppercase tracking-wider">
{{.Story.Source}} {{.Story.Source}}
</span> </span>
{{end}}
<span class="text-[color:var(--ink)]/50">{{timeAgo .Story.SeenAt}}</span> <span class="text-[color:var(--ink)]/50">{{timeAgo .Story.SeenAt}}</span>
</div> </div>
<h3 class="font-display text-lg font-semibold leading-snug group-hover:underline underline-offset-2 decoration-theme-{{.Theme}}"> <h3 class="font-display text-lg font-semibold leading-snug group-hover:underline underline-offset-2 decoration-theme-{{.Theme}}">

View File

@@ -1,37 +1,81 @@
{{define "title"}}{{.SiteTitle}} — your news feed{{end}} {{define "title"}}{{.SiteTitle}} — your news feed{{end}}
{{define "main"}} {{define "main"}}
<section class="mt-2 mb-10 sm:mb-14 text-center"> <section class="mt-2 mb-8 text-center">
<h1 class="font-display text-4xl sm:text-6xl font-bold leading-tight"> <h1 class="font-display text-4xl sm:text-6xl font-bold leading-tight">
a friendlier news feed. a friendlier news feed.
</h1> </h1>
<p class="mx-auto mt-3 max-w-xl text-base sm:text-lg text-[color:var(--ink)]/70"> <p class="mx-auto mt-3 max-w-xl text-base sm:text-lg text-[color:var(--ink)]/70">
Pete reads RSS, sorts stories, and posts them to Matrix. Pete reads RSS, sorts stories, and posts them to Matrix.
Here's what he's been reading lately. Glowing cards are the ones he's posted.
</p> </p>
</section> </section>
{{range .Sections}} {{if .JustPosted}}
<section class="mb-10">
<div class="flex items-end justify-between gap-3 mb-4">
<h2 class="font-display text-xl sm:text-2xl font-bold">
<span aria-hidden="true">📡</span> Pete just posted
</h2>
<span class="text-xs text-[color:var(--ink)]/50">most recent posts to Matrix</span>
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{{range .JustPosted}}
{{template "card" dict "Story" . "Theme" .Channel "ShowChannel" true}}
{{end}}
</div>
</section>
{{end}}
<section class="mb-10">
<div class="flex items-end justify-between gap-3 mb-4">
<h2 class="font-display text-xl sm:text-2xl font-bold">
<span aria-hidden="true">📊</span> Channels
</h2>
<span class="text-xs text-[color:var(--ink)]/50">last 24 hours</span>
</div>
<div class="grid grid-cols-2 lg:grid-cols-4 gap-3 sm:gap-4">
{{range .Stats}}
<a href="/{{.Channel.Slug}}"
class="block rounded-3xl bg-[color:var(--card)] border-2 border-[color:var(--ink)]/10 shadow-pete p-4 hover:-translate-y-1 hover:shadow-pete-lg transition">
<div class="flex items-center gap-2">
<span class="inline-flex items-center justify-center h-9 w-9 rounded-2xl bg-theme-{{.Channel.Theme}} text-white text-lg">
{{.Channel.Emoji}}
</span>
<span class="font-display text-lg font-semibold">{{.Channel.Title}}</span>
</div>
<dl class="mt-3 space-y-1 text-xs text-[color:var(--ink)]/70">
<div class="flex justify-between gap-2">
<dt>last post</dt>
<dd class="font-semibold text-[color:var(--ink)]">
{{if .HasLastPosted}}{{timeAgo .LastPostedAt}}{{else}}—{{end}}
</dd>
</div>
<div class="flex justify-between gap-2">
<dt>posted 24h</dt>
<dd class="font-semibold text-[color:var(--ink)]">{{.PostsToday}}</dd>
</div>
<div class="flex justify-between gap-2">
<dt>stories</dt>
<dd class="font-semibold text-[color:var(--ink)]">{{.Total}}</dd>
</div>
</dl>
</a>
{{end}}
</div>
</section>
<section class="mb-12"> <section class="mb-12">
<div class="flex items-end justify-between gap-3 mb-4"> <div class="flex items-end justify-between gap-3 mb-4">
<div> <h2 class="font-display text-xl sm:text-2xl font-bold">
<h2 class="font-display text-2xl sm:text-3xl font-bold"> <span aria-hidden="true">🗞️</span> Latest across the feed
<span aria-hidden="true">{{.Channel.Emoji}}</span> </h2>
<a href="/{{.Channel.Slug}}" class="hover:underline underline-offset-4 decoration-theme-{{.Channel.Theme}} decoration-4"> <span class="text-xs text-[color:var(--ink)]/50">newest first, all channels</span>
{{.Channel.Title}}
</a>
</h2>
<p class="text-sm text-[color:var(--ink)]/60 mt-1">{{.Channel.Blurb}}</p>
</div>
<a href="/{{.Channel.Slug}}" class="hidden sm:inline-flex items-center gap-1 text-sm font-semibold rounded-full bg-theme-{{.Channel.Theme}} text-white px-4 py-2 shadow-pete hover:-translate-y-0.5 transition">
more →
</a>
</div> </div>
{{if .Latest}}
{{if .Stories}}
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4"> <div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
{{range .Stories}} {{range .Latest}}
{{template "card" dict "Story" . "Theme" $.Channel.Theme}} {{template "card" dict "Story" . "Theme" .Channel "ShowChannel" true}}
{{end}} {{end}}
</div> </div>
{{else}} {{else}}
@@ -41,4 +85,3 @@
{{end}} {{end}}
</section> </section>
{{end}} {{end}}
{{end}}

199
internal/web/thumbs.go Normal file
View File

@@ -0,0 +1,199 @@
package web
import (
"bytes"
"crypto/sha256"
"encoding/hex"
"fmt"
"image"
_ "image/gif"
_ "image/jpeg"
"image/png"
"io"
"log/slog"
"net/http"
"net/url"
"os"
"os/exec"
"path/filepath"
"strings"
"sync"
"time"
"golang.org/x/image/draw"
_ "golang.org/x/image/webp"
"pete/internal/storage"
)
const (
thumbWidth = 800
thumbMaxBytes = 12 << 20 // 12 MiB cap on source download
thumbFetchTimeout = 12 * time.Second
thumbCacheDir = "./data/img-cache"
thumbQuality = "45"
thumbSpeed = "8"
)
var (
thumbClient = &http.Client{Timeout: thumbFetchTimeout}
thumbMu sync.Mutex
thumbInFlight = map[string]*sync.WaitGroup{}
)
func thumbKey(u string) string {
sum := sha256.Sum256([]byte(u))
return hex.EncodeToString(sum[:16])
}
// thumbURL maps a source image URL to the /img proxy path. Empty input → "".
func thumbURL(src string) string {
if src == "" {
return ""
}
return "/img/" + thumbKey(src) + ".avif?u=" + url.QueryEscape(src)
}
func (s *Server) handleImg(w http.ResponseWriter, r *http.Request) {
hash := r.PathValue("hash")
src := r.URL.Query().Get("u")
if src == "" || thumbKey(src) != hash {
http.NotFound(w, r)
return
}
if !(strings.HasPrefix(src, "http://") || strings.HasPrefix(src, "https://")) {
http.NotFound(w, r)
return
}
if !storage.IsKnownImageURL(src) {
http.NotFound(w, r)
return
}
if err := os.MkdirAll(thumbCacheDir, 0o755); err != nil {
http.Error(w, "cache dir", http.StatusInternalServerError)
return
}
cachePath := filepath.Join(thumbCacheDir, hash+".avif")
if fi, err := os.Stat(cachePath); err == nil && fi.Size() > 0 {
serveAvif(w, r, cachePath)
return
}
// Single-flight: collapse concurrent requests for the same image.
thumbMu.Lock()
wg, busy := thumbInFlight[hash]
if !busy {
wg = &sync.WaitGroup{}
wg.Add(1)
thumbInFlight[hash] = wg
}
thumbMu.Unlock()
if busy {
wg.Wait()
if fi, err := os.Stat(cachePath); err == nil && fi.Size() > 0 {
serveAvif(w, r, cachePath)
return
}
http.Redirect(w, r, src, http.StatusFound)
return
}
defer func() {
thumbMu.Lock()
delete(thumbInFlight, hash)
thumbMu.Unlock()
wg.Done()
}()
if err := buildAvifThumb(src, cachePath); err != nil {
slog.Warn("thumb build failed", "url", src, "err", err)
http.Redirect(w, r, src, http.StatusFound)
return
}
serveAvif(w, r, cachePath)
}
func serveAvif(w http.ResponseWriter, r *http.Request, path string) {
w.Header().Set("Content-Type", "image/avif")
w.Header().Set("Cache-Control", "public, max-age=2592000, immutable")
http.ServeFile(w, r, path)
}
// buildAvifThumb downloads src, decodes + resizes in Go, then shells out to
// avifenc to produce a compact AVIF at outPath.
func buildAvifThumb(src, outPath string) error {
req, err := http.NewRequest(http.MethodGet, src, nil)
if err != nil {
return err
}
req.Header.Set("User-Agent", "Pete-Thumb/1.0 (+https://pete.example)")
resp, err := thumbClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("upstream %d", resp.StatusCode)
}
body, err := io.ReadAll(io.LimitReader(resp.Body, thumbMaxBytes+1))
if err != nil {
return err
}
if len(body) > thumbMaxBytes {
return fmt.Errorf("image too big (>%d bytes)", thumbMaxBytes)
}
src2, _, err := image.Decode(bytes.NewReader(body))
if err != nil {
return fmt.Errorf("decode: %w", err)
}
b := src2.Bounds()
srcW, srcH := b.Dx(), b.Dy()
if srcW <= 0 || srcH <= 0 {
return fmt.Errorf("empty image")
}
dstW, dstH := srcW, srcH
if srcW > thumbWidth {
dstW = thumbWidth
dstH = int(float64(srcH) * float64(thumbWidth) / float64(srcW))
if dstH < 1 {
dstH = 1
}
}
var resized image.Image = src2
if dstW != srcW || dstH != srcH {
dst := image.NewRGBA(image.Rect(0, 0, dstW, dstH))
draw.CatmullRom.Scale(dst, dst.Bounds(), src2, b, draw.Over, nil)
resized = dst
}
tmp, err := os.CreateTemp("", "pete-thumb-*.png")
if err != nil {
return err
}
tmpPath := tmp.Name()
defer os.Remove(tmpPath)
if err := png.Encode(tmp, resized); err != nil {
tmp.Close()
return err
}
if err := tmp.Close(); err != nil {
return err
}
// Encode to a sibling temp file then atomically rename so partial outputs
// never appear in the cache.
stagedOut := outPath + ".tmp"
cmd := exec.Command("avifenc",
"-q", thumbQuality,
"-s", thumbSpeed,
tmpPath, stagedOut,
)
if out, err := cmd.CombinedOutput(); err != nil {
os.Remove(stagedOut)
return fmt.Errorf("avifenc: %w: %s", err, strings.TrimSpace(string(out)))
}
return os.Rename(stagedOut, outPath)
}