Commit 0c8cbc1f07
Unsigned
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. | ||
| 199 | private struct DownloadedArtifact: Identifiable { | ||
| 200 | let id = UUID() | ||
| 201 | let url: URL | ||
| 202 | } | ||
| 203 | |||
| 187 | private struct ArtifactRow: View { | 204 | private 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 } |