From 871b04159aa47c0b0e62e2520c2f30e81f0f024b Mon Sep 17 00:00:00 2001 From: Christian Cleberg Date: Thu, 16 Jul 2026 01:06:46 -0500 Subject: fix: download artifacts through the API instead of handing them to Safari MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tapping download opened Artifact.url in the browser, which answered with "Authorization header is required". That URL is not a web page: git.sr.ht resolves it to /query/artifact//, which demands a bearer token. Safari has none and no way to get one, so the download could never have worked — this predates the upload work. Fetch it with the client that already holds the token and hand the user the file through a share sheet. fetchData mirrors fetchText, including its host guard, so an authenticated request still cannot be aimed anywhere but *.sr.ht over https. Also guards zero-byte uploads. sr.ht streams into S3, which rejects a zero-part multipart completion with "MalformedXML: UnknownError" — an error that says nothing about the cause and cost a round of testing to identify. Empty files are now refused by name before the request is made. --- .../Repositories/RepositoryDetailViewModel.swift | 31 ++++++++++++++++++++++ 1 file changed, 31 insertions(+) (limited to 'Hutch/Views/Repositories/RepositoryDetailViewModel.swift') diff --git a/Hutch/Views/Repositories/RepositoryDetailViewModel.swift b/Hutch/Views/Repositories/RepositoryDetailViewModel.swift index dce39c1..9b6b942 100644 --- a/Hutch/Views/Repositories/RepositoryDetailViewModel.swift +++ b/Hutch/Views/Repositories/RepositoryDetailViewModel.swift @@ -542,6 +542,14 @@ final class RepositoryDetailViewModel { return false } + // sr.ht streams the upload into S3, which rejects a zero-part multipart + // completion with "MalformedXML" — an error that says nothing about the + // actual problem. Catch it here where we can name it. + guard !fileData.isEmpty else { + self.error = "\(fileURL.lastPathComponent) is empty. SourceHut rejects zero-byte artifacts." + return false + } + do { _ = try await client.executeMultipart( service: service, @@ -567,6 +575,29 @@ final class RepositoryDetailViewModel { } } + /// Downloads an artifact and returns a local file URL to share. + /// + /// `Artifact.url` points at the API origin, not the web one, and returns an + /// auth error to anything without a bearer token — so it cannot be opened in + /// a browser. Fetch it here and hand the user the file instead. + func downloadArtifact(_ artifact: ArtifactInfo) async -> URL? { + guard !isMutatingArtifact else { return nil } + isMutatingArtifact = true + error = nil + defer { isMutatingArtifact = false } + + do { + let data = try await client.fetchData(url: artifact.url) + let destination = FileManager.default.temporaryDirectory + .appendingPathComponent(artifact.filename) + try data.write(to: destination, options: .atomic) + return destination + } catch { + self.error = "Couldn't download \(artifact.filename). \(error.userFacingMessage)" + return nil + } + } + @discardableResult func deleteArtifact(id: Int) async -> Bool { guard !isMutatingArtifact else { return false } -- cgit v1.2.3