ライブラリ・再生・削除を追加(Range 配信、パスの安全確認、共有ファイル名規則)
This commit is contained in:
@@ -0,0 +1,195 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user