Commit 0c8cbc1f07

0c8cbc1f0789f0c9fe5e1176a1293116e67619c6

parent: ff141c0e34

Unsigned

cmc <hello@cleberg.net> · 2026-07-16 06:06 UTC

fix: download artifacts through the API instead of handing them to Safari

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 <api origin>/query/artifact/<checksum>/<filename>, 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.

Layout: unified · split

Hutch/Networking/SRHTClient.swift +37
@@ -276,6 +276,43 @@ final class SRHTClient: Sendable {
276276
277277 // MARK: - Plain-text fetch
278278
279 /// Fetch the bytes at a URL using the same authorization header.
280 ///
281 /// sr.ht serves some resources from the API origin rather than the web one —
282 /// `Artifact.url` is `https://git.sr.ht/query/artifact/<checksum>/<filename>`
283 /// — and those return an auth error to anything without a bearer token. They
284 /// cannot be handed to a browser; they have to be fetched here.
285 func fetchData(url: URL) async throws -> Data {
286 guard let token = tokenLock.withLock({ $0 }), !token.isEmpty else {
287 throw SRHTError.unauthorized
288 }
289 guard Self.isTrustedAuthenticatedTextURL(url) else {
290 throw SRHTError.invalidAuthenticatedURL(url)
291 }
292
293 var request = URLRequest(url: url)
294 request.setValue(Bundle.main.hutchUserAgent, forHTTPHeaderField: "User-Agent")
295 request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
296
297 let (data, response): (Data, URLResponse)
298 do {
299 (data, response) = try await session.data(for: request)
300 } catch {
301 throw SRHTError.networkError(error)
302 }
303
304 if let http = response as? HTTPURLResponse {
305 if http.statusCode == 401 {
306 throw SRHTError.unauthorized
307 }
308 if !(200...299).contains(http.statusCode) {
309 throw SRHTError.httpError(http.statusCode)
310 }
311 }
312
313 return data
314 }
315
279316 /// Fetch the contents of a URL as plain text, using the same authorization header.
280317 /// Used for build logs and other non-GraphQL resources.
281318 func fetchText(url: URL) async throws -> String {
Hutch/Views/Repositories/ArtifactsView.swift +19 −2
@@ -12,11 +12,11 @@ struct ArtifactsView: View {
1212 /// Passed in rather than recomputed: RepositoryDetailView already owns this
1313 /// check and gates its other management surfaces on it.
1414 var canManage: Bool = false
15 @Environment(\.openURL) private var openURL
1615
1716 @State private var uploadTargetRef: String?
1817 @State private var isImporting = false
1918 @State private var pendingDeletion: ArtifactInfo?
19 @State private var downloadedFile: DownloadedArtifact?
2020
2121 private var isOwnedByCurrentUser: Bool { canManage }
2222
@@ -63,7 +63,14 @@ struct ArtifactsView: View {
6363 Section {
6464 ForEach(refArtifacts.artifacts) { artifact in
6565 ArtifactRow(artifact: artifact) {
66 openURL(artifact.url)
66 Task {
67 // Artifact.url is on the API origin and 401s
68 // without a bearer token, so it cannot be handed
69 // to a browser. Fetch it and share the file.
70 if let fileURL = await viewModel.downloadArtifact(artifact) {
71 downloadedFile = DownloadedArtifact(url: fileURL)
72 }
73 }
6774 }
6875 // See MailingListListView: a full-swipe destructive
6976 // action animates the row out before the confirmation.
@@ -131,6 +138,9 @@ struct ArtifactsView: View {
131138 .themedList()
132139 .listStyle(.insetGrouped)
133140 .srhtErrorBanner(error: $vm.error)
141 .sheet(item: $downloadedFile) { download in
142 FileContentShareSheet(activityItems: [download.url])
143 }
134144 .task {
135145 // Tags drive the picker above and are not otherwise needed by this tab.
136146 if isOwnedByCurrentUser, viewModel.tags.isEmpty {
@@ -184,6 +194,13 @@ struct ArtifactsView: View {
184194 }
185195}
186196
197/// Wraps the downloaded file for `.sheet(item:)`. URL is not Identifiable, and
198/// conforming a stdlib type retroactively is worse than a four-line struct.
199private struct DownloadedArtifact: Identifiable {
200 let id = UUID()
201 let url: URL
202}
203
187204private struct ArtifactRow: View {
188205 let artifact: ArtifactInfo
189206 let onDownload: () -> Void
Hutch/Views/Repositories/RepositoryDetailViewModel.swift +31
@@ -542,6 +542,14 @@ final class RepositoryDetailViewModel {
542542 return false
543543 }
544544
545 // sr.ht streams the upload into S3, which rejects a zero-part multipart
546 // completion with "MalformedXML" — an error that says nothing about the
547 // actual problem. Catch it here where we can name it.
548 guard !fileData.isEmpty else {
549 self.error = "\(fileURL.lastPathComponent) is empty. SourceHut rejects zero-byte artifacts."
550 return false
551 }
552
545553 do {
546554 _ = try await client.executeMultipart(
547555 service: service,
@@ -567,6 +575,29 @@ final class RepositoryDetailViewModel {
567575 }
568576 }
569577
578 /// Downloads an artifact and returns a local file URL to share.
579 ///
580 /// `Artifact.url` points at the API origin, not the web one, and returns an
581 /// auth error to anything without a bearer token — so it cannot be opened in
582 /// a browser. Fetch it here and hand the user the file instead.
583 func downloadArtifact(_ artifact: ArtifactInfo) async -> URL? {
584 guard !isMutatingArtifact else { return nil }
585 isMutatingArtifact = true
586 error = nil
587 defer { isMutatingArtifact = false }
588
589 do {
590 let data = try await client.fetchData(url: artifact.url)
591 let destination = FileManager.default.temporaryDirectory
592 .appendingPathComponent(artifact.filename)
593 try data.write(to: destination, options: .atomic)
594 return destination
595 } catch {
596 self.error = "Couldn't download \(artifact.filename). \(error.userFacingMessage)"
597 return nil
598 }
599 }
600
570601 @discardableResult
571602 func deleteArtifact(id: Int) async -> Bool {
572603 guard !isMutatingArtifact else { return false }