16b96478c2
GitHubUploader and CodebergUploader read the file SHA via GET before issuing the PUT, but the GET went through URLSession's default cache. On the next save the cached body would be replayed and the PUT would target the previous commit's SHA, returning HTTP 409 conflict. The fetchCurrentSHA calls now set .reloadIgnoringLocalAndRemoteCacheData and a no-cache header, matching the pattern in RelayClient and ThreadClient.
95 lines
3.7 KiB
Swift
95 lines
3.7 KiB
Swift
import Foundation
|
|
|
|
/// Uploads the social.org file to a GitHub repository via the Contents API.
|
|
///
|
|
/// Uses `GET /repos/{owner}/{repo}/contents/{path}` to fetch the current SHA,
|
|
/// then `PUT` to create or update the file.
|
|
/// Required token scope: `repo` (or `public_repo` for public repositories).
|
|
public struct GitHubUploader: FeedUploader {
|
|
|
|
public let token: String
|
|
public let owner: String
|
|
public let repo: String
|
|
public let path: String
|
|
public let branch: String
|
|
private let session: URLSession
|
|
|
|
private static let apiBase = "https://api.github.com"
|
|
|
|
public init(
|
|
token: String,
|
|
owner: String,
|
|
repo: String,
|
|
path: String = "social.org",
|
|
branch: String = "main",
|
|
session: URLSession = .shared
|
|
) {
|
|
self.token = token
|
|
self.owner = owner
|
|
self.repo = repo
|
|
self.path = path
|
|
self.branch = branch
|
|
self.session = session
|
|
}
|
|
|
|
public func upload(content: String) async throws {
|
|
let sha = try? await fetchCurrentSHA()
|
|
let encoded = Data(content.utf8).base64EncodedString()
|
|
|
|
var body: [String: Any] = [
|
|
"message": "Update social.org via iOS",
|
|
"content": encoded,
|
|
"branch": branch
|
|
]
|
|
if let sha { body["sha"] = sha }
|
|
|
|
let url = try contentsURL()
|
|
var request = URLRequest(url: url)
|
|
request.httpMethod = "PUT"
|
|
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
|
|
request.setValue("application/vnd.github+json", forHTTPHeaderField: "Accept")
|
|
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
|
request.httpBody = try? JSONSerialization.data(withJSONObject: body)
|
|
|
|
let (_, response) = try await perform(request)
|
|
if let http = response as? HTTPURLResponse, !(200..<300).contains(http.statusCode) {
|
|
throw UploadError.uploadFailed(statusCode: http.statusCode)
|
|
}
|
|
}
|
|
|
|
private func fetchCurrentSHA() async throws -> String {
|
|
let url = try contentsURL()
|
|
var request = URLRequest(url: url)
|
|
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
|
|
request.setValue("application/vnd.github+json", forHTTPHeaderField: "Accept")
|
|
// The SHA changes on every commit; URLSession's default caching would
|
|
// return the previous commit's SHA on the next save, and PUT would
|
|
// then fail with HTTP 409 conflict.
|
|
request.cachePolicy = .reloadIgnoringLocalAndRemoteCacheData
|
|
request.setValue("no-cache", forHTTPHeaderField: "Cache-Control")
|
|
let (data, response) = try await perform(request)
|
|
if let http = response as? HTTPURLResponse, http.statusCode == 404 { throw UploadError.fileNotFound }
|
|
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
|
let sha = json["sha"] as? String else { throw UploadError.fileNotFound }
|
|
return sha
|
|
}
|
|
|
|
private func contentsURL() throws -> URL {
|
|
let urlString = "\(Self.apiBase)/repos/\(owner)/\(repo)/contents/\(path)"
|
|
guard let url = URL(string: urlString) else {
|
|
throw UploadError.invalidConfiguration("Invalid GitHub API URL: \(urlString)")
|
|
}
|
|
return url
|
|
}
|
|
|
|
private func perform(_ request: URLRequest) async throws -> (Data, URLResponse) {
|
|
do { return try await session.data(for: request) }
|
|
catch { throw UploadError.networkError(underlying: error.localizedDescription) }
|
|
}
|
|
|
|
/// Derives the public raw URL for the file (usable as `publicFeedURL`).
|
|
public var derivedPublicURL: String {
|
|
"https://raw.githubusercontent.com/\(owner)/\(repo)/\(branch)/\(path)"
|
|
}
|
|
}
|