ライブラリ・再生・削除を追加(Range 配信、パスの安全確認、共有ファイル名規則)
This commit is contained in:
@@ -0,0 +1,61 @@
|
||||
package media
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
const maxComponentRunes = 80
|
||||
const maxComponentBytes = 60
|
||||
|
||||
// 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 sanitizeComponent(value string) string {
|
||||
var result []rune
|
||||
lastReplacement := false
|
||||
for _, r := range value {
|
||||
invalid := unicode.IsControl(r) || strings.ContainsRune(`<>:"/\|?*`, r)
|
||||
if invalid {
|
||||
if !lastReplacement {
|
||||
result = append(result, '_')
|
||||
lastReplacement = true
|
||||
}
|
||||
continue
|
||||
}
|
||||
if unicode.IsSpace(r) {
|
||||
r = ' '
|
||||
}
|
||||
result = append(result, r)
|
||||
lastReplacement = false
|
||||
}
|
||||
clean := strings.Trim(string(result), " .")
|
||||
if clean == "" || clean == "." || clean == ".." {
|
||||
return "名称未設定"
|
||||
}
|
||||
runes := []rune(clean)
|
||||
if len(runes) > maxComponentRunes {
|
||||
runes = runes[:maxComponentRunes]
|
||||
}
|
||||
var limited []rune
|
||||
bytes := 0
|
||||
for _, r := range runes {
|
||||
size := len(string(r))
|
||||
if bytes+size > maxComponentBytes {
|
||||
break
|
||||
}
|
||||
limited = append(limited, r)
|
||||
bytes += size
|
||||
}
|
||||
clean = strings.Trim(string(limited), " .")
|
||||
if clean == "" || clean == "." || clean == ".." {
|
||||
return "名称未設定"
|
||||
}
|
||||
return clean
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
package media
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
func TestRelativeVideoPath(t *testing.T) {
|
||||
got := RelativeVideoPath("番組名", "第1話", "ep123")
|
||||
want := filepath.Join("番組名", "番組名 第1話 [ep123].mp4")
|
||||
if got != want {
|
||||
t.Fatalf("path=%q want=%q", got, want)
|
||||
}
|
||||
other := RelativeVideoPath("番組名", "第1話", "ep456")
|
||||
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", "../")
|
||||
if filepath.IsAbs(got) || strings.ContainsAny(got, `:*?<>|"`) || strings.Contains(got, "..") {
|
||||
t.Fatalf("禁止文字が残った: %q", got)
|
||||
}
|
||||
for _, r := range got {
|
||||
if unicode.IsControl(r) {
|
||||
t.Errorf("制御文字が残った: %U in %q", r, got)
|
||||
}
|
||||
}
|
||||
parts := strings.Split(filepath.ToSlash(got), "/")
|
||||
if len(parts) != 2 || parts[0] == "" || !strings.HasSuffix(got, ".mp4") {
|
||||
t.Fatalf("相対パスの形: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRelativeVideoPathHandlesEmptyAndLongNames(t *testing.T) {
|
||||
for _, name := range []string{"", " ", ".", "..", "..."} {
|
||||
got := RelativeVideoPath(name, name, "")
|
||||
if !strings.Contains(got, "名称未設定") {
|
||||
t.Errorf("空の代替名 %q: %q", name, got)
|
||||
}
|
||||
}
|
||||
long := strings.Repeat("界", 200)
|
||||
got := RelativeVideoPath(long, long, "id")
|
||||
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])))
|
||||
}
|
||||
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 got := sanitizeComponent(strings.Repeat("a", 61)); len([]byte(got)) != 60 {
|
||||
t.Fatalf("ASCII の60バイト境界: %d %q", len([]byte(got)), got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSanitizeComponentTrimsWhitespaceBeforeLengthLimit(t *testing.T) {
|
||||
for _, tt := range []struct {
|
||||
name, input, want string
|
||||
}{
|
||||
{"前後の空白", " \t 番組名\n ", "_ 番組名_"},
|
||||
{"空白のみ", " ", "名称未設定"},
|
||||
{"制御文字のみ", "\t\n", "_"},
|
||||
{"多数の先頭空白", strings.Repeat(" ", maxComponentRunes+10) + "番組名", "番組名"},
|
||||
{"先頭空白と長い名前", strings.Repeat(" ", 70) + strings.Repeat("a", 70), strings.Repeat("a", 60)},
|
||||
} {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := sanitizeComponent(tt.input); got != tt.want {
|
||||
t.Errorf("sanitizeComponent()=%q want=%q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
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)},
|
||||
} {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := sanitizeComponent(tt.input)
|
||||
if got != tt.want {
|
||||
t.Errorf("sanitizeComponent()=%q want=%q", got, tt.want)
|
||||
}
|
||||
if strings.Trim(got, " .") != got || got == "" {
|
||||
t.Errorf("切り詰め後の名前が不正: %q", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSanitizeComponentSafetyProperties(t *testing.T) {
|
||||
var controls strings.Builder
|
||||
for r := rune(0); r <= 0x9f; r++ {
|
||||
if unicode.IsControl(r) {
|
||||
controls.WriteRune(r)
|
||||
}
|
||||
}
|
||||
inputs := []string{
|
||||
"", " ", ".", "..", "...", `<>:"/\|?*`, controls.String(),
|
||||
" \t 番組名\n. ", "a/b\\c", strings.Repeat("a", 500),
|
||||
strings.Repeat("界", 500), strings.Repeat("😀", 500),
|
||||
strings.Repeat(".", 100) + "name" + strings.Repeat(" ", 100),
|
||||
string([]byte{0xff, 0xfe, 'a', 0xc0, 0xaf}),
|
||||
}
|
||||
for i, input := range inputs {
|
||||
got := sanitizeComponent(input)
|
||||
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 !utf8.ValidString(got) {
|
||||
t.Errorf("case %d: 不正なUTF-8: %q", i, got)
|
||||
}
|
||||
if strings.ContainsAny(got, `<>:"/\|?*`) {
|
||||
t.Errorf("case %d: 禁止文字が残った: %q", i, got)
|
||||
}
|
||||
for _, r := range got {
|
||||
if unicode.IsControl(r) {
|
||||
t.Errorf("case %d: 制御文字が残った: %U in %q", i, r, got)
|
||||
}
|
||||
}
|
||||
if again := sanitizeComponent(got); again != got {
|
||||
t.Errorf("case %d: 再適用で変化: %q -> %q", i, got, again)
|
||||
}
|
||||
|
||||
path := RelativeVideoPath(input, input, input)
|
||||
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)
|
||||
}
|
||||
if !utf8.ValidString(path) {
|
||||
t.Errorf("case %d: パスが不正なUTF-8: %q", i, path)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user