package web import ( "errors" "fmt" "net/http" "os" "path/filepath" "strconv" "strings" "gitea.kazu634.com/kazu634/tver/internal/store" "github.com/jackc/pgx/v5" ) const libraryLimit = 100 type libraryItemView struct { ID int64 ThumbnailURL, SeriesTitle, Title string BroadcasterName, DurationText string FileSizeText, FinishedAtText string } type libraryView struct { Items []libraryItemView } type videoView struct { libraryItemView VideoURL string } func (s *server) libraryPage(w http.ResponseWriter, r *http.Request) { jobs, err := s.jobs.ListSucceededJobs(r.Context(), libraryLimit) if err != nil { s.logger.Printf("ライブラリの取得に失敗: %v", err) http.Error(w, "ライブラリを取得できませんでした", http.StatusInternalServerError) return } view := libraryView{Items: make([]libraryItemView, 0, len(jobs))} for _, job := range jobs { view.Items = append(view.Items, s.libraryItem(job)) } s.render(w, "layout", page{Title: "ライブラリ", Library: &view}) } func (s *server) videoPage(w http.ResponseWriter, r *http.Request) { job, ok := s.succeededJob(w, r) if !ok { return } item := s.libraryItem(job) s.render(w, "layout", page{Title: job.SeriesTitle + " " + job.Title, Video: &videoView{libraryItemView: item, VideoURL: "/videos/" + strconv.FormatInt(job.ID, 10)}}) } func (s *server) libraryItem(job store.Job) libraryItemView { item := libraryItemView{ID: job.ID, ThumbnailURL: "https://statics.tver.jp" + job.ThumbnailPath, SeriesTitle: job.SeriesTitle, Title: job.Title, BroadcasterName: job.BroadcasterName, DurationText: fmt.Sprintf("%d分", durationMinutes(int64(job.DurationSeconds)))} if job.FileSizeBytes != nil { item.FileSizeText = formatBytes(float64(*job.FileSizeBytes)) } if job.FinishedAt != nil { item.FinishedAtText = job.FinishedAt.In(s.zone).Format("2006-01-02 15:04 MST") } return item } func (s *server) serveVideo(w http.ResponseWriter, r *http.Request) { job, ok := s.succeededJob(w, r) if !ok || job.FilePath == nil { if ok { http.NotFound(w, r) } return } path, err := secureVideoPath(s.videoDir, *job.FilePath, true) if err != nil { http.NotFound(w, r) return } file, err := os.Open(path) if err != nil { http.NotFound(w, r) return } defer file.Close() info, err := file.Stat() if err != nil || !info.Mode().IsRegular() { http.NotFound(w, r) return } w.Header().Set("Content-Type", "video/mp4") http.ServeContent(w, r, filepath.Base(path), info.ModTime(), file) } func (s *server) deleteVideo(w http.ResponseWriter, r *http.Request) { job, ok := s.succeededJob(w, r) if !ok { return } if job.FilePath != nil { path, err := secureVideoPath(s.videoDir, *job.FilePath, false) if err != nil { http.Error(w, "動画ファイルの場所が不正です", http.StatusConflict) return } if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) { s.logger.Printf("動画ファイルの削除に失敗: %v", err) http.Error(w, "動画ファイルを削除できませんでした", http.StatusInternalServerError) return } } if err := s.jobs.DeleteSucceededJob(r.Context(), job.ID); err != nil { s.logger.Printf("動画の記録削除に失敗: %v", err) http.Error(w, "動画の記録を削除できませんでした", http.StatusInternalServerError) return } http.Redirect(w, r, "/library", http.StatusSeeOther) } func (s *server) succeededJob(w http.ResponseWriter, r *http.Request) (store.Job, bool) { id, err := strconv.ParseInt(r.PathValue("id"), 10, 64) if err != nil || id <= 0 { http.NotFound(w, r) return store.Job{}, false } job, err := s.jobs.GetSucceededJob(r.Context(), id) if errors.Is(err, pgx.ErrNoRows) { http.NotFound(w, r) return store.Job{}, false } if err != nil { s.logger.Printf("完了ジョブの取得に失敗: %v", err) http.Error(w, "動画を取得できませんでした", http.StatusInternalServerError) return store.Job{}, false } return job, true } func secureVideoPath(root, relative string, mustExist bool) (string, error) { if relative == "" || filepath.IsAbs(relative) { return "", errors.New("video path must be relative") } clean := filepath.Clean(relative) if clean == "." || clean == ".." || strings.HasPrefix(clean, ".."+string(filepath.Separator)) { return "", errors.New("video path leaves root") } rootAbs, err := filepath.Abs(root) if err != nil { return "", err } rootReal, err := filepath.EvalSymlinks(rootAbs) if err != nil { return "", err } candidate := filepath.Join(rootAbs, clean) if err := insideRoot(rootAbs, candidate); err != nil { return "", err } resolved, err := filepath.EvalSymlinks(candidate) if err == nil { if err := insideRoot(rootReal, resolved); err != nil { return "", err } return candidate, nil } if mustExist || !errors.Is(err, os.ErrNotExist) { return "", err } ancestor := filepath.Dir(candidate) for { resolvedAncestor, evalErr := filepath.EvalSymlinks(ancestor) if evalErr == nil { if err := insideRoot(rootReal, resolvedAncestor); err != nil { return "", err } return candidate, nil } if !errors.Is(evalErr, os.ErrNotExist) || ancestor == rootAbs { return "", evalErr } ancestor = filepath.Dir(ancestor) } } func insideRoot(root, path string) error { rel, err := filepath.Rel(root, path) if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) { return errors.New("path leaves video root") } return nil }