Compare commits

...
2 Commits
9 changed files with 518 additions and 13 deletions
+8 -5
View File
@@ -3,7 +3,7 @@ title: tver の現状
tags: tags:
- homelab - homelab
- tver - tver
status: web 土台実装中 status: 新着一覧実装中
--- ---
# tver の現状 # tver の現状
@@ -13,8 +13,8 @@ status: web 土台実装中
## 今の状態 ## 今の状態
- **web の土台を実装中。** Go の HTTP サーバー、TVer API クライアント、画面の空のページ、テスト、web コンテナの定義がある - **新着一覧の画面を実装中。** Go の HTTP サーバー、TVer API クライアント、ドラマ・バラエティの新着表示、テスト、web コンテナの定義がある
- 新着一覧の画面表示、DB、worker は未実装 - DB、worker は未実装。ダウンロード済み・待ち行列中の区別は DB 導入後に実装する
- リポジトリ: `https://gitea.kazu634.com/kazu634/tver.git` - リポジトリ: `https://gitea.kazu634.com/kazu634/tver.git`
## 目的 ## 目的
@@ -71,7 +71,9 @@ TVer(`https://tver.jp/`)で公開中の動画情報を取得し、選んだ
| `web` | UI・API。新着一覧の取得、待ち行列への登録、番組登録、動画の配信(再生) | | `web` | UI・API。新着一覧の取得、待ち行列への登録、番組登録、動画の配信(再生) |
| `worker` | Go + yt-dlp + ffmpeg。待ち行列からダウンロードし、登録番組の新しい回を定期的に確認する | | `worker` | Go + yt-dlp + ffmpeg。待ち行列からダウンロードし、登録番組の新しい回を定期的に確認する |
- 現在の `web` は `/healthz` と「新着/待ち行列/登録番組/ライブラリ」の空ページを提供する。`internal/tver` はセッション発行とドラマ・バラエティの新着取得を実装済みだが、画面からはまだ呼び出さない - 現在の `web` は `/healthz`、ドラマ・バラエティの新着ページ(`/`)、新着一覧の HTML 断片(`/newer`)を提供する。「待ち行列/登録番組/ライブラリ」のページは空のまま
- 新着はジャンルごとにメモリへ 10 分間キャッシュする。キャッシュ中は TVer セッションの再発行と一覧の再取得を行わない。TVer の取得に失敗した場合、一覧の場所にエラーを表示して詳細をログへ記録する
- 「予告を隠す」は既定でオンとし、180 秒以下の件を非表示にする。長さは秒から分へ切り上げて表示し、配信終了日時は Asia/Tokyo で表示する。ジャンルと予告表示の状態は URL に残す
- 画面は `html/template` と htmx 2.0.11 を使い、htmx は Go の `embed` から `/static/` でローカル配信する - 画面は `html/template` と htmx 2.0.11 を使い、htmx は Go の `embed` から `/static/` でローカル配信する
- `compose.yaml` は web のみを定義し、dockage VM の LAN アドレス `192.168.10.155:8080` で公開する。待ち受けは `WEB_ADDR` で変更でき、既定は `:8080` - `compose.yaml` は web のみを定義し、dockage VM の LAN アドレス `192.168.10.155:8080` で公開する。待ち受けは `WEB_ADDR` で変更でき、既定は `:8080`
- DB はコンテナを持たず、NAS の共有 Postgres に接続する(番組登録・ダウンロード履歴・待ち行列) - DB はコンテナを持たず、NAS の共有 Postgres に接続する(番組登録・ダウンロード履歴・待ち行列)
@@ -106,5 +108,6 @@ TVer(`https://tver.jp/`)で公開中の動画情報を取得し、選んだ
## 確かめ方 ## 確かめ方
- `env GOCACHE=/private/tmp/tver-go-cache GOPROXY=off go test -count=1 ./...` で Go のテストを実行する。テストの TVer 応答は `internal/tver/testdata/newer_drama.json`(2026-10-03 の実応答を 3 件に縮小)と `httptest` を使い、テストから本物の TVer にはアクセスしない - `env GOCACHE=/private/tmp/tver-go-cache GOPROXY=off go test -count=1 ./...` で Go のテストを実行する。テストの TVer 応答は `internal/tver/testdata/newer_drama.json`(2026-10-03 の実応答を 3 件に縮小)と `httptest` を使い、テストから本物の TVer にはアクセスしない
- 新着画面のテストは偽物の TVer クライアントと差し替え可能な時計を使い、表示項目、絞り込みの境界、10 分キャッシュ、取得失敗、JST 表示を確認する
- `env GOCACHE=/private/tmp/tver-go-cache GOPROXY=off go vet ./...` で静的解析する - `env GOCACHE=/private/tmp/tver-go-cache GOPROXY=off go vet ./...` で静的解析する
- `WEB_ADDR=127.0.0.1:18080 go run ./cmd/web` で起動し、`/healthz` と `/` を確認できる。Dockerfile と Compose のビルド・実行は Docker が使える環境で別途確認が必要 - `WEB_ADDR=127.0.0.1:18080 go run ./cmd/web` で起動し、`/healthz`、`/`、`/newer?genre=variety` を確認できる。TVer への実アクセスを伴うため手動確認は少数回に留める
+7 -1
View File
@@ -5,7 +5,9 @@ import (
"net/http" "net/http"
"os" "os"
"time" "time"
_ "time/tzdata"
"gitea.kazu634.com/kazu634/tver/internal/tver"
"gitea.kazu634.com/kazu634/tver/internal/web" "gitea.kazu634.com/kazu634/tver/internal/web"
) )
@@ -14,9 +16,13 @@ func main() {
if address == "" { if address == "" {
address = ":8080" address = ":8080"
} }
client, err := tver.NewClient("", nil)
if err != nil {
log.Fatal(err)
}
server := &http.Server{ server := &http.Server{
Addr: address, Addr: address,
Handler: web.NewHandler(), Handler: web.NewHandler(client, time.Now, log.Default()),
ReadHeaderTimeout: 5 * time.Second, ReadHeaderTimeout: 5 * time.Second,
} }
log.Printf("web listening on %s", address) log.Printf("web listening on %s", address)
+23 -3
View File
@@ -4,7 +4,10 @@ import (
"embed" "embed"
"html/template" "html/template"
"io/fs" "io/fs"
"log"
"net/http" "net/http"
"sync"
"time"
) )
//go:embed templates/*.html static/* //go:embed templates/*.html static/*
@@ -12,29 +15,46 @@ var assets embed.FS
type page struct { type page struct {
Title string Title string
Newer *newerView
} }
func NewHandler() http.Handler { type server struct {
fetcher NewerFetcher
now func() time.Time
logger *log.Logger
zone *time.Location
tmpl *template.Template
cacheMu sync.Mutex
cache map[string]cacheEntry
}
func NewHandler(fetcher NewerFetcher, now func() time.Time, logger *log.Logger) http.Handler {
tmpl := template.Must(template.ParseFS(assets, "templates/*.html")) tmpl := template.Must(template.ParseFS(assets, "templates/*.html"))
staticFiles, err := fs.Sub(assets, "static") staticFiles, err := fs.Sub(assets, "static")
if err != nil { if err != nil {
panic(err) panic(err)
} }
zone, err := time.LoadLocation("Asia/Tokyo")
if err != nil {
panic(err)
}
s := &server{fetcher: fetcher, now: now, logger: logger, zone: zone, tmpl: tmpl, cache: make(map[string]cacheEntry)}
mux := http.NewServeMux() mux := http.NewServeMux()
mux.Handle("GET /static/", http.StripPrefix("/static/", http.FileServerFS(staticFiles))) mux.Handle("GET /static/", http.StripPrefix("/static/", http.FileServerFS(staticFiles)))
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, _ *http.Request) { mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "text/plain; charset=utf-8") w.Header().Set("Content-Type", "text/plain; charset=utf-8")
_, _ = w.Write([]byte("ok\n")) _, _ = w.Write([]byte("ok\n"))
}) })
mux.HandleFunc("GET /{$}", s.home)
mux.HandleFunc("GET /newer", s.newerFragment)
for path, title := range map[string]string{ for path, title := range map[string]string{
"GET /{$}": "新着",
"GET /queue": "待ち行列", "GET /queue": "待ち行列",
"GET /subscriptions": "登録番組", "GET /subscriptions": "登録番組",
"GET /library": "ライブラリ", "GET /library": "ライブラリ",
} { } {
mux.HandleFunc(path, func(w http.ResponseWriter, _ *http.Request) { mux.HandleFunc(path, func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := tmpl.ExecuteTemplate(w, "layout", page{Title: title}); err != nil { if err := s.tmpl.ExecuteTemplate(w, "layout", page{Title: title}); err != nil {
http.Error(w, "template error", http.StatusInternalServerError) http.Error(w, "template error", http.StatusInternalServerError)
} }
}) })
+3 -3
View File
@@ -9,14 +9,14 @@ import (
func TestHealthz(t *testing.T) { func TestHealthz(t *testing.T) {
response := httptest.NewRecorder() response := httptest.NewRecorder()
NewHandler().ServeHTTP(response, httptest.NewRequest(http.MethodGet, "/healthz", nil)) newTestHandler(&fakeFetcher{}, nil).ServeHTTP(response, httptest.NewRequest(http.MethodGet, "/healthz", nil))
if response.Code != http.StatusOK || response.Body.String() != "ok\n" { if response.Code != http.StatusOK || response.Body.String() != "ok\n" {
t.Errorf("healthz: status=%d body=%q", response.Code, response.Body.String()) t.Errorf("healthz: status=%d body=%q", response.Code, response.Body.String())
} }
} }
func TestPagesAndEmbeddedAssets(t *testing.T) { func TestPagesAndEmbeddedAssets(t *testing.T) {
handler := NewHandler() handler := newTestHandler(&fakeFetcher{}, nil)
for _, tt := range []struct{ path, title string }{ for _, tt := range []struct{ path, title string }{
{"/", "新着"}, {"/queue", "待ち行列"}, {"/subscriptions", "登録番組"}, {"/library", "ライブラリ"}, {"/", "新着"}, {"/queue", "待ち行列"}, {"/subscriptions", "登録番組"}, {"/library", "ライブラリ"},
} { } {
@@ -42,7 +42,7 @@ func TestPagesAndEmbeddedAssets(t *testing.T) {
} }
func TestUnknownPathsAndMethods(t *testing.T) { func TestUnknownPathsAndMethods(t *testing.T) {
handler := NewHandler() handler := newTestHandler(&fakeFetcher{}, nil)
for _, tt := range []struct { for _, tt := range []struct {
name, method, path string name, method, path string
wantStatus int wantStatus int
+168
View File
@@ -0,0 +1,168 @@
package web
import (
"bytes"
"context"
"fmt"
"net/http"
"net/url"
"regexp"
"time"
"gitea.kazu634.com/kazu634/tver/internal/tver"
)
const (
previewMaxSeconds = 180
newerCacheTTL = 10 * time.Minute
)
var platformTokenPattern = regexp.MustCompile(`(?i)(platform_token=)[^&\s]+`)
type NewerFetcher interface {
FetchNewer(context.Context, string) ([]tver.Episode, error)
}
type cacheEntry struct {
episodes []tver.Episode
expiresAt time.Time
}
type newerOptions struct {
genre string
hidePreviews bool
}
type episodeView struct {
URL string
ThumbnailURL string
SeriesTitle string
Title string
BroadcasterName string
DurationMinutes int64
BroadcastDateLabel string
EndAtText string
EndAtISO string
}
type newerView struct {
Genre string
HidePreviews bool
Episodes []episodeView
Failed bool
}
func parseNewerOptions(r *http.Request) (newerOptions, error) {
options := newerOptions{genre: "drama", hidePreviews: true}
query := r.URL.Query()
if values, ok := query["genre"]; ok {
options.genre = values[len(values)-1]
}
if options.genre != "drama" && options.genre != "variety" {
return newerOptions{}, fmt.Errorf("invalid genre")
}
if values, ok := query["hide_previews"]; ok {
switch values[len(values)-1] {
case "0":
options.hidePreviews = false
case "1":
options.hidePreviews = true
default:
return newerOptions{}, fmt.Errorf("invalid hide_previews")
}
}
return options, nil
}
func (options newerOptions) pageURL() string {
query := url.Values{}
query.Set("genre", options.genre)
if options.hidePreviews {
query.Set("hide_previews", "1")
} else {
query.Set("hide_previews", "0")
}
return "/?" + query.Encode()
}
func (s *server) home(w http.ResponseWriter, r *http.Request) {
options, err := parseNewerOptions(r)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
view := s.newerView(r.Context(), options)
s.render(w, "layout", page{Title: "新着", Newer: &view})
}
func (s *server) newerFragment(w http.ResponseWriter, r *http.Request) {
options, err := parseNewerOptions(r)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
view := s.newerView(r.Context(), options)
w.Header().Set("HX-Push-Url", options.pageURL())
s.render(w, "newer_results", view)
}
func (s *server) render(w http.ResponseWriter, name string, value any) {
var body bytes.Buffer
if err := s.tmpl.ExecuteTemplate(&body, name, value); err != nil {
s.logger.Printf("テンプレートの描画に失敗: %v", err)
http.Error(w, "template error", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_, _ = body.WriteTo(w)
}
func (s *server) newerView(ctx context.Context, options newerOptions) newerView {
view := newerView{Genre: options.genre, HidePreviews: options.hidePreviews}
episodes, err := s.cachedNewer(ctx, options.genre)
if err != nil {
s.logger.Printf("TVer の新着取得に失敗(genre=%s): %s", options.genre, platformTokenPattern.ReplaceAllString(err.Error(), "${1}[REDACTED]"))
view.Failed = true
return view
}
for _, episode := range episodes {
if options.hidePreviews && episode.Duration <= previewMaxSeconds {
continue
}
endAt := time.Unix(episode.EndAt, 0).In(s.zone)
view.Episodes = append(view.Episodes, episodeView{
URL: "https://tver.jp/episodes/" + url.PathEscape(episode.ID),
ThumbnailURL: "https://statics.tver.jp" + episode.ThumbnailPath,
SeriesTitle: episode.SeriesTitle,
Title: episode.Title,
BroadcasterName: episode.BroadcasterName,
DurationMinutes: durationMinutes(episode.Duration),
BroadcastDateLabel: episode.BroadcastDateLabel,
EndAtText: endAt.Format("2006-01-02 15:04 MST"),
EndAtISO: endAt.Format(time.RFC3339),
})
}
return view
}
func durationMinutes(seconds int64) int64 {
minutes := seconds / 60
if seconds%60 != 0 {
minutes++
}
return max(1, minutes)
}
func (s *server) cachedNewer(ctx context.Context, genre string) ([]tver.Episode, error) {
s.cacheMu.Lock()
defer s.cacheMu.Unlock()
if cached, ok := s.cache[genre]; ok && s.now().Before(cached.expiresAt) {
return cached.episodes, nil
}
episodes, err := s.fetcher.FetchNewer(ctx, genre)
if err != nil {
return nil, err
}
s.cache[genre] = cacheEntry{episodes: episodes, expiresAt: s.now().Add(newerCacheTTL)}
return episodes, nil
}
+247
View File
@@ -0,0 +1,247 @@
package web
import (
"bytes"
"context"
"errors"
"io"
"log"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"gitea.kazu634.com/kazu634/tver/internal/tver"
)
type fakeFetcher struct {
episodes []tver.Episode
episodesByGenre map[string][]tver.Episode
err error
calls []string
}
func (f *fakeFetcher) FetchNewer(_ context.Context, genre string) ([]tver.Episode, error) {
f.calls = append(f.calls, genre)
if f.episodesByGenre != nil {
return f.episodesByGenre[genre], f.err
}
return f.episodes, f.err
}
func newTestHandler(fetcher *fakeFetcher, now func() time.Time) http.Handler {
if now == nil {
now = func() time.Time { return time.Date(2026, 10, 3, 0, 0, 0, 0, time.UTC) }
}
return NewHandler(fetcher, now, log.New(io.Discard, "", 0))
}
func getPage(handler http.Handler, path string) *httptest.ResponseRecorder {
response := httptest.NewRecorder()
handler.ServeHTTP(response, httptest.NewRequest(http.MethodGet, path, nil))
return response
}
func TestNewerPageShowsEpisodeFieldsAndJST(t *testing.T) {
endAt := time.Date(2026, 10, 3, 21, 34, 0, 0, time.UTC).Unix()
fetcher := &fakeFetcher{episodes: []tver.Episode{{
ID: "ep123", SeriesTitle: "番組名", Title: "第1話", BroadcasterName: "放送局",
Duration: 2712, EndAt: endAt, ThumbnailPath: "/images/ep123.jpg",
BroadcastDateLabel: "10月3日放送分",
}}}
response := getPage(newTestHandler(fetcher, nil), "/")
if response.Code != http.StatusOK {
t.Fatalf("status = %d", response.Code)
}
body := response.Body.String()
for _, want := range []string{
"番組名", "第1話", "放送局", "10月3日放送分",
"長さ: 46分",
"https://statics.tver.jp/images/ep123.jpg",
"https://tver.jp/episodes/ep123",
"2026-10-04 06:34 JST", `datetime="2026-10-04T06:34:00&#43;09:00"`,
`value="drama" selected`, `name="hide_previews" value="1" checked`,
`hx-get="/newer"`, `hx-target="#newer-results"`,
} {
if !strings.Contains(body, want) {
t.Errorf("page missing %q", want)
}
}
if len(fetcher.calls) != 1 || fetcher.calls[0] != "drama" {
t.Errorf("calls = %v", fetcher.calls)
}
}
func TestDurationMinutesRoundsUp(t *testing.T) {
for _, tt := range []struct {
seconds int64
minutes int64
}{
{0, 1}, {1, 1}, {59, 1}, {60, 1}, {61, 2}, {2712, 46},
} {
if got := durationMinutes(tt.seconds); got != tt.minutes {
t.Errorf("%d seconds = %d minutes; want %d", tt.seconds, got, tt.minutes)
}
}
}
func TestNewerFragmentUpdatesURLAndFullPageRestoresState(t *testing.T) {
fetcher := &fakeFetcher{episodes: []tver.Episode{{ID: "variety-1", Duration: 200}}}
handler := newTestHandler(fetcher, nil)
response := getPage(handler, "/newer?genre=variety&hide_previews=0")
if response.Code != http.StatusOK {
t.Fatalf("fragment status = %d", response.Code)
}
if got := response.Header().Get("HX-Push-Url"); got != "/?genre=variety&hide_previews=0" {
t.Errorf("HX-Push-Url = %q", got)
}
if body := response.Body.String(); !strings.Contains(body, `id="newer-results"`) || !strings.Contains(body, "variety-1") || strings.Contains(body, "<html") {
t.Errorf("not an episode fragment: %s", body)
}
reload := getPage(handler, "/?genre=variety&hide_previews=0")
if reload.Code != http.StatusOK || !strings.Contains(reload.Body.String(), `value="variety" selected`) || strings.Contains(reload.Body.String(), `value="1" checked`) || !strings.Contains(reload.Body.String(), "variety-1") {
t.Errorf("reload did not restore state: status=%d body=%s", reload.Code, reload.Body.String())
}
if len(fetcher.calls) != 1 || fetcher.calls[0] != "variety" {
t.Errorf("calls = %v", fetcher.calls)
}
}
func TestNewerInvalidGenreReturns400WithoutFetch(t *testing.T) {
fetcher := &fakeFetcher{}
handler := newTestHandler(fetcher, nil)
for _, path := range []string{"/?genre=news", "/newer?genre=news", "/?genre=", "/?hide_previews=invalid", "/newer?hide_previews="} {
response := getPage(handler, path)
if response.Code != http.StatusBadRequest {
t.Errorf("%s: status = %d", path, response.Code)
}
}
if len(fetcher.calls) != 0 {
t.Errorf("unexpected fetch calls: %v", fetcher.calls)
}
}
func TestPreviewFilterBoundaryAndToggle(t *testing.T) {
fetcher := &fakeFetcher{episodes: []tver.Episode{
{ID: "preview-180", Duration: 180},
{ID: "episode-181", Duration: 181},
}}
handler := newTestHandler(fetcher, nil)
hidden := getPage(handler, "/")
if strings.Contains(hidden.Body.String(), "preview-180") || !strings.Contains(hidden.Body.String(), "episode-181") {
t.Errorf("default filter: %s", hidden.Body.String())
}
shown := getPage(handler, "/newer?genre=drama&hide_previews=0")
if !strings.Contains(shown.Body.String(), "preview-180") || !strings.Contains(shown.Body.String(), "episode-181") {
t.Errorf("show previews: %s", shown.Body.String())
}
checked := getPage(handler, "/newer?genre=drama&hide_previews=0&hide_previews=1")
if strings.Contains(checked.Body.String(), "preview-180") || checked.Header().Get("HX-Push-Url") != "/?genre=drama&hide_previews=1" {
t.Errorf("checked form state: %s", checked.Body.String())
}
if len(fetcher.calls) != 1 {
t.Errorf("filter caused %d TVer fetches; want 1", len(fetcher.calls))
}
}
func TestNewerCacheExpiresPerGenre(t *testing.T) {
now := time.Date(2026, 10, 3, 0, 0, 0, 0, time.UTC)
fetcher := &fakeFetcher{episodes: []tver.Episode{{ID: "ep", Duration: 200}}}
handler := newTestHandler(fetcher, func() time.Time { return now })
getPage(handler, "/")
now = now.Add(9*time.Minute + 59*time.Second)
getPage(handler, "/?genre=drama&hide_previews=0")
getPage(handler, "/?genre=variety")
if got := strings.Join(fetcher.calls, ","); got != "drama,variety" {
t.Fatalf("before expiry calls = %q", got)
}
now = now.Add(2 * time.Second)
getPage(handler, "/")
getPage(handler, "/?genre=variety")
if got := strings.Join(fetcher.calls, ","); got != "drama,variety,drama" {
t.Errorf("after expiry calls = %q", got)
}
}
func TestNewerCacheKeepsGenreResultsSeparateAndExpiresAtTenMinutes(t *testing.T) {
now := time.Date(2026, 10, 3, 0, 0, 0, 0, time.UTC)
fetcher := &fakeFetcher{episodesByGenre: map[string][]tver.Episode{
"drama": {{ID: "drama-only", Duration: 200}},
"variety": {{ID: "variety-only", Duration: 200}},
}}
handler := newTestHandler(fetcher, func() time.Time { return now })
checkPage := func(path, wantID, absentID string) {
t.Helper()
response := getPage(handler, path)
body := response.Body.String()
if response.Code != http.StatusOK || !strings.Contains(body, "/episodes/"+wantID) || strings.Contains(body, "/episodes/"+absentID) {
t.Errorf("%s at %s: status=%d, wrong genre contents: %s", path, now.Format(time.RFC3339), response.Code, body)
}
}
checkCalls := func(want string) {
t.Helper()
if got := strings.Join(fetcher.calls, ","); got != want {
t.Errorf("fetch calls = %q; want %q", got, want)
}
}
checkPage("/", "drama-only", "variety-only")
now = now.Add(5 * time.Minute)
checkPage("/?genre=variety", "variety-only", "drama-only")
now = now.Add(4*time.Minute + 59*time.Second)
checkPage("/", "drama-only", "variety-only")
checkCalls("drama,variety")
now = now.Add(time.Second)
checkPage("/", "drama-only", "variety-only")
checkPage("/?genre=variety", "variety-only", "drama-only")
checkCalls("drama,variety,drama")
now = now.Add(5 * time.Minute)
checkPage("/?genre=variety", "variety-only", "drama-only")
checkCalls("drama,variety,drama,variety")
}
func TestNewerPageEscapesUntrustedEpisodeFields(t *testing.T) {
fetcher := &fakeFetcher{episodes: []tver.Episode{{
ID: "safe-id", SeriesTitle: `<script>alert(1)</script>`, Title: `<b>title</b>`,
BroadcasterName: `<img src=x onerror=alert(1)>`, BroadcastDateLabel: `<svg onload=alert(1)>`,
ThumbnailPath: `/image.jpg" onerror="alert(1)`, Duration: 200,
}}}
response := getPage(newTestHandler(fetcher, nil), "/")
if response.Code != http.StatusOK {
t.Fatalf("status = %d", response.Code)
}
body := response.Body.String()
for _, unsafe := range []string{"<script>alert(1)</script>", "<b>title</b>", "<img src=x", "<svg onload", `onerror="alert(1)"`} {
if strings.Contains(body, unsafe) {
t.Errorf("untrusted markup was not escaped: %q", unsafe)
}
}
for _, escaped := range []string{"&lt;script&gt;", "&lt;b&gt;title&lt;/b&gt;", "&lt;img src=x onerror=alert(1)&gt;", "&lt;svg onload=alert(1)&gt;"} {
if !strings.Contains(body, escaped) {
t.Errorf("escaped field missing: %q", escaped)
}
}
}
func TestNewerFailureShowsFriendlyMessageAndRedactsToken(t *testing.T) {
fetcher := &fakeFetcher{err: errors.New("GET https://platform-api.tver.jp/?platform_token=secret-123&x=1: upstream unavailable")}
var logs bytes.Buffer
handler := NewHandler(fetcher, time.Now, log.New(&logs, "", 0))
response := getPage(handler, "/")
if response.Code != http.StatusOK || !strings.Contains(response.Body.String(), "新着を取得できませんでした") {
t.Errorf("error page: status=%d body=%s", response.Code, response.Body.String())
}
if strings.Contains(response.Body.String(), "upstream") || strings.Contains(response.Body.String(), "secret-123") {
t.Errorf("error detail leaked into page: %s", response.Body.String())
}
fragment := getPage(handler, "/newer?genre=variety&hide_previews=0")
if fragment.Code != http.StatusOK || !strings.Contains(fragment.Body.String(), `id="newer-results"`) || !strings.Contains(fragment.Body.String(), "新着を取得できませんでした") || strings.Contains(fragment.Body.String(), "<html") {
t.Errorf("error fragment: status=%d body=%s", fragment.Code, fragment.Body.String())
}
if fragment.Header().Get("HX-Push-Url") != "/?genre=variety&hide_previews=0" || strings.Contains(fragment.Body.String(), "secret-123") {
t.Errorf("error fragment URL or token leak: header=%q body=%s", fragment.Header().Get("HX-Push-Url"), fragment.Body.String())
}
if !strings.Contains(logs.String(), "upstream unavailable") || strings.Contains(logs.String(), "secret-123") {
t.Errorf("log detail or token redaction failed: %s", logs.String())
}
}
+10
View File
@@ -3,3 +3,13 @@ header { display: flex; align-items: center; gap: 2rem; padding: 1rem 2rem; bord
nav { display: flex; flex-wrap: wrap; gap: 1.5rem; } nav { display: flex; flex-wrap: wrap; gap: 1.5rem; }
nav a { color: #145a86; } nav a { color: #145a86; }
main { max-width: 72rem; margin: 2rem auto; padding: 0 2rem; } main { max-width: 72rem; margin: 2rem auto; padding: 0 2rem; }
#newer-controls { display: flex; align-items: center; flex-wrap: wrap; gap: 1rem; margin-bottom: 2rem; }
#newer-controls select, #newer-controls button { font: inherit; padding: .35rem .6rem; }
.episode-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(17rem, 1fr)); gap: 1.25rem; }
.episode-card { border: 1px solid #d6dee4; border-radius: .5rem; overflow: hidden; }
.episode-card a { color: inherit; text-decoration: none; }
.episode-card a:hover h2 { text-decoration: underline; }
.episode-card img { display: block; width: 100%; aspect-ratio: 16 / 9; object-fit: cover; background: #e5ebef; }
.episode-copy { padding: .8rem 1rem; }
.episode-copy h2 { font-size: 1.1rem; margin: 0 0 .4rem; }
.episode-copy p { margin: .3rem 0; }
+5 -1
View File
@@ -18,7 +18,11 @@
</nav> </nav>
</header> </header>
<main> <main>
<h1>{{.Title}}</h1> {{if .Newer}}
{{template "newer_page" .Newer}}
{{else}}
<h1>{{.Title}}</h1>
{{end}}
</main> </main>
</body> </body>
</html>{{end}} </html>{{end}}
+47
View File
@@ -0,0 +1,47 @@
{{define "newer_page"}}
<h1>新着</h1>
<form id="newer-controls" action="/" method="get"
hx-get="/newer" hx-trigger="change, submit"
hx-target="#newer-results" hx-swap="outerHTML">
<label for="genre">ジャンル</label>
<select id="genre" name="genre">
<option value="drama" {{if eq .Genre "drama"}}selected{{end}}>ドラマ</option>
<option value="variety" {{if eq .Genre "variety"}}selected{{end}}>バラエティ</option>
</select>
<input type="hidden" name="hide_previews" value="0">
<label for="hide-previews">
<input id="hide-previews" type="checkbox" name="hide_previews" value="1" {{if .HidePreviews}}checked{{end}}>
予告を隠す
</label>
<button type="submit">表示</button>
</form>
{{template "newer_results" .}}
{{end}}
{{define "newer_results"}}
<section id="newer-results" aria-label="新着一覧" aria-live="polite">
{{if .Failed}}
<p role="status">新着を取得できませんでした</p>
{{else if not .Episodes}}
<p>表示する新着はありません</p>
{{else}}
<div class="episode-grid">
{{range .Episodes}}
<article class="episode-card">
<a href="{{.URL}}">
<img src="{{.ThumbnailURL}}" alt="" loading="lazy">
<div class="episode-copy">
<h2>{{.SeriesTitle}}</h2>
<p>{{.Title}}</p>
<p>放送局: {{.BroadcasterName}}</p>
<p>長さ: {{.DurationMinutes}}分</p>
<p>放送日: {{.BroadcastDateLabel}}</p>
<p>配信終了: <time datetime="{{.EndAtISO}}">{{.EndAtText}}</time></p>
</div>
</a>
</article>
{{end}}
</div>
{{end}}
</section>
{{end}}