ファイル名の上限を拡大し、動画の配信・削除を os.Root に置き換え(エピソード ID は 64 バイト上限)
This commit is contained in:
+36
-15
@@ -1,23 +1,41 @@
|
||||
package media
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
const maxComponentRunes = 80
|
||||
const maxComponentBytes = 60
|
||||
const (
|
||||
maxSeriesBytes = 90
|
||||
maxEpisodeBytes = 140
|
||||
maxEpisodeIDBytes = 64
|
||||
maxFilenameBytes = 255
|
||||
)
|
||||
|
||||
// RelativeVideoPath は worker と web が共有する保存先の相対パスを返す。
|
||||
func RelativeVideoPath(seriesTitle, episodeTitle, episodeID string) string {
|
||||
series := sanitizeComponent(seriesTitle)
|
||||
episode := sanitizeComponent(episodeTitle)
|
||||
id := sanitizeComponent(episodeID)
|
||||
return filepath.Join(series, series+" "+episode+" ["+id+"].mp4")
|
||||
func RelativeVideoPath(seriesTitle, episodeTitle, episodeID string) (string, error) {
|
||||
series := sanitizeComponent(seriesTitle, maxSeriesBytes)
|
||||
episode := sanitizeComponent(episodeTitle, maxEpisodeBytes)
|
||||
id := sanitizeComponent(episodeID, -1)
|
||||
if len(id) > maxEpisodeIDBytes {
|
||||
return "", fmt.Errorf("エピソードIDはサニタイズ後%dバイト以下である必要があります: %dバイト", maxEpisodeIDBytes, len(id))
|
||||
}
|
||||
|
||||
// A directory entry must fit within the NFS namlen limit. Keep the episode
|
||||
// ID intact and use the remaining bytes for the episode title.
|
||||
const separators = " [].mp4"
|
||||
seriesInFilename := series
|
||||
titleBytes := maxFilenameBytes - len(seriesInFilename) - len(id) - len(separators)
|
||||
if titleBytes < 0 {
|
||||
titleBytes = 0
|
||||
}
|
||||
episode = truncateComponent(episode, titleBytes, false)
|
||||
return filepath.Join(series, seriesInFilename+" "+episode+" ["+id+"].mp4"), nil
|
||||
}
|
||||
|
||||
func sanitizeComponent(value string) string {
|
||||
func sanitizeComponent(value string, maxBytes int) string {
|
||||
var result []rune
|
||||
lastReplacement := false
|
||||
for _, r := range value {
|
||||
@@ -39,22 +57,25 @@ func sanitizeComponent(value string) string {
|
||||
if clean == "" || clean == "." || clean == ".." {
|
||||
return "名称未設定"
|
||||
}
|
||||
runes := []rune(clean)
|
||||
if len(runes) > maxComponentRunes {
|
||||
runes = runes[:maxComponentRunes]
|
||||
return truncateComponent(clean, maxBytes, true)
|
||||
}
|
||||
|
||||
func truncateComponent(value string, maxBytes int, fallback bool) string {
|
||||
if maxBytes < 0 {
|
||||
return value
|
||||
}
|
||||
var limited []rune
|
||||
bytes := 0
|
||||
for _, r := range runes {
|
||||
for _, r := range value {
|
||||
size := len(string(r))
|
||||
if bytes+size > maxComponentBytes {
|
||||
if bytes+size > maxBytes {
|
||||
break
|
||||
}
|
||||
limited = append(limited, r)
|
||||
bytes += size
|
||||
}
|
||||
clean = strings.Trim(string(limited), " .")
|
||||
if clean == "" || clean == "." || clean == ".." {
|
||||
clean := strings.Trim(string(limited), " .")
|
||||
if fallback && (clean == "" || clean == "." || clean == "..") {
|
||||
return "名称未設定"
|
||||
}
|
||||
return clean
|
||||
|
||||
+143
-30
@@ -1,6 +1,7 @@
|
||||
package media
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -9,19 +10,28 @@ import (
|
||||
)
|
||||
|
||||
func TestRelativeVideoPath(t *testing.T) {
|
||||
got := RelativeVideoPath("番組名", "第1話", "ep123")
|
||||
got, err := RelativeVideoPath("番組名", "第1話", "ep123")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := filepath.Join("番組名", "番組名 第1話 [ep123].mp4")
|
||||
if got != want {
|
||||
t.Fatalf("path=%q want=%q", got, want)
|
||||
}
|
||||
other := RelativeVideoPath("番組名", "第1話", "ep456")
|
||||
other, err := RelativeVideoPath("番組名", "第1話", "ep456")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got == other || !strings.Contains(other, "[ep456]") {
|
||||
t.Fatalf("ID による衝突回避: %q %q", got, other)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRelativeVideoPathSanitizesComponents(t *testing.T) {
|
||||
got := RelativeVideoPath(" 番組:/\\*?<>|\"\x00\n\t\x7f\u0085. ", "\x00\n", "../")
|
||||
got, err := RelativeVideoPath(" 番組:/\\*?<>|\"\x00\n\t\x7f\u0085. ", "\x00\n", "../")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if filepath.IsAbs(got) || strings.ContainsAny(got, `:*?<>|"`) || strings.Contains(got, "..") {
|
||||
t.Fatalf("禁止文字が残った: %q", got)
|
||||
}
|
||||
@@ -38,27 +48,122 @@ func TestRelativeVideoPathSanitizesComponents(t *testing.T) {
|
||||
|
||||
func TestRelativeVideoPathHandlesEmptyAndLongNames(t *testing.T) {
|
||||
for _, name := range []string{"", " ", ".", "..", "..."} {
|
||||
got := RelativeVideoPath(name, name, "")
|
||||
got, err := RelativeVideoPath(name, name, "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(got, "名称未設定") {
|
||||
t.Errorf("空の代替名 %q: %q", name, got)
|
||||
}
|
||||
}
|
||||
long := strings.Repeat("界", 200)
|
||||
got := RelativeVideoPath(long, long, "id")
|
||||
got, err := RelativeVideoPath(long, long, "id")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
parts := strings.Split(filepath.ToSlash(got), "/")
|
||||
if len(parts) != 2 || len([]byte(parts[0])) != 60 || !utf8.ValidString(parts[0]) {
|
||||
t.Fatalf("番組名の60バイト境界: %q (%d bytes)", parts[0], len([]byte(parts[0])))
|
||||
if len(parts) != 2 || len([]byte(parts[0])) != maxSeriesBytes || !utf8.ValidString(parts[0]) {
|
||||
t.Fatalf("番組名の%dバイト境界: %q (%d bytes)", maxSeriesBytes, parts[0], len([]byte(parts[0])))
|
||||
}
|
||||
for _, part := range strings.Split(filepath.ToSlash(got), "/") {
|
||||
if len([]byte(part)) > 240 {
|
||||
t.Fatalf("長すぎる要素: %d bytes", len([]byte(part)))
|
||||
}
|
||||
if !utf8.ValidString(part) {
|
||||
t.Fatalf("不正な UTF-8: %q", part)
|
||||
}
|
||||
if len([]byte(parts[1])) > maxFilenameBytes || !utf8.ValidString(parts[1]) {
|
||||
t.Fatalf("ファイル名の上限: %q (%d bytes)", parts[1], len([]byte(parts[1])))
|
||||
}
|
||||
if got := sanitizeComponent(strings.Repeat("a", 61)); len([]byte(got)) != 60 {
|
||||
t.Fatalf("ASCII の60バイト境界: %d %q", len([]byte(got)), got)
|
||||
if got := sanitizeComponent(strings.Repeat("a", maxEpisodeBytes+1), maxEpisodeBytes); len([]byte(got)) != maxEpisodeBytes {
|
||||
t.Fatalf("ASCII の%dバイト境界: %d %q", maxEpisodeBytes, len([]byte(got)), got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRelativeVideoPathEpisodeIDLimit(t *testing.T) {
|
||||
id := strings.Repeat("i", maxEpisodeIDBytes)
|
||||
got, err := RelativeVideoPath(strings.Repeat("s", maxSeriesBytes), strings.Repeat("t", maxEpisodeBytes), id)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
parts := strings.Split(filepath.ToSlash(got), "/")
|
||||
if len(parts) != 2 || len([]byte(parts[1])) > maxFilenameBytes || !strings.Contains(parts[1], "["+id+"]") {
|
||||
t.Fatalf("64バイトIDのファイル名=%q", got)
|
||||
}
|
||||
if _, err := RelativeVideoPath("series", "episode", strings.Repeat("i", maxEpisodeIDBytes+1)); err == nil {
|
||||
t.Fatal("65バイトのエピソードIDを受理した")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRelativeVideoPathUsesFilenameBudgetInRequiredOrder(t *testing.T) {
|
||||
series := strings.Repeat("s", maxSeriesBytes)
|
||||
title := strings.Repeat("t", maxEpisodeBytes)
|
||||
for _, tt := range []struct {
|
||||
name string
|
||||
idBytes int
|
||||
wantSeriesInFile int
|
||||
wantEpisodeInFile int
|
||||
wantFilenameBytes int
|
||||
}{
|
||||
{"ちょうど255バイト", 17, 90, 140, 255},
|
||||
{"回タイトルから1バイト削る", 18, 90, 139, 255},
|
||||
{"64バイトIDでも番組名を維持", 64, 90, 93, 255},
|
||||
} {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
id := strings.Repeat("i", tt.idBytes)
|
||||
path, err := RelativeVideoPath(series, title, id)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
parts := strings.Split(filepath.ToSlash(path), "/")
|
||||
if len(parts) != 2 || parts[0] != series {
|
||||
t.Fatalf("ディレクトリ名=%q", parts)
|
||||
}
|
||||
wantFilename := strings.Repeat("s", tt.wantSeriesInFile) + " " + strings.Repeat("t", tt.wantEpisodeInFile) + " [" + id + "].mp4"
|
||||
if parts[1] != wantFilename || len([]byte(parts[1])) != tt.wantFilenameBytes {
|
||||
t.Fatalf("filename=%q (%d bytes) want=%q (%d bytes)", parts[1], len([]byte(parts[1])), wantFilename, tt.wantFilenameBytes)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRelativeVideoPathEpisodeIDNearAndBeyondFilenameLimit(t *testing.T) {
|
||||
for _, idBytes := range []int{64, 65, 248, 255, 256} {
|
||||
t.Run(fmt.Sprintf("id_%d_bytes", idBytes), func(t *testing.T) {
|
||||
id := strings.Repeat("i", idBytes)
|
||||
path, err := RelativeVideoPath("series", "episode", id)
|
||||
if idBytes > 64 {
|
||||
if err == nil || path != "" {
|
||||
t.Fatalf("%dバイトID: path=%q err=%v, want error", idBytes, path, err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "64バイト以下") {
|
||||
t.Fatalf("%dバイトIDのエラー理由=%q", idBytes, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
parts := strings.Split(filepath.ToSlash(path), "/")
|
||||
if len(parts) != 2 {
|
||||
t.Fatalf("parts=%q", parts)
|
||||
}
|
||||
if !strings.Contains(parts[1], "["+id+"]") {
|
||||
t.Fatalf("episode ID was changed: %q", parts[1])
|
||||
}
|
||||
if got := len([]byte(parts[1])); got > maxFilenameBytes {
|
||||
t.Fatalf("episode ID %d bytes produced %d-byte filename above %d: %q", idBytes, got, maxFilenameBytes, parts[1])
|
||||
}
|
||||
if !utf8.ValidString(parts[1]) {
|
||||
t.Fatalf("filename is not valid UTF-8: %q", parts[1])
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRelativeVideoPathHasNoRuneLimit(t *testing.T) {
|
||||
series := strings.Repeat("a", 90)
|
||||
title := strings.Repeat("b", 100)
|
||||
got, err := RelativeVideoPath(series, title, "id")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
parts := strings.Split(filepath.ToSlash(got), "/")
|
||||
if parts[0] != series || !strings.Contains(parts[1], " "+title+" [id].mp4") {
|
||||
t.Fatalf("80 rune limit remains: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -69,11 +174,11 @@ func TestSanitizeComponentTrimsWhitespaceBeforeLengthLimit(t *testing.T) {
|
||||
{"前後の空白", " \t 番組名\n ", "_ 番組名_"},
|
||||
{"空白のみ", " ", "名称未設定"},
|
||||
{"制御文字のみ", "\t\n", "_"},
|
||||
{"多数の先頭空白", strings.Repeat(" ", maxComponentRunes+10) + "番組名", "番組名"},
|
||||
{"先頭空白と長い名前", strings.Repeat(" ", 70) + strings.Repeat("a", 70), strings.Repeat("a", 60)},
|
||||
{"多数の先頭空白", strings.Repeat(" ", 100) + "番組名", "番組名"},
|
||||
{"先頭空白と長い名前", strings.Repeat(" ", 70) + strings.Repeat("a", 100), strings.Repeat("a", maxSeriesBytes)},
|
||||
} {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := sanitizeComponent(tt.input); got != tt.want {
|
||||
if got := sanitizeComponent(tt.input, maxSeriesBytes); got != tt.want {
|
||||
t.Errorf("sanitizeComponent()=%q want=%q", got, tt.want)
|
||||
}
|
||||
})
|
||||
@@ -84,14 +189,14 @@ func TestSanitizeComponentTrimsAgainAfterLengthLimit(t *testing.T) {
|
||||
for _, tt := range []struct {
|
||||
name, input, want string
|
||||
}{
|
||||
{"60バイト目が空白", strings.Repeat("a", 59) + " " + "b", strings.Repeat("a", 59)},
|
||||
{"60バイト目がドット", strings.Repeat("a", 59) + "." + "b", strings.Repeat("a", 59)},
|
||||
{"切り詰め部分が空白のみ", "a" + strings.Repeat(" ", 59) + "b", "a"},
|
||||
{"切り詰め部分がドットのみ", "a" + strings.Repeat(".", 59) + "b", "a"},
|
||||
{"複数バイト文字の直後がドット", strings.Repeat("界", 19) + ".続", strings.Repeat("界", 19)},
|
||||
{"90バイト目が空白", strings.Repeat("a", 89) + " " + "b", strings.Repeat("a", 89)},
|
||||
{"90バイト目がドット", strings.Repeat("a", 89) + "." + "b", strings.Repeat("a", 89)},
|
||||
{"切り詰め部分が空白のみ", "a" + strings.Repeat(" ", 89) + "b", "a"},
|
||||
{"切り詰め部分がドットのみ", "a" + strings.Repeat(".", 89) + "b", "a"},
|
||||
{"複数バイト文字の直後がドット", strings.Repeat("界", 29) + ".続", strings.Repeat("界", 29)},
|
||||
} {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := sanitizeComponent(tt.input)
|
||||
got := sanitizeComponent(tt.input, maxSeriesBytes)
|
||||
if got != tt.want {
|
||||
t.Errorf("sanitizeComponent()=%q want=%q", got, tt.want)
|
||||
}
|
||||
@@ -117,15 +222,15 @@ func TestSanitizeComponentSafetyProperties(t *testing.T) {
|
||||
string([]byte{0xff, 0xfe, 'a', 0xc0, 0xaf}),
|
||||
}
|
||||
for i, input := range inputs {
|
||||
got := sanitizeComponent(input)
|
||||
got := sanitizeComponent(input, maxEpisodeBytes)
|
||||
if got == "" || got == "." || got == ".." {
|
||||
t.Errorf("case %d: 空またはdot segment: %q", i, got)
|
||||
}
|
||||
if strings.Trim(got, " .") != got {
|
||||
t.Errorf("case %d: 前後に空白またはdot: %q", i, got)
|
||||
}
|
||||
if len([]byte(got)) > maxComponentBytes || utf8.RuneCountInString(got) > maxComponentRunes {
|
||||
t.Errorf("case %d: 上限超過: bytes=%d runes=%d %q", i, len([]byte(got)), utf8.RuneCountInString(got), got)
|
||||
if len([]byte(got)) > 140 {
|
||||
t.Errorf("case %d: 上限超過: bytes=%d %q", i, len([]byte(got)), got)
|
||||
}
|
||||
if !utf8.ValidString(got) {
|
||||
t.Errorf("case %d: 不正なUTF-8: %q", i, got)
|
||||
@@ -138,14 +243,22 @@ func TestSanitizeComponentSafetyProperties(t *testing.T) {
|
||||
t.Errorf("case %d: 制御文字が残った: %U in %q", i, r, got)
|
||||
}
|
||||
}
|
||||
if again := sanitizeComponent(got); again != got {
|
||||
if again := sanitizeComponent(got, maxEpisodeBytes); again != got {
|
||||
t.Errorf("case %d: 再適用で変化: %q -> %q", i, got, again)
|
||||
}
|
||||
|
||||
path := RelativeVideoPath(input, input, input)
|
||||
path, err := RelativeVideoPath(input, input, "id")
|
||||
if err != nil {
|
||||
t.Errorf("case %d: パス生成エラー: %v", i, err)
|
||||
continue
|
||||
}
|
||||
parts := strings.Split(filepath.ToSlash(path), "/")
|
||||
if filepath.IsAbs(path) || len(parts) != 2 || parts[0] == "" || parts[0] == "." || parts[0] == ".." || !strings.HasSuffix(parts[1], ".mp4") {
|
||||
t.Errorf("case %d: 安全でない相対パス: %q parts=%q", i, path, parts)
|
||||
continue
|
||||
}
|
||||
if len([]byte(parts[0])) > 90 || len([]byte(parts[1])) > 255 {
|
||||
t.Errorf("case %d: パス要素の上限超過: dir=%d filename=%d path=%q", i, len([]byte(parts[0])), len([]byte(parts[1])), path)
|
||||
}
|
||||
if !utf8.ValidString(path) {
|
||||
t.Errorf("case %d: パスが不正なUTF-8: %q", i, path)
|
||||
|
||||
+18
-60
@@ -7,7 +7,6 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"gitea.kazu634.com/kazu634/tver/internal/store"
|
||||
"github.com/jackc/pgx/v5"
|
||||
@@ -76,12 +75,13 @@ func (s *server) serveVideo(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
return
|
||||
}
|
||||
path, err := secureVideoPath(s.videoDir, *job.FilePath, true)
|
||||
root, err := os.OpenRoot(s.videoDir)
|
||||
if err != nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
file, err := os.Open(path)
|
||||
defer root.Close()
|
||||
file, err := root.Open(*job.FilePath)
|
||||
if err != nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
@@ -93,7 +93,7 @@ func (s *server) serveVideo(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "video/mp4")
|
||||
http.ServeContent(w, r, filepath.Base(path), info.ModTime(), file)
|
||||
http.ServeContent(w, r, filepath.Base(*job.FilePath), info.ModTime(), file)
|
||||
}
|
||||
|
||||
func (s *server) deleteVideo(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -102,12 +102,24 @@ func (s *server) deleteVideo(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
if job.FilePath != nil {
|
||||
path, err := secureVideoPath(s.videoDir, *job.FilePath, false)
|
||||
if !filepath.IsLocal(*job.FilePath) {
|
||||
http.Error(w, "動画ファイルの場所が不正です", http.StatusConflict)
|
||||
return
|
||||
}
|
||||
root, err := os.OpenRoot(s.videoDir)
|
||||
if err != nil {
|
||||
http.Error(w, "動画ファイルの場所が不正です", http.StatusConflict)
|
||||
return
|
||||
}
|
||||
if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
defer root.Close()
|
||||
file, err := root.Open(*job.FilePath)
|
||||
if err == nil {
|
||||
_ = file.Close()
|
||||
} else if !errors.Is(err, os.ErrNotExist) {
|
||||
http.Error(w, "動画ファイルの場所が不正です", http.StatusConflict)
|
||||
return
|
||||
}
|
||||
if err := root.Remove(*job.FilePath); err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
s.logger.Printf("動画ファイルの削除に失敗: %v", err)
|
||||
http.Error(w, "動画ファイルを削除できませんでした", http.StatusInternalServerError)
|
||||
return
|
||||
@@ -139,57 +151,3 @@ func (s *server) succeededJob(w http.ResponseWriter, r *http.Request) (store.Job
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
@@ -135,7 +135,23 @@ func TestVideoPathTraversalAndSymlinkAreRejected(t *testing.T) {
|
||||
if err := os.Symlink(outside, filepath.Join(dir, "linked-directory")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, relative := range []string{"../outside.mp4", "show/../../outside.mp4", outsideFile, "link.mp4", "linked-directory/outside.mp4", "linked-directory/missing.mp4"} {
|
||||
relativeOutsideFile, err := filepath.Rel(dir, outsideFile)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
relativeOutsideDir, err := filepath.Rel(dir, outside)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Symlink(relativeOutsideFile, filepath.Join(dir, "relative-link.mp4")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Symlink(relativeOutsideDir, filepath.Join(dir, "relative-linked-directory")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, relative := range []string{"../outside.mp4", "show/../../outside.mp4", outsideFile,
|
||||
"link.mp4", "linked-directory/outside.mp4", "linked-directory/missing.mp4",
|
||||
"relative-link.mp4", "relative-linked-directory/outside.mp4", "relative-linked-directory/missing.mp4"} {
|
||||
jobs := &fakeJobStore{jobs: map[string]store.Job{"ep1": libraryJob(1, relative)}}
|
||||
handler := newTestHandlerWithVideoDir(&fakeFetcher{}, jobs, dir, nil)
|
||||
if got := getPage(handler, "/videos/1").Code; got != http.StatusNotFound {
|
||||
@@ -146,6 +162,89 @@ func TestVideoPathTraversalAndSymlinkAreRejected(t *testing.T) {
|
||||
t.Errorf("delete path %q status=%d jobs=%d", relative, deleted.Code, len(jobs.jobs))
|
||||
}
|
||||
}
|
||||
if got, err := os.ReadFile(outsideFile); err != nil || string(got) != "secret" {
|
||||
t.Fatalf("outside target was changed: content=%q err=%v", got, err)
|
||||
}
|
||||
for _, link := range []string{"link.mp4", "linked-directory", "relative-link.mp4", "relative-linked-directory"} {
|
||||
if _, err := os.Lstat(filepath.Join(dir, link)); err != nil {
|
||||
t.Errorf("rejected link %q was changed: %v", link, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestVideoSymlinkWithinRootCanBeServedAndDeleted(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
target := filepath.Join(dir, "target.mp4")
|
||||
if err := os.WriteFile(target, []byte("video"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Symlink("target.mp4", filepath.Join(dir, "link.mp4")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
jobs := &fakeJobStore{jobs: map[string]store.Job{"ep1": libraryJob(1, "link.mp4")}}
|
||||
handler := newTestHandlerWithVideoDir(&fakeFetcher{}, jobs, dir, nil)
|
||||
if response := getPage(handler, "/videos/1"); response.Code != http.StatusOK || response.Body.String() != "video" {
|
||||
t.Fatalf("serve: status=%d body=%q", response.Code, response.Body.String())
|
||||
}
|
||||
if response := postForm(handler, "/library/1/delete", nil); response.Code != http.StatusSeeOther || len(jobs.jobs) != 0 {
|
||||
t.Fatalf("delete: status=%d jobs=%d", response.Code, len(jobs.jobs))
|
||||
}
|
||||
if _, err := os.Stat(target); err != nil {
|
||||
t.Fatalf("symlink target was removed: %v", err)
|
||||
}
|
||||
if _, err := os.Lstat(filepath.Join(dir, "link.mp4")); !errors.Is(err, os.ErrNotExist) {
|
||||
t.Fatalf("symlink itself remains: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVideoSymlinkedDirectoryWithinRootCanBeServedAndDeleted(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
if err := os.Mkdir(filepath.Join(dir, "target"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
target := filepath.Join(dir, "target", "video.mp4")
|
||||
if err := os.WriteFile(target, []byte("video"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Symlink("target", filepath.Join(dir, "linked-directory")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
jobs := &fakeJobStore{jobs: map[string]store.Job{"ep1": libraryJob(1, "linked-directory/video.mp4")}}
|
||||
handler := newTestHandlerWithVideoDir(&fakeFetcher{}, jobs, dir, nil)
|
||||
if response := getPage(handler, "/videos/1"); response.Code != http.StatusOK || response.Body.String() != "video" {
|
||||
t.Fatalf("serve: status=%d body=%q", response.Code, response.Body.String())
|
||||
}
|
||||
if response := postForm(handler, "/library/1/delete", nil); response.Code != http.StatusSeeOther || len(jobs.jobs) != 0 {
|
||||
t.Fatalf("delete: status=%d jobs=%d", response.Code, len(jobs.jobs))
|
||||
}
|
||||
if _, err := os.Stat(target); !errors.Is(err, os.ErrNotExist) {
|
||||
t.Fatalf("target file remains: %v", err)
|
||||
}
|
||||
if _, err := os.Lstat(filepath.Join(dir, "linked-directory")); err != nil {
|
||||
t.Fatalf("directory symlink was removed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVideoAbsoluteSymlinkWithinRootIsRejectedByOSRoot(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
target := filepath.Join(dir, "target.mp4")
|
||||
if err := os.WriteFile(target, []byte("video"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Symlink(target, filepath.Join(dir, "absolute-link.mp4")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
jobs := &fakeJobStore{jobs: map[string]store.Job{"ep1": libraryJob(1, "absolute-link.mp4")}}
|
||||
handler := newTestHandlerWithVideoDir(&fakeFetcher{}, jobs, dir, nil)
|
||||
if got := getPage(handler, "/videos/1").Code; got != http.StatusNotFound {
|
||||
t.Fatalf("absolute symlink serve status=%d", got)
|
||||
}
|
||||
if response := postForm(handler, "/library/1/delete", nil); response.Code != http.StatusConflict || len(jobs.jobs) != 1 {
|
||||
t.Fatalf("absolute symlink delete: status=%d jobs=%d", response.Code, len(jobs.jobs))
|
||||
}
|
||||
if _, err := os.Stat(target); err != nil {
|
||||
t.Fatalf("target was changed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteVideoFileThenRecord(t *testing.T) {
|
||||
|
||||
@@ -172,6 +172,11 @@ func (r *Runner) ProcessJob(ctx context.Context, job store.Job) {
|
||||
return
|
||||
}
|
||||
defer os.RemoveAll(workDir)
|
||||
relative, err := media.RelativeVideoPath(job.SeriesTitle, job.Title, job.EpisodeID)
|
||||
if err != nil {
|
||||
r.failIfRunning(job.ID, "invalid_output", redact(err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
downloaded, stderr, err := r.download(ctx, job, workDir)
|
||||
if errors.Is(err, errShutdown) {
|
||||
@@ -214,7 +219,6 @@ func (r *Runner) ProcessJob(ctx context.Context, job store.Job) {
|
||||
r.failIfRunning(job.ID, "unknown", "移動状態を記録できません: "+err.Error())
|
||||
return
|
||||
}
|
||||
relative := media.RelativeVideoPath(job.SeriesTitle, job.Title, job.EpisodeID)
|
||||
destination := filepath.Join(r.config.VideoDir, relative)
|
||||
if err := os.MkdirAll(filepath.Dir(destination), 0o755); err != nil {
|
||||
r.failIfRunning(job.ID, "unknown", "保存先を作成できません: "+err.Error())
|
||||
|
||||
@@ -228,6 +228,15 @@ func runningJob() store.Job {
|
||||
return store.Job{ID: 7, EpisodeID: "ep123", SeriesTitle: "番組名", Title: "第1話", Status: "running"}
|
||||
}
|
||||
|
||||
func relativeVideoPath(t *testing.T, seriesTitle, episodeTitle, episodeID string) string {
|
||||
t.Helper()
|
||||
path, err := media.RelativeVideoPath(seriesTitle, episodeTitle, episodeID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
func TestProcessJobDownloadsProbesMovesAndCleans(t *testing.T) {
|
||||
t.Setenv("FAKE_YTDLP_MODE", "success")
|
||||
t.Setenv("FAKE_FFPROBE_MODE", "valid")
|
||||
@@ -236,7 +245,7 @@ func TestProcessJobDownloadsProbesMovesAndCleans(t *testing.T) {
|
||||
t.Setenv("FAKE_ARGS_FILE", argsFile)
|
||||
jobs := &fakeStore{job: runningJob()}
|
||||
testRunner(t, jobs, dir).ProcessJob(context.Background(), jobs.job)
|
||||
want := filepath.ToSlash(media.RelativeVideoPath("番組名", "第1話", "ep123"))
|
||||
want := filepath.ToSlash(relativeVideoPath(t, "番組名", "第1話", "ep123"))
|
||||
if !jobs.completed || jobs.completePath != want || jobs.completeSize == 0 {
|
||||
t.Fatalf("complete: done=%v path=%q size=%d", jobs.completed, jobs.completePath, jobs.completeSize)
|
||||
}
|
||||
@@ -273,6 +282,32 @@ func TestProcessJobDownloadsProbesMovesAndCleans(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessJobRejectsLongEpisodeIDAndCleans(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
argsFile := filepath.Join(dir, "yt-dlp-args")
|
||||
t.Setenv("FAKE_ARGS_FILE", argsFile)
|
||||
workDir := filepath.Join(dir, ".incoming", "7")
|
||||
if err := os.MkdirAll(workDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(workDir, "stale"), []byte("stale"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
job := runningJob()
|
||||
job.EpisodeID = strings.Repeat("i", 65)
|
||||
jobs := &fakeStore{job: job}
|
||||
testRunner(t, jobs, dir).ProcessJob(context.Background(), job)
|
||||
if jobs.completed || jobs.failedKind != "invalid_output" || !strings.Contains(jobs.failedMessage, "64バイト以下") {
|
||||
t.Fatalf("長いIDの失敗: kind=%q message=%q completed=%v", jobs.failedKind, jobs.failedMessage, jobs.completed)
|
||||
}
|
||||
if _, err := os.Stat(argsFile); !errors.Is(err, os.ErrNotExist) {
|
||||
t.Fatalf("yt-dlp が起動された: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(workDir); !errors.Is(err, os.ErrNotExist) {
|
||||
t.Fatalf("作業ディレクトリが残った: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessJobKeepsPercentNamesOutOfYTDLPTemplate(t *testing.T) {
|
||||
t.Setenv("FAKE_YTDLP_MODE", "success")
|
||||
t.Setenv("FAKE_FFPROBE_MODE", "valid")
|
||||
@@ -294,7 +329,7 @@ func TestProcessJobKeepsPercentNamesOutOfYTDLPTemplate(t *testing.T) {
|
||||
if strings.Contains(string(args), job.SeriesTitle) || strings.Contains(string(args), job.Title) {
|
||||
t.Fatalf("番組情報が yt-dlp 引数に入った: %s", args)
|
||||
}
|
||||
want := filepath.ToSlash(media.RelativeVideoPath(job.SeriesTitle, job.Title, job.EpisodeID))
|
||||
want := filepath.ToSlash(relativeVideoPath(t, job.SeriesTitle, job.Title, job.EpisodeID))
|
||||
if jobs.completePath != want {
|
||||
t.Fatalf("保存先=%q want=%q", jobs.completePath, want)
|
||||
}
|
||||
@@ -305,7 +340,7 @@ func TestProcessJobReplacesExistingDestination(t *testing.T) {
|
||||
t.Setenv("FAKE_FFPROBE_MODE", "valid")
|
||||
dir := t.TempDir()
|
||||
job := runningJob()
|
||||
relative := media.RelativeVideoPath(job.SeriesTitle, job.Title, job.EpisodeID)
|
||||
relative := relativeVideoPath(t, job.SeriesTitle, job.Title, job.EpisodeID)
|
||||
destination := filepath.Join(dir, relative)
|
||||
if err := os.MkdirAll(filepath.Dir(destination), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -409,7 +444,7 @@ func TestProcessJobDestinationDirectoryFailureCleansIncoming(t *testing.T) {
|
||||
t.Setenv("FAKE_FFPROBE_MODE", "valid")
|
||||
dir := t.TempDir()
|
||||
job := runningJob()
|
||||
seriesDir := filepath.Join(dir, filepath.Dir(media.RelativeVideoPath(job.SeriesTitle, job.Title, job.EpisodeID)))
|
||||
seriesDir := filepath.Join(dir, filepath.Dir(relativeVideoPath(t, job.SeriesTitle, job.Title, job.EpisodeID)))
|
||||
if err := os.WriteFile(seriesDir, []byte("blocks directory"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user