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