TM-SGNL-iOS/SignalServiceKit/Storage/Database/IncrementalMigrations/TSAttachment/TSAttachmentMigration+AttachmentValidator.swift
TeleMessage developers dde0620daf initial commit
2025-05-03 12:28:28 -07:00

700 lines
28 KiB
Swift

//
// Copyright 2024 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
import AVKit
import Foundation
extension TSAttachmentMigration {
struct PendingV2AttachmentFile {
let blurHash: String?
let sha256ContentHash: Data
let encryptedByteCount: UInt32
let unencryptedByteCount: UInt32
let mimeType: String
let encryptionKey: Data
let digestSHA256Ciphertext: Data
let localRelativeFilePath: String
let renderingFlag: TSAttachmentMigration.V2RenderingFlag
let sourceFilename: String?
let validatedContentType: TSAttachmentMigration.V2Attachment.ContentType
let audioDurationSeconds: Double?
let mediaSizePixels: CGSize?
let videoDurationSeconds: Double?
let audioWaveformRelativeFilePath: String?
let videoStillFrameRelativeFilePath: String?
}
class V2AttachmentContentValidator {
// Note that unlike "live" attachment validation which assigns final
// attachment file locations on the fly, the migrations are required
// to "reserve" the final location using a random but persisted UUID.
// This way if the migration is interrupted, any files we managed
// to create before interruption are simply written over instead of
// living forever unreferenced and consuming space.
struct ReservedRelativeFileIds {
let primaryFile: UUID
let audioWaveform: UUID
let videoStillFrame: UUID
}
static func validateContents(
unencryptedFileUrl: URL,
reservedFileIds: ReservedRelativeFileIds,
encryptionKey: Data? = nil,
mimeType: String,
renderingFlag: TSAttachmentMigration.V2RenderingFlag,
sourceFilename: String?
) throws -> TSAttachmentMigration.PendingV2AttachmentFile {
let byteSize: Int = {
return OWSFileSystem.fileSize(of: unencryptedFileUrl)?.intValue ?? 0
}()
guard byteSize < 95 * 1000 * 1000 /* SignalAttachment.kMaxFileSizeGeneric */ else {
throw AttachmentTooLargeError()
}
let encryptionKey = encryptionKey ?? Cryptography.randomAttachmentEncryptionKey()
let pendingAttachment = try validateContents(
unencryptedFileUrl: unencryptedFileUrl,
byteSize: byteSize,
reservedFileIds: reservedFileIds,
encryptionKey: encryptionKey,
mimeType: mimeType,
renderingFlag: renderingFlag,
sourceFilename: sourceFilename
)
return pendingAttachment
}
private static func validateContents(
unencryptedFileUrl: URL,
byteSize: Int,
reservedFileIds: ReservedRelativeFileIds,
encryptionKey: Data,
mimeType: String,
renderingFlag: TSAttachmentMigration.V2RenderingFlag,
sourceFilename: String?
) throws -> TSAttachmentMigration.PendingV2AttachmentFile {
var mimeType = mimeType
let contentTypeResult = try validateContentType(
unencryptedFileUrl: unencryptedFileUrl,
byteSize: byteSize,
reservedFileIds: reservedFileIds,
encryptionKey: encryptionKey,
mimeType: &mimeType
)
return try prepareAttachmentFiles(
unencryptedFileUrl,
reservedFileIds: reservedFileIds,
encryptionKey: encryptionKey,
mimeType: mimeType,
renderingFlag: renderingFlag,
sourceFilename: sourceFilename,
contentResult: contentTypeResult
)
}
private static let thumbnailDimensionPointsForQuotedReply: CGFloat = 200
static func prepareQuotedReplyThumbnail(
fromOriginalAttachmentStream stream: TSAttachmentMigration.V1Attachment,
reservedFileIds: ReservedRelativeFileIds,
renderingFlag: TSAttachmentMigration.V2RenderingFlag,
sourceFilename: String?
) throws -> TSAttachmentMigration.PendingV2AttachmentFile? {
guard let localFilePath = stream.localFilePath else {
throw OWSAssertionError("Non stream")
}
let originalImage: UIImage
// The thing called "contentType" on TSAttachment is the MIME type.
let contentType = self.rawContentType(mimeType: stream.contentType)
switch contentType {
case .invalid, .audio, .file:
throw OWSAssertionError("Non visual media target")
case .image, .animatedImage:
guard let image = UIImage(contentsOfFile: localFilePath) else {
Logger.error("Unable to read image")
return nil
}
originalImage = image
case .video:
let asset: AVAsset = AVAsset(url: URL(fileURLWithPath: localFilePath))
guard TSAttachmentMigration.OWSMediaUtils.isValidVideo(asset: asset) else {
throw OWSAssertionError("Unable to read video")
}
do {
originalImage = try TSAttachmentMigration.OWSMediaUtils.thumbnail(
forVideo: asset,
maxSizePixels: .square(AttachmentThumbnailQuality.large.thumbnailDimensionPoints())
)
} catch {
Logger.error("Failed to generate video still frame")
return nil
}
}
guard
let resizedImage = TSAttachmentMigration.OWSMediaUtils.resize(
image: originalImage,
maxDimensionPoints: Self.thumbnailDimensionPointsForQuotedReply
),
let imageData = resizedImage.jpegData(compressionQuality: 0.8)
else {
Logger.error("Unable to create thumbnail")
return nil
}
let tmpFile = OWSFileSystem.temporaryFileUrl(isAvailableWhileDeviceLocked: true)
try imageData.write(to: tmpFile)
let renderingFlagForThumbnail: TSAttachmentMigration.V2RenderingFlag
switch renderingFlag {
case .borderless:
// Preserve borderless flag from the original
renderingFlagForThumbnail = .borderless
case .default, .voiceMessage, .shouldLoop:
// Other cases become default for the still image.
renderingFlagForThumbnail = .default
}
return try Self.validateContents(
unencryptedFileUrl: tmpFile,
reservedFileIds: reservedFileIds,
mimeType: "image/jpeg",
renderingFlag: renderingFlagForThumbnail,
sourceFilename: sourceFilename
)
}
// MARK: Content Type Validation
static let supportedVideoMimeTypes: Set<String> = [
"video/3gpp",
"video/3gpp2",
"video/mp4",
"video/quicktime",
"video/x-m4v",
"video/mpeg",
]
static let supportedAudioMimeTypes: Set<String> = [
"audio/aac",
"audio/x-m4p",
"audio/x-m4b",
"audio/x-m4a",
"audio/wav",
"audio/x-wav",
"audio/x-mpeg",
"audio/mpeg",
"audio/mp4",
"audio/mp3",
"audio/mpeg3",
"audio/x-mp3",
"audio/x-mpeg3",
"audio/aiff",
"audio/x-aiff",
"audio/3gpp2",
"audio/3gpp",
]
static let supportedImageMimeTypes: Set<String> = [
"image/jpeg",
"image/pjpeg",
"image/png",
"image/tiff",
"image/x-tiff",
"image/bmp",
"image/x-windows-bmp",
"image/heic",
"image/heif",
"image/webp",
]
static let supportedDefinitelyAnimatedMimeTypes: Set<String> = [
"image/gif",
"image/apng",
"image/vnd.mozilla.apng",
]
public static let supportedMaybeAnimatedMimeTypes: Set<String> = Set([
"image/webp",
"image/png",
]).union(supportedDefinitelyAnimatedMimeTypes)
static func rawContentType(mimeType: String) -> TSAttachmentMigration.V2Attachment.ContentType {
if Self.supportedVideoMimeTypes.contains(mimeType) {
return .video
} else if Self.supportedAudioMimeTypes.contains(mimeType) {
return .audio
} else if Self.supportedDefinitelyAnimatedMimeTypes.contains(mimeType) {
return .animatedImage
} else if Self.supportedImageMimeTypes.contains(mimeType) {
return .image
} else if Self.supportedMaybeAnimatedMimeTypes.contains(mimeType) {
return .animatedImage
} else {
return .file
}
}
fileprivate struct PendingFile {
let tmpFileUrl: URL
let isTmpFileEncrypted: Bool
let reservedRelativeFilePath: String
init(
tmpFileUrl: URL,
isTmpFileEncrypted: Bool,
reservedRelativeFilePath: String
) {
self.tmpFileUrl = tmpFileUrl
self.isTmpFileEncrypted = isTmpFileEncrypted
self.reservedRelativeFilePath = reservedRelativeFilePath
}
}
private struct ContentTypeResult {
let contentType: TSAttachmentMigration.V2Attachment.ContentType
let audioDurationSeconds: Double?
let mediaSizePixels: CGSize?
let videoDurationSeconds: Double?
let blurHash: String?
let audioWaveformFile: TSAttachmentMigration.V2AttachmentContentValidator.PendingFile?
let videoStillFrameFile: TSAttachmentMigration.V2AttachmentContentValidator.PendingFile?
}
private static func validateContentType(
unencryptedFileUrl: URL,
byteSize: Int,
reservedFileIds: ReservedRelativeFileIds,
encryptionKey: Data,
mimeType: inout String
) throws -> ContentTypeResult {
let invalidResult = ContentTypeResult(
contentType: .invalid,
audioDurationSeconds: nil,
mediaSizePixels: nil,
videoDurationSeconds: nil,
blurHash: nil,
audioWaveformFile: nil,
videoStillFrameFile: nil
)
switch rawContentType(mimeType: mimeType) {
case .invalid:
return invalidResult
case .file:
return ContentTypeResult(
contentType: .file,
audioDurationSeconds: nil,
mediaSizePixels: nil,
videoDurationSeconds: nil,
blurHash: nil,
audioWaveformFile: nil,
videoStillFrameFile: nil
)
case .image:
guard byteSize < 8 * 1024 * 1024 /* SignalAttachment.kMaxFileSizeImage */ else {
throw AttachmentTooLargeError()
}
return try validateImageContentType(
unencryptedFileUrl,
mimeType: &mimeType
) ?? invalidResult
case .animatedImage:
guard byteSize < 25 * 1024 * 1024 /* SignalAttachment.kMaxFileSizeAnimatedImage */ else {
throw AttachmentTooLargeError()
}
return try validateImageContentType(
unencryptedFileUrl,
mimeType: &mimeType
) ?? invalidResult
case .video:
guard byteSize < 95 * 1000 * 1000 /* SignalAttachment.kMaxFileSizeVideo */ else {
throw AttachmentTooLargeError()
}
return try validateVideoContentType(
unencryptedFileUrl,
reservedFileIds: reservedFileIds,
mimeType: mimeType,
encryptionKey: encryptionKey
) ?? invalidResult
case .audio:
guard byteSize < 95 * 1000 * 1000 /* SignalAttachment.kMaxFileSizeAudio */ else {
throw AttachmentTooLargeError()
}
return try validateAudioContentType(
unencryptedFileUrl,
reservedFileIds: reservedFileIds,
mimeType: mimeType,
encryptionKey: encryptionKey
) ?? invalidResult
}
}
// MARK: Image/Animated
// Includes static and animated image validation.
private static func validateImageContentType(
_ unencryptedFileUrl: URL,
mimeType: inout String
) throws -> ContentTypeResult? {
let imageSource: TSAttachmentMigration.OWSImageSource = try {
do {
return try TSAttachmentMigration.OWSImageSource(fileUrl: unencryptedFileUrl)
} catch {
var errorString = "\(error)"
errorString = errorString.replacingOccurrences(of: "/Attachments/", with: "/[attachment_dir]/")
errorString = errorString.replacingOccurrences(of: unencryptedFileUrl.lastPathComponent, with: "xxxx")
throw OWSAssertionError("Failed to open file handle image source \(errorString)")
}
}()
guard let imageMetadata = imageSource.imageMetadata(
mimeTypeForValidation: mimeType
) else {
return nil
}
guard imageMetadata.isValid else {
return nil
}
let pixelSize = imageMetadata.pixelSize
let blurHash: String? = {
guard let image = UIImage(contentsOfFile: unencryptedFileUrl.path) else {
return nil
}
return try? BlurHash.computeBlurHashSync(for: image)
}()
let contentType: TSAttachmentMigration.V2Attachment.ContentType
if imageMetadata.isAnimated {
contentType = .animatedImage
} else {
contentType = .image
}
return ContentTypeResult(
contentType: contentType,
audioDurationSeconds: nil,
mediaSizePixels: pixelSize,
videoDurationSeconds: nil,
blurHash: blurHash,
audioWaveformFile: nil,
videoStillFrameFile: nil
)
}
// MARK: Video
public class AttachmentTooLargeError: Error {}
private static func validateVideoContentType(
_ unencryptedFileUrl: URL,
reservedFileIds: ReservedRelativeFileIds,
mimeType: String,
encryptionKey: Data
) throws -> ContentTypeResult? {
let asset: AVAsset = {
return AVAsset(url: unencryptedFileUrl)
}()
guard TSAttachmentMigration.OWSMediaUtils.isValidVideo(asset: asset) else {
return nil
}
let thumbnailImage = try? TSAttachmentMigration.OWSMediaUtils.thumbnail(
forVideo: asset,
maxSizePixels: .square(AttachmentThumbnailQuality.large.thumbnailDimensionPoints())
)
guard let thumbnailImage else {
return nil
}
let stillFrameFile: TSAttachmentMigration.V2AttachmentContentValidator.PendingFile? = try thumbnailImage
// Don't compress; we already size-limited this thumbnail, it already has whatever
// compression applied to the source video, and we want a high fidelity still frame.
.jpegData(compressionQuality: 1)
.map { thumbnailData in
let thumbnailTmpFile = OWSFileSystem.temporaryFileUrl(isAvailableWhileDeviceLocked: true)
let (encryptedThumbnail, _) = try Cryptography.encrypt(thumbnailData, encryptionKey: encryptionKey)
try encryptedThumbnail.write(to: thumbnailTmpFile)
return TSAttachmentMigration.V2AttachmentContentValidator.PendingFile(
tmpFileUrl: thumbnailTmpFile,
isTmpFileEncrypted: true,
reservedRelativeFilePath: TSAttachmentMigration.V2Attachment.relativeFilePath(
reservedUUID: reservedFileIds.videoStillFrame
)
)
}
let blurHash = try? BlurHash.computeBlurHashSync(for: thumbnailImage)
let duration = asset.duration.seconds
// We have historically used the size of the still frame as the video size.
let pixelSize = thumbnailImage.pixelSize
return ContentTypeResult(
contentType: .video,
audioDurationSeconds: nil,
mediaSizePixels: pixelSize,
videoDurationSeconds: duration,
blurHash: blurHash,
audioWaveformFile: nil,
videoStillFrameFile: stillFrameFile
)
}
// MARK: Audio
private static func validateAudioContentType(
_ unencryptedFileUrl: URL,
reservedFileIds: ReservedRelativeFileIds,
mimeType: String,
encryptionKey: Data
) throws -> ContentTypeResult? {
let duration = try computeAudioDuration(unencryptedFileUrl, mimeType: mimeType)
guard let duration else {
Logger.error("Unable to compute duration, treating audio as invalid file")
return nil
}
// Don't require the waveform file.
let waveformFile = try? self.createAudioWaveform(
unencryptedFileUrl,
reservedFileIds: reservedFileIds,
mimeType: mimeType,
encryptionKey: encryptionKey
)
return ContentTypeResult(
contentType: .audio,
audioDurationSeconds: duration,
mediaSizePixels: nil,
videoDurationSeconds: nil,
blurHash: nil,
audioWaveformFile: waveformFile,
videoStillFrameFile: nil
)
}
private static func computeAudioDuration(_ unencryptedFileUrl: URL, mimeType: String) throws -> TimeInterval? {
do {
let player = try AVAudioPlayer(contentsOf: unencryptedFileUrl)
player.prepareToPlay()
return player.duration
} catch {
let pathExtension = unencryptedFileUrl.pathExtension
if
pathExtension == "aac"
|| mimeType == "audio/aac"
|| mimeType == "audio/x-aac"
{
// AVAudioPlayer can't handle aac file extensions, but _should_ work
// if we just change the extension.
Logger.info("Failed aac file, retrying as m4a")
let newTmpURL = OWSFileSystem.temporaryFileUrl(
fileExtension: "m4a",
isAvailableWhileDeviceLocked: true
)
do {
try FileManager.default.copyItem(at: unencryptedFileUrl, to: newTmpURL)
} catch {
throw OWSAssertionError("Failed to copy attachment file")
}
let player: AVAudioPlayer
do {
player = try AVAudioPlayer(contentsOf: newTmpURL)
} catch {
Logger.error("Failed to read aac file after reapplying extension")
return nil
}
player.prepareToPlay()
let duration = player.duration
try FileManager.default.removeItem(at: newTmpURL)
return duration
}
Logger.error("Failed reading audio file, mimeType: \(mimeType) ext: \(pathExtension)")
return nil
}
}
private enum AudioWaveformFile {
case unencrypted(URL)
case encrypted(URL, encryptionKey: Data)
}
private static func createAudioWaveform(
_ unencryptedFileUrl: URL,
reservedFileIds: ReservedRelativeFileIds,
mimeType: String,
encryptionKey: Data
) throws -> TSAttachmentMigration.V2AttachmentContentValidator.PendingFile {
let waveform: TSAttachmentMigration.AudioWaveform = try TSAttachmentMigration.AudioWaveformManager
.buildAudioWaveForm(unencryptedFilePath: unencryptedFileUrl.path)
let outputWaveformFile = OWSFileSystem.temporaryFileUrl(isAvailableWhileDeviceLocked: true)
let waveformData = try waveform.archive()
let (encryptedWaveform, _) = try Cryptography.encrypt(waveformData, encryptionKey: encryptionKey)
try encryptedWaveform.write(to: outputWaveformFile, options: .atomicWrite)
return .init(
tmpFileUrl: outputWaveformFile,
isTmpFileEncrypted: true,
reservedRelativeFilePath: TSAttachmentMigration.V2Attachment.relativeFilePath(
reservedUUID: reservedFileIds.audioWaveform
)
)
}
// MARK: - File Preparation
private static func prepareAttachmentFiles(
_ unencryptedFileUrl: URL,
reservedFileIds: ReservedRelativeFileIds,
encryptionKey: Data,
mimeType: String,
renderingFlag: TSAttachmentMigration.V2RenderingFlag,
sourceFilename: String?,
contentResult: ContentTypeResult
) throws -> TSAttachmentMigration.PendingV2AttachmentFile {
let primaryFilePlaintextHash = try computePlaintextHash(unencryptedFileUrl: unencryptedFileUrl)
// First encrypt the files that need encrypting.
let (primaryPendingFile, primaryFileMetadata) = try encryptPrimaryFile(
unencryptedFileUrl: unencryptedFileUrl,
reservedFileIds: reservedFileIds,
encryptionKey: encryptionKey
)
guard let primaryFileDigest = primaryFileMetadata.digest else {
throw OWSAssertionError("No digest in output")
}
guard
let primaryPlaintextLength = primaryFileMetadata.plaintextLength
.map(UInt32.init(exactly:)) ?? nil
else {
throw OWSAssertionError("File too large")
}
guard
let primaryEncryptedLength = OWSFileSystem.fileSize(
of: primaryPendingFile.tmpFileUrl
)?.uint32Value
else {
throw OWSAssertionError("Couldn't determine size")
}
let audioWaveformFile = try contentResult.audioWaveformFile?.encryptFileIfNeeded(
encryptionKey: encryptionKey
)
let videoStillFrameFile = try contentResult.videoStillFrameFile?.encryptFileIfNeeded(
encryptionKey: encryptionKey
)
// Now we can copy files.
for pendingFile in [primaryPendingFile, audioWaveformFile, videoStillFrameFile].compacted() {
let destinationUrl = TSAttachmentMigration.V2Attachment.absoluteAttachmentFileURL(
relativeFilePath: pendingFile.reservedRelativeFilePath
)
guard OWSFileSystem.ensureDirectoryExists(destinationUrl.deletingLastPathComponent().path) else {
throw OWSAssertionError("Unable to create directory")
}
if OWSFileSystem.fileOrFolderExists(url: destinationUrl) {
// If something is at our reserved (random) location, since collisions are absurdly
// unlikely, it must mean we previously created the file at the reserved location
// but were interrupted. Delete what was there and keep going.
try OWSFileSystem.deleteFile(url: destinationUrl)
}
try OWSFileSystem.moveFile(
from: pendingFile.tmpFileUrl,
to: destinationUrl
)
}
return TSAttachmentMigration.PendingV2AttachmentFile(
blurHash: contentResult.blurHash,
sha256ContentHash: primaryFilePlaintextHash,
encryptedByteCount: primaryEncryptedLength,
unencryptedByteCount: primaryPlaintextLength,
mimeType: mimeType,
encryptionKey: encryptionKey,
digestSHA256Ciphertext: primaryFileDigest,
localRelativeFilePath: primaryPendingFile.reservedRelativeFilePath,
renderingFlag: renderingFlag,
sourceFilename: sourceFilename,
validatedContentType: contentResult.contentType,
audioDurationSeconds: contentResult.audioDurationSeconds,
mediaSizePixels: contentResult.mediaSizePixels,
videoDurationSeconds: contentResult.videoDurationSeconds,
audioWaveformRelativeFilePath: contentResult.audioWaveformFile?.reservedRelativeFilePath,
videoStillFrameRelativeFilePath: contentResult.videoStillFrameFile?.reservedRelativeFilePath
)
}
// MARK: - Encryption
private static func computePlaintextHash(unencryptedFileUrl: URL) throws -> Data {
return try Cryptography.computeSHA256DigestOfFile(at: unencryptedFileUrl)
}
private static func encryptPrimaryFile(
unencryptedFileUrl: URL,
reservedFileIds: ReservedRelativeFileIds,
encryptionKey: Data
) throws -> (TSAttachmentMigration.V2AttachmentContentValidator.PendingFile, EncryptionMetadata) {
let outputFile = OWSFileSystem.temporaryFileUrl(isAvailableWhileDeviceLocked: true)
let encryptionMetadata = try Cryptography.encryptAttachment(
at: unencryptedFileUrl,
output: outputFile,
encryptionKey: encryptionKey
)
return (
TSAttachmentMigration.V2AttachmentContentValidator.PendingFile(
tmpFileUrl: outputFile,
isTmpFileEncrypted: true,
reservedRelativeFilePath: TSAttachmentMigration.V2Attachment.relativeFilePath(
reservedUUID: reservedFileIds.primaryFile
)
),
encryptionMetadata
)
}
}
}
extension TSAttachmentMigration.V2AttachmentContentValidator.PendingFile {
fileprivate func encryptFileIfNeeded(
encryptionKey: Data
) throws -> Self {
if isTmpFileEncrypted {
return self
}
let outputFile = OWSFileSystem.temporaryFileUrl(isAvailableWhileDeviceLocked: true)
// Encrypt _without_ custom padding; we never send these files
// and just use them locally, so no need for custom padding
// that later requires out-of-band plaintext length tracking
// so we can trim the custom padding at read time.
_ = try Cryptography.encryptFile(
at: tmpFileUrl,
output: outputFile,
encryptionKey: encryptionKey
)
return Self(
tmpFileUrl: outputFile,
isTmpFileEncrypted: true,
// Preserve the reserved file path; this is already
// on the ContentType enum and musn't be changed.
reservedRelativeFilePath: self.reservedRelativeFilePath
)
}
}