krz/hutch

an ios client for sourcehut

clone: git clone https://gitbay.org/krz/hutch.git

871b04159aa47c0b0e62e2520c2f30e81f0f024b

verified · cmc

author: Christian Cleberg <hello@cleberg.net> · 2026-07-16T06:06:46Z

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.
 Hutch/Networking/SRHTClient.swift                  | 37 ++++++++++++++++++++++
 Hutch/Views/Repositories/ArtifactsView.swift       | 21 ++++++++++--
 .../Repositories/RepositoryDetailViewModel.swift   | 31 ++++++++++++++++++
 3 files changed, 87 insertions(+), 2 deletions(-)

diff --git a/Hutch/Networking/SRHTClient.swift b/Hutch/Networking/SRHTClient.swift
index 94531f4..8aaa4aa 100644
--- a/Hutch/Networking/SRHTClient.swift
+++ b/Hutch/Networking/SRHTClient.swift
@@ -276,6 +276,43 @@ final class SRHTClient: Sendable {
 
     // MARK: - Plain-text fetch
 
+    /// Fetch the bytes at a URL using the same authorization header.
+    ///
+    /// sr.ht serves some resources from the API origin rather than the web one —
+    /// `Artifact.url` is `https://git.sr.ht/query/artifact/<checksum>/<filename>`
+    /// — and those return an auth error to anything without a bearer token. They
+    /// cannot be handed to a browser; they have to be fetched here.
+    func fetchData(url: URL) async throws -> Data {
+        guard let token = tokenLock.withLock({ $0 }), !token.isEmpty else {
+            throw SRHTError.unauthorized
+        }
+        guard Self.isTrustedAuthenticatedTextURL(url) else {
+            throw SRHTError.invalidAuthenticatedURL(url)
+        }
+
+        var request = URLRequest(url: url)
+        request.setValue(Bundle.main.hutchUserAgent, forHTTPHeaderField: "User-Agent")
+        request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
+
+        let (data, response): (Data, URLResponse)
+        do {
+            (data, response) = try await session.data(for: request)
+        } catch {
+            throw SRHTError.networkError(error)
+        }
+
+        if let http = response as? HTTPURLResponse {
+            if http.statusCode == 401 {
+                throw SRHTError.unauthorized
+            }
+            if !(200...299).contains(http.statusCode) {
+                throw SRHTError.httpError(http.statusCode)
+            }
+        }
+
+        return data
+    }
+
     /// Fetch the contents of a URL as plain text, using the same authorization header.
     /// Used for build logs and other non-GraphQL resources.
     func fetchText(url: URL) async throws -> String {
diff --git a/Hutch/Views/Repositories/ArtifactsView.swift b/Hutch/Views/Repositories/ArtifactsView.swift
index 8843c6f..1d9fc6c 100644
--- a/Hutch/Views/Repositories/ArtifactsView.swift
+++ b/Hutch/Views/Repositories/ArtifactsView.swift
@@ -12,11 +12,11 @@ struct ArtifactsView: View {
     /// Passed in rather than recomputed: RepositoryDetailView already owns this
     /// check and gates its other management surfaces on it.
     var canManage: Bool = false
-    @Environment(\.openURL) private var openURL
 
     @State private var uploadTargetRef: String?
     @State private var isImporting = false
     @State private var pendingDeletion: ArtifactInfo?
+    @State private var downloadedFile: DownloadedArtifact?
 
     private var isOwnedByCurrentUser: Bool { canManage }
 
@@ -63,7 +63,14 @@ struct ArtifactsView: View {
                 Section {
                     ForEach(refArtifacts.artifacts) { artifact in
                         ArtifactRow(artifact: artifact) {
-                            openURL(artifact.url)
+                            Task {
+                                // Artifact.url is on the API origin and 401s
+                                // without a bearer token, so it cannot be handed
+                                // to a browser. Fetch it and share the file.
+                                if let fileURL = await viewModel.downloadArtifact(artifact) {
+                                    downloadedFile = DownloadedArtifact(url: fileURL)
+                                }
+                            }
                         }
                         // See MailingListListView: a full-swipe destructive
                         // action animates the row out before the confirmation.
@@ -131,6 +138,9 @@ struct ArtifactsView: View {
         .themedList()
         .listStyle(.insetGrouped)
         .srhtErrorBanner(error: $vm.error)
+        .sheet(item: $downloadedFile) { download in
+            FileContentShareSheet(activityItems: [download.url])
+        }
         .task {
             // Tags drive the picker above and are not otherwise needed by this tab.
             if isOwnedByCurrentUser, viewModel.tags.isEmpty {
@@ -184,6 +194,13 @@ struct ArtifactsView: View {
     }
 }
 
+/// Wraps the downloaded file for `.sheet(item:)`. URL is not Identifiable, and
+/// conforming a stdlib type retroactively is worse than a four-line struct.
+private struct DownloadedArtifact: Identifiable {
+    let id = UUID()
+    let url: URL
+}
+
 private struct ArtifactRow: View {
     let artifact: ArtifactInfo
     let onDownload: () -> Void
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 }