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 {
276 276
277 // MARK: - Plain-text fetch 277 // MARK: - Plain-text fetch
278 278
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
279 /// Fetch the contents of a URL as plain text, using the same authorization header. 316 /// Fetch the contents of a URL as plain text, using the same authorization header.
280 /// Used for build logs and other non-GraphQL resources. 317 /// Used for build logs and other non-GraphQL resources.
281 func fetchText(url: URL) async throws -> String { 318 func fetchText(url: URL) async throws -> String {
Hutch/Views/Repositories/ArtifactsView.swift +19 −2
@@ -12,11 +12,11 @@ struct ArtifactsView: View {
12 /// Passed in rather than recomputed: RepositoryDetailView already owns this 12 /// Passed in rather than recomputed: RepositoryDetailView already owns this
13 /// check and gates its other management surfaces on it. 13 /// check and gates its other management surfaces on it.
14 var canManage: Bool = false 14 var canManage: Bool = false
15 @Environment(\.openURL) private var openURL
16 15
17 @State private var uploadTargetRef: String? 16 @State private var uploadTargetRef: String?
18 @State private var isImporting = false 17 @State private var isImporting = false
19 @State private var pendingDeletion: ArtifactInfo? 18 @State private var pendingDeletion: ArtifactInfo?
19 @State private var downloadedFile: DownloadedArtifact?
20 20
21 private var isOwnedByCurrentUser: Bool { canManage } 21 private var isOwnedByCurrentUser: Bool { canManage }
22 22
@@ -63,7 +63,14 @@ struct ArtifactsView: View {
63 Section { 63 Section {
64 ForEach(refArtifacts.artifacts) { artifact in 64 ForEach(refArtifacts.artifacts) { artifact in
65 ArtifactRow(artifact: artifact) { 65 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 }
67 } 74 }
68 // See MailingListListView: a full-swipe destructive 75 // See MailingListListView: a full-swipe destructive
69 // action animates the row out before the confirmation. 76 // action animates the row out before the confirmation.
@@ -131,6 +138,9 @@ struct ArtifactsView: View {
131 .themedList() 138 .themedList()
132 .listStyle(.insetGrouped) 139 .listStyle(.insetGrouped)
133 .srhtErrorBanner(error: $vm.error) 140 .srhtErrorBanner(error: $vm.error)
141 .sheet(item: $downloadedFile) { download in
142 FileContentShareSheet(activityItems: [download.url])
143 }
134 .task { 144 .task {
135 // Tags drive the picker above and are not otherwise needed by this tab. 145 // Tags drive the picker above and are not otherwise needed by this tab.
136 if isOwnedByCurrentUser, viewModel.tags.isEmpty { 146 if isOwnedByCurrentUser, viewModel.tags.isEmpty {
@@ -184,6 +194,13 @@ struct ArtifactsView: View {
184 } 194 }
185} 195}
186 196
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
187private struct ArtifactRow: View { 204private struct ArtifactRow: View {
188 let artifact: ArtifactInfo 205 let artifact: ArtifactInfo
189 let onDownload: () -> Void 206 let onDownload: () -> Void
Hutch/Views/Repositories/RepositoryDetailViewModel.swift +31
@@ -542,6 +542,14 @@ final class RepositoryDetailViewModel {
542 return false 542 return false
543 } 543 }
544 544
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
545 do { 553 do {
546 _ = try await client.executeMultipart( 554 _ = try await client.executeMultipart(
547 service: service, 555 service: service,
@@ -567,6 +575,29 @@ final class RepositoryDetailViewModel {
567 } 575 }
568 } 576 }
569 577
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
570 @discardableResult 601 @discardableResult
571 func deleteArtifact(id: Int) async -> Bool { 602 func deleteArtifact(id: Int) async -> Bool {
572 guard !isMutatingArtifact else { return false } 603 guard !isMutatingArtifact else { return false }