Created
April 2, 2025 02:19
-
-
Save janwirth/6b28874dae9fc98d980a81fb61c97703 to your computer and use it in GitHub Desktop.
Upload stuff to B2 with swift
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// | |
// S3_Backblaze.swift | |
// Tuna Family | |
// | |
// Created by Jan Wirth on 14/12/24. | |
// | |
//❯ cat config | |
//[default] | |
//region = EU-CENTRAL | |
//endpoint_url=https://s3.eu-central-003.backblazeb2.com | |
// | |
// | |
//~/.aws | |
//❯ cat credentials | |
import Foundation | |
import CryptoKit | |
import CommonCrypto | |
class BackblazeS3Uploader { | |
private let accessKeyId = "TODO" | |
private let secretAccessKey = "TODO" | |
private let region = "EU-CENTRAL" | |
private let endpoint = "https://s3.eu-central-003.backblazeb2.com" | |
private let bucketName = "tuna-family" // Replace with your bucket name | |
func downloadAudio(fileName: String) async throws -> Data { | |
let dateFormatter = DateFormatter() | |
dateFormatter.dateFormat = "yyyyMMdd'T'HHmmss'Z'" | |
dateFormatter.timeZone = TimeZone(abbreviation: "UTC") | |
let dateString = dateFormatter.string(from: Date()) | |
let url = URL(string: "\(endpoint)/\(bucketName)/\(fileName)")! | |
var request = URLRequest(url: url) | |
request.httpMethod = "GET" | |
// Add required headers in alphabetical order | |
let host = URL(string: endpoint)!.host! | |
request.setValue(host, forHTTPHeaderField: "Host") | |
request.setValue("UNSIGNED-PAYLOAD", forHTTPHeaderField: "x-amz-content-sha256") | |
request.setValue(dateString, forHTTPHeaderField: "x-amz-date") | |
// Generate signature with headers in correct order | |
let signature = generateAWSSignature( | |
httpMethod: "GET", | |
path: "/\(bucketName)/\(fileName)", | |
headers: [ | |
"host": host, | |
"x-amz-content-sha256": "UNSIGNED-PAYLOAD", | |
"x-amz-date": dateString | |
], | |
payload: Data() | |
) | |
// Create authorization header with sorted signed headers | |
let authHeader = "AWS4-HMAC-SHA256 " + | |
"Credential=\(accessKeyId)/\(dateString.prefix(8))/\(region)/s3/aws4_request, " + | |
"SignedHeaders=host;x-amz-content-sha256;x-amz-date, " + | |
"Signature=\(signature)" | |
request.setValue(authHeader, forHTTPHeaderField: "Authorization") | |
// Debug prints | |
print("Download Request URL: \(url)") | |
print("Download Headers:") | |
request.allHTTPHeaderFields?.forEach { print(" \($0): \($1)") } | |
let (data, response) = try await URLSession.shared.data(for: request) | |
if let httpResponse = response as? HTTPURLResponse { | |
print("Download Status code: \(httpResponse.statusCode)") | |
guard (200...299).contains(httpResponse.statusCode) else { | |
if let responseString = String(data: data, encoding: .utf8) { | |
print("Error Response: \(responseString)") | |
} | |
throw URLError(.badServerResponse) | |
} | |
} | |
return data | |
} | |
func uploadAudio(fileURL: URL, fileName: String) async throws { | |
// let date = ISO8601DateFormatter().string(from: Date()) | |
let dateFormatter = DateFormatter() | |
dateFormatter.dateFormat = "yyyyMMdd'T'HHmmss'Z'" | |
dateFormatter.timeZone = TimeZone(abbreviation: "UTC") | |
let date = dateFormatter.string(from: Date()) | |
let contentType = "audio/mpeg" // Adjust based on your audio format | |
let url = URL(string: "\(endpoint)/\(bucketName)/\(fileName)")! | |
var request = URLRequest(url: url) | |
request.httpMethod = "PUT" | |
// Verify file data | |
let fileData = try Data(contentsOf: fileURL) | |
let fileSize = fileData.count | |
print("File size: \(fileSize) bytes") // Debug print | |
guard fileSize > 0 else { | |
throw URLError(.fileDoesNotExist) | |
} | |
// Step 1: Create canonical request | |
let payloadHash = SHA256.hash(data: fileData) | |
.compactMap { String(format: "%02x", $0) } | |
.joined() | |
// Add all required headers | |
request.setValue(contentType, forHTTPHeaderField: "Content-Type") | |
request.setValue(date, forHTTPHeaderField: "x-amz-date") | |
request.setValue(String(fileSize), forHTTPHeaderField: "Content-Length") | |
request.setValue(URL(string: endpoint)!.host!, forHTTPHeaderField: "Host") | |
request.setValue(payloadHash, forHTTPHeaderField: "x-amz-content-sha256") | |
// Generate signature with updated headers | |
let signature = generateAWSSignature( | |
httpMethod: "PUT", | |
path: "/\(bucketName)/\(fileName)", | |
headers: [ | |
"host": URL(string: endpoint)!.host!, | |
"x-amz-date": date, | |
"content-type": contentType, | |
"content-length": String(fileSize), | |
"x-amz-content-sha256": payloadHash | |
], | |
payload: fileData | |
) | |
let authHeader = "AWS4-HMAC-SHA256 " + | |
"Credential=\(accessKeyId)/\(date.prefix(8))/\(region)/s3/aws4_request, " + | |
"SignedHeaders=content-length;content-type;host;x-amz-content-sha256;x-amz-date, " + | |
"Signature=\(signature)" | |
request.setValue(authHeader, forHTTPHeaderField: "Authorization") | |
// Debug print | |
print("Request URL: \(url)") | |
print("Headers:") | |
request.allHTTPHeaderFields?.forEach { print(" \($0): \($1)") } | |
// Add this before the upload | |
print("File exists: \(FileManager.default.fileExists(atPath: fileURL.path))") | |
print("File is readable: \(FileManager.default.isReadableFile(atPath: fileURL.path))") | |
// Use URLSession.upload(for:from:) with the file URL directly instead of data | |
let (data, response) = try await URLSession.shared.upload(for: request, fromFile: fileURL) | |
if let httpResponse = response as? HTTPURLResponse { | |
print("Status code: \(httpResponse.statusCode)") | |
if let responseString = String(data: data, encoding: .utf8) { | |
print("Response body: \(responseString)") | |
} | |
guard (200...299).contains(httpResponse.statusCode) else { | |
throw URLError(.badServerResponse) | |
} | |
} | |
print("ock", response) | |
} | |
// Update generateAWSSignature to ensure canonical request is exactly correct | |
private func generateAWSSignature(httpMethod: String, path: String, headers: [String: String], payload: Data) -> String { | |
// Step 1: Create canonical request | |
let payloadHash = payload.isEmpty ? "UNSIGNED-PAYLOAD" : SHA256.hash(data: payload) | |
.compactMap { String(format: "%02x", $0) } | |
.joined() | |
let canonicalHeaders = headers | |
.sorted(by: { $0.key < $1.key }) | |
.map { "\($0.key.lowercased()):\($0.value)\n" } | |
.joined() | |
let signedHeaders = headers.keys | |
.map { $0.lowercased() } | |
.sorted() | |
.joined(separator: ";") | |
let canonicalRequest = [ | |
httpMethod, | |
path, | |
"", // Query string (empty in this case) | |
canonicalHeaders, | |
signedHeaders, | |
payloadHash | |
].joined(separator: "\n") | |
// Step 2: Create string to sign | |
let algorithm = "AWS4-HMAC-SHA256" | |
let requestDate = headers["x-amz-date"]! | |
let credentialScope = "\(requestDate.prefix(8))/\(region)/s3/aws4_request" | |
let canonicalRequestHash = SHA256.hash(data: canonicalRequest.data(using: .utf8)!) | |
.compactMap { String(format: "%02x", $0) } | |
.joined() | |
let stringToSign = [ | |
algorithm, | |
requestDate, | |
credentialScope, | |
canonicalRequestHash | |
].joined(separator: "\n") | |
// Step 3: Calculate the signature | |
let dateKey = hmac(key: "AWS4" + secretAccessKey, data: String(requestDate.prefix(8))) | |
let dateRegionKey = hmac(key: dateKey, data: region) | |
let dateRegionServiceKey = hmac(key: dateRegionKey, data: "s3") | |
let signingKey = hmac(key: dateRegionServiceKey, data: "aws4_request") | |
let signature = hmac(key: signingKey, data: stringToSign) | |
.compactMap { String(format: "%02x", $0) } | |
.joined() | |
return signature | |
} | |
private func hmac(key: [UInt8], data: String) -> [UInt8] { | |
let symmetricKey = SymmetricKey(data: key) | |
let dataToHash = data.data(using: .utf8)! | |
let signature = HMAC<SHA256>.authenticationCode(for: dataToHash, using: symmetricKey) | |
return Array(signature) | |
} | |
private func hmac(key: String, data: String) -> [UInt8] { | |
return hmac(key: Array(key.utf8), data: data) | |
} | |
} | |
import SwiftUI | |
struct BackblazeTest: View { | |
let bb = BackblazeS3Uploader() | |
func runTest() async { | |
// test_audi | |
let assets_folder = try! root_folder.createSubfolder(named: "assets") | |
let test_audio = try! assets_folder.subfolder(at: "dropped").createFileIfNeeded(at: "test.mp3") | |
print("running test") | |
try? await bb.uploadAudio(fileURL: test_audio.url, fileName: test_audio.name) | |
} | |
var body: some View { | |
// Button("permssions") { | |
// Task { | |
// FileAccessManager.request_home_access(cb: { | |
// print("yay") | |
// }) | |
// } | |
// } | |
Button("Upload") { | |
Task { | |
await runTest() | |
} | |
print("APPEARED") | |
} | |
Button("Download") { | |
Task { | |
do { | |
let data = try await bb.downloadAudio(fileName: "test.mp3") | |
// let payloadHash = SHA256.hash(data: data) | |
// .compactMap { String(format: "%02x", $0) } | |
// .joined() | |
let target_location = assets_folder.url.appendingPathComponent("test.mp3") | |
try data.write(to: target_location) | |
// try TunaMusicPlayer.shared.playFile(file: target_location) | |
} catch { | |
print(error) | |
} | |
} | |
print("APPEARED") | |
} | |
} | |
} | |
#Preview { | |
BackblazeTest() | |
} | |
import Files | |
struct S3File { | |
let sha256: String | |
let file_extension: String | |
func check_integrity() -> Bool { | |
fatalError("not implemented") | |
} | |
} | |
extension File { | |
func on_s3 (s3_files: [S3File]) -> Bool { | |
let hash = self.sha256 | |
let found: S3File? = s3_files.first {s3_file in hash == s3_file.sha256 } | |
return found != nil | |
} | |
} | |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment