From c4930f31ffc5d7c5de5eeafd3da184c7691f8ab7 Mon Sep 17 00:00:00 2001 From: Christian Cleberg Date: Wed, 15 Jul 2026 23:50:18 -0500 Subject: feat: upload and delete repository artifacts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit uploadArtifact and deleteArtifact existed in git.sr.ht's API but were never called, so the artifacts tab could only download. Upload is reachable two ways, and the second is the one that matters: the tab only lists tags that already carry an artifact, so a per-section button alone could never attach the first one to a tag — and the app cannot create that first artifact any other way. A toolbar action picks from all tags instead. The file variable is top-level here, unlike meta's avatar upload where it nests inside an input object. This is the second caller of executeMultipart, which until now only served avatars. Artifacts are tarballs and signatures, so the upload declares application/octet-stream rather than guessing a type from the extension. Security-scoped access is released after the read, since fileImporter hands back a URL the app does not otherwise own. Both actions are gated on repository ownership, reusing the check RepositoryDetailView already applies to its other management surfaces rather than recomputing it. Delete sits behind a confirmation naming the file. --- .../Repositories/RepositoryDetailViewModel.swift | 120 +++++++++++++++++++++ 1 file changed, 120 insertions(+) (limited to 'Hutch/Views/Repositories/RepositoryDetailViewModel.swift') diff --git a/Hutch/Views/Repositories/RepositoryDetailViewModel.swift b/Hutch/Views/Repositories/RepositoryDetailViewModel.swift index 4839437..dce39c1 100644 --- a/Hutch/Views/Repositories/RepositoryDetailViewModel.swift +++ b/Hutch/Views/Repositories/RepositoryDetailViewModel.swift @@ -75,6 +75,20 @@ private struct PathObject: Decodable, Sendable { let text: String? } +private struct UploadArtifactResponse: Decodable, Sendable { + let uploadArtifact: ArtifactInfo +} + +private struct DeleteArtifactResponse: Decodable, Sendable { + /// Nullable in the schema: sr.ht returns null when there was no artifact to + /// remove, which is still a success from the caller's point of view. + let deleteArtifact: ArtifactIDPayload? +} + +private struct ArtifactIDPayload: Decodable, Sendable { + let id: Int +} + private struct ArtifactsResponse: Decodable, Sendable { let repository: ArtifactsRepository? } @@ -144,6 +158,7 @@ final class RepositoryDetailViewModel { private(set) var referenceArtifacts: [ReferenceWithArtifacts] = [] private(set) var isLoadingArtifacts = false + private(set) var isMutatingArtifact = false // MARK: - Error @@ -457,6 +472,26 @@ final class RepositoryDetailViewModel { // MARK: - Artifacts + /// `file` is a top-level Upload variable here, unlike meta's avatar upload + /// where it is nested inside an input object. + private static let uploadArtifactMutation = """ + mutation uploadArtifact($repoId: Int!, $revspec: String!, $file: Upload!) { + uploadArtifact(repoId: $repoId, revspec: $revspec, file: $file) { + id + filename + checksum + size + url + } + } + """ + + private static let deleteArtifactMutation = """ + mutation deleteArtifact($id: Int!) { + deleteArtifact(id: $id) { id } + } + """ + private static let artifactsQuery = """ query artifacts($rid: ID!) { repository(rid: $rid) { @@ -480,6 +515,91 @@ final class RepositoryDetailViewModel { } """ + /// Attaches a file to the tag named by `revspec`. + /// + /// sr.ht requires the filename to be unique among the repository's artifacts, + /// and rejects a duplicate rather than replacing it, so the error is surfaced + /// as-is rather than being retried. + @discardableResult + func uploadArtifact(revspec: String, fileURL: URL) async -> Bool { + guard !isMutatingArtifact else { return false } + isMutatingArtifact = true + error = nil + defer { isMutatingArtifact = false } + + let needsScopedAccess = fileURL.startAccessingSecurityScopedResource() + defer { + if needsScopedAccess { + fileURL.stopAccessingSecurityScopedResource() + } + } + + let fileData: Data + do { + fileData = try Data(contentsOf: fileURL) + } catch { + self.error = "Couldn't read \(fileURL.lastPathComponent)." + return false + } + + do { + _ = try await client.executeMultipart( + service: service, + query: Self.uploadArtifactMutation, + variables: [ + "repoId": repository.id, + "revspec": revspec, + "file": nil as String? as Any + ], + file: MultipartUploadFile( + variablePath: "file", + fileData: fileData, + fileName: fileURL.lastPathComponent, + mimeType: Self.mimeType(for: fileURL) + ), + responseType: UploadArtifactResponse.self + ) + await reloadArtifacts() + return true + } catch { + self.error = "Couldn't upload \(fileURL.lastPathComponent). \(error.userFacingMessage)" + return false + } + } + + @discardableResult + func deleteArtifact(id: Int) async -> Bool { + guard !isMutatingArtifact else { return false } + isMutatingArtifact = true + error = nil + defer { isMutatingArtifact = false } + + do { + _ = try await client.execute( + service: service, + query: Self.deleteArtifactMutation, + variables: ["id": id], + responseType: DeleteArtifactResponse.self + ) + await reloadArtifacts() + return true + } catch { + self.error = "Couldn't delete the artifact. \(error.userFacingMessage)" + return false + } + } + + private func reloadArtifacts() async { + isLoadingArtifacts = false + await loadArtifacts() + } + + /// Artifacts are release tarballs and signatures rather than media, so a + /// generic binary type is honest more often than guessing from the extension. + private nonisolated static func mimeType(for url: URL) -> String { + "application/octet-stream" + } + func loadArtifacts() async { guard !isLoadingArtifacts else { return } isLoadingArtifacts = true -- cgit v1.2.3 From 871b04159aa47c0b0e62e2520c2f30e81f0f024b Mon Sep 17 00:00:00 2001 From: Christian Cleberg Date: Thu, 16 Jul 2026 01:06:46 -0500 Subject: fix: download artifacts through the API instead of handing them to Safari MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 /query/artifact//, 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(-) (limited to 'Hutch/Views/Repositories/RepositoryDetailViewModel.swift') 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//` + /// — 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 } -- cgit v1.2.3