159 lines
6.6 KiB
Swift
159 lines
6.6 KiB
Swift
import Foundation
|
|
import Darwin
|
|
|
|
struct ResolvedSubtitle: Decodable, Sendable {
|
|
let url: String
|
|
let format: String
|
|
let language: String
|
|
let label: String
|
|
let isDefault: Bool
|
|
private let headerFields: [String: String]
|
|
var proxy: MiruroProxy? = nil
|
|
var headers: String {
|
|
headerFields.sorted { $0.key < $1.key }.map { "\($0.key): \($0.value)\r\n" }.joined()
|
|
}
|
|
fileprivate var validatedHeaders: [String: String] { headerFields }
|
|
|
|
init(url: String, format: String, language: String, label: String, isDefault: Bool,
|
|
headers: [String: String], proxy: MiruroProxy? = nil) {
|
|
self.url = url
|
|
self.format = format
|
|
self.language = language
|
|
self.label = label
|
|
self.isDefault = isDefault
|
|
self.headerFields = headers
|
|
self.proxy = proxy
|
|
}
|
|
private enum CodingKeys: String, CodingKey {
|
|
case url, format, language, label
|
|
case isDefault = "default"
|
|
case headerFields = "headers"
|
|
}
|
|
}
|
|
|
|
struct ResolvedSource: Sendable {
|
|
let url: String
|
|
let headers: String
|
|
let identity: String
|
|
let title: String
|
|
let subtitles: [ResolvedSubtitle]
|
|
var requirements: MiruroPlaybackRequirements = .init()
|
|
var playbackLifetime: (any Sendable)? = nil
|
|
|
|
func replacingSubtitles(_ subtitles: [ResolvedSubtitle]) -> ResolvedSource {
|
|
ResolvedSource(url: url, headers: headers, identity: identity, title: title, subtitles: subtitles,
|
|
requirements: requirements, playbackLifetime: playbackLifetime)
|
|
}
|
|
|
|
struct Document: Decodable {
|
|
struct Identity: Decodable {
|
|
let provider: String
|
|
let media_id: String
|
|
let episode: Int
|
|
}
|
|
let version: Int
|
|
let url: String
|
|
let headers: [String: String]
|
|
let identity: Identity
|
|
let title: String?
|
|
let subtitles: [ResolvedSubtitle]
|
|
}
|
|
|
|
private struct InvalidSource: LocalizedError {
|
|
var errorDescription: String? { "Invalid or unreadable resolved source file." }
|
|
}
|
|
|
|
static func load(from file: URL) throws -> ResolvedSource {
|
|
do {
|
|
guard file.isFileURL else { throw InvalidSource() }
|
|
let descriptor: Int32 = file.withUnsafeFileSystemRepresentation { path in
|
|
guard let path else { return -1 }
|
|
return Darwin.open(path, O_RDONLY | O_NONBLOCK | O_CLOEXEC)
|
|
}
|
|
guard descriptor >= 0 else { throw InvalidSource() }
|
|
var info = stat()
|
|
guard fstat(descriptor, &info) == 0, (info.st_mode & S_IFMT) == S_IFREG else {
|
|
Darwin.close(descriptor)
|
|
throw InvalidSource()
|
|
}
|
|
let handle = FileHandle(fileDescriptor: descriptor, closeOnDealloc: true)
|
|
defer { try? handle.close() }
|
|
let data = try handle.read(upToCount: 1_048_577) ?? Data()
|
|
guard data.count <= 1_048_576 else { throw InvalidSource() }
|
|
let document = try JSONDecoder().decode(Document.self, from: data)
|
|
return try validated(document)
|
|
} catch { throw InvalidSource() }
|
|
}
|
|
|
|
static func validIdentity(mediaID: String, episode: Int) -> Bool {
|
|
!mediaID.isEmpty && mediaID.utf8.count <= 512 &&
|
|
mediaID.utf8.allSatisfy({ isIDByte($0) }) &&
|
|
episode > 0 && episode <= Int(Int32.max)
|
|
}
|
|
|
|
static func validAniListIdentity(mediaID: String, episode: Int) -> Bool {
|
|
guard let id = Int32(mediaID), id > 0, String(id) == mediaID else { return false }
|
|
return episode > 0 && episode <= Int(Int32.max)
|
|
}
|
|
|
|
static func validated(_ document: Document) throws -> ResolvedSource {
|
|
let id = document.identity
|
|
guard document.version == 1,
|
|
(id.provider == "miruro" && validIdentity(mediaID: id.media_id, episode: id.episode)) ||
|
|
(id.provider == "anilist" && validAniListIdentity(mediaID: id.media_id, episode: id.episode)),
|
|
validURL(document.url), validHeaders(document.headers),
|
|
validText(document.title ?? "", limit: 4096),
|
|
validSubtitles(document.subtitles) else { throw InvalidSource() }
|
|
let headers = document.headers.sorted { $0.key < $1.key }
|
|
.map { "\($0.key): \($0.value)\r\n" }.joined()
|
|
return ResolvedSource(url: document.url, headers: headers,
|
|
identity: "\(id.provider):\(id.media_id):\(id.episode)", title: document.title ?? "\(id.provider == "anilist" ? "AniList \(id.media_id)" : "Miruro") episode \(id.episode)",
|
|
subtitles: document.subtitles)
|
|
}
|
|
|
|
static func validSubtitles(_ subtitles: [ResolvedSubtitle]) -> Bool {
|
|
subtitles.count <= 32 && subtitles.filter({ $0.isDefault }).count <= 1 &&
|
|
subtitles.allSatisfy {
|
|
validURL($0.url) && $0.format == "vtt" &&
|
|
validText($0.language, limit: 128) && !$0.language.isEmpty &&
|
|
validText($0.label, limit: 4096) && !$0.label.isEmpty &&
|
|
validHeaders($0.validatedHeaders)
|
|
}
|
|
}
|
|
|
|
private static func isIDByte(_ byte: UInt8) -> Bool {
|
|
(65...90).contains(byte) || (97...122).contains(byte) ||
|
|
(48...57).contains(byte) || byte == 45 || byte == 95
|
|
}
|
|
|
|
static func validText(_ text: String, limit: Int) -> Bool {
|
|
text.utf8.count <= limit && !text.unicodeScalars.contains { $0.value < 32 || $0.value == 127 }
|
|
}
|
|
|
|
static func validURL(_ text: String) -> Bool {
|
|
guard !text.isEmpty, validText(text, limit: 32768),
|
|
!text.contains(where: { $0.isWhitespace }),
|
|
let components = URLComponents(string: text),
|
|
components.scheme == "https" || components.scheme == "http",
|
|
let host = components.host, !host.isEmpty,
|
|
components.user == nil, components.password == nil,
|
|
components.fragment == nil, components.url != nil else { return false }
|
|
return components.port == nil || (1...65535).contains(components.port!)
|
|
}
|
|
|
|
static func validHeaders(_ headers: [String: String]) -> Bool {
|
|
guard headers.count <= 64 else { return false }
|
|
var names = Set<String>()
|
|
var size = 0
|
|
for (name, value) in headers {
|
|
guard !name.isEmpty, name.utf8.count <= 256,
|
|
name.utf8.allSatisfy({ isIDByte($0) || "!#$%&'*+.^`|~".utf8.contains($0) }),
|
|
names.insert(name.lowercased()).inserted,
|
|
value.utf8.count <= 16384,
|
|
!value.unicodeScalars.contains(where: { $0.value < 32 || $0.value == 127 }) else { return false }
|
|
size += name.utf8.count + value.utf8.count + 4
|
|
}
|
|
return size <= 65536
|
|
}
|
|
}
|