Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 52 additions & 6 deletions damus/Core/NIPs/NIP98/NIP98AuthenticatedRequest.swift
Original file line number Diff line number Diff line change
Expand Up @@ -19,11 +19,18 @@ enum HTTPPayloadType: String {
case binary = "application/octet-stream"
}

func make_nip98_authenticated_request(method: HTTPMethod, url: URL, payload: Data?, payload_type: HTTPPayloadType?, auth_keypair: Keypair) async throws -> (data: Data, response: URLResponse) {
var request = URLRequest(url: url)
request.httpMethod = method.rawValue
request.httpBody = payload

/// Creates a NIP-98 authentication event for HTTP requests.
///
/// This generates the Nostr event used for authenticating HTTP requests according to NIP-98.
/// The event includes the request URL, HTTP method, and optionally a payload hash.
///
/// - Parameters:
/// - method: The HTTP method (GET, POST, PUT, DELETE)
/// - url: The full URL being requested
/// - payload: Optional request body data to hash and include in the auth event
/// - auth_keypair: The keypair to sign the auth event with
/// - Returns: The NIP-98 authentication event, or nil if event creation fails
func create_nip98_auth_event(method: HTTPMethod, url: URL, payload: Data?, auth_keypair: Keypair) -> NdbNote? {
var tag_pairs = [
["u", url.absoluteString],
["method", method.rawValue],
Expand All @@ -35,14 +42,32 @@ func make_nip98_authenticated_request(method: HTTPMethod, url: URL, payload: Dat
tag_pairs.append(["payload", payload_hash_hex])
}

let auth_note = NdbNote(
return NdbNote(
content: "",
keypair: auth_keypair,
kind: 27235,
tags: tag_pairs,
createdAt: UInt32(Date().timeIntervalSince1970)
)
}

/// Makes an HTTP request authenticated with a pre-built NIP-98 event.
///
/// This overload accepts a pre-built and signed NIP-98 authentication event.
///
/// - Parameters:
/// - method: The HTTP method (GET, POST, PUT, DELETE)
/// - url: The full URL to request
/// - payload: Optional request body data
/// - payload_type: Optional Content-Type for the payload
/// - auth_note: The pre-built NIP-98 authentication event
/// - Returns: A tuple containing the response data and URLResponse
/// - Throws: Errors from URL loading or JSON encoding
func make_nip98_authenticated_request(method: HTTPMethod, url: URL, payload: Data?, payload_type: HTTPPayloadType?, auth_note: NdbNote) async throws -> (data: Data, response: URLResponse) {
var request = URLRequest(url: url)
request.httpMethod = method.rawValue
request.httpBody = payload

let auth_note_json_data: Data = try encode_json_data(auth_note)
let auth_note_base64: String = base64_encode(auth_note_json_data.bytes)

Expand All @@ -52,3 +77,24 @@ func make_nip98_authenticated_request(method: HTTPMethod, url: URL, payload: Dat
}
return try await URLSession.shared.data(for: request)
}

/// Makes an HTTP request authenticated with NIP-98.
///
/// This function creates a NIP-98 authentication event and includes it in the request's
/// Authorization header as a base64-encoded Nostr event.
///
/// - Parameters:
/// - method: The HTTP method (GET, POST, PUT, DELETE)
/// - url: The full URL to request
/// - payload: Optional request body data
/// - payload_type: Optional Content-Type for the payload
/// - auth_keypair: The keypair to sign the auth event with
/// - Returns: A tuple containing the response data and URLResponse
/// - Throws: Errors from URL loading or JSON encoding
func make_nip98_authenticated_request(method: HTTPMethod, url: URL, payload: Data?, payload_type: HTTPPayloadType?, auth_keypair: Keypair) async throws -> (data: Data, response: URLResponse) {
guard let auth_note = create_nip98_auth_event(method: method, url: url, payload: payload, auth_keypair: auth_keypair) else {
throw URLError(.unknown)
}

return try await make_nip98_authenticated_request(method: method, url: url, payload: payload, payload_type: payload_type, auth_note: auth_note)
}
55 changes: 41 additions & 14 deletions damus/Shared/Media/GIF/GIFPickerView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -347,44 +347,71 @@ private extension PurpleGIFAPIError {
/// Converts Purple GIF API failures into a reusable user-presentable error.
func userPresentableError(action: String, currentQuery: String?) -> ErrorView.UserPresentableError {
let queryContext = currentQuery.map { "query=\($0)" } ?? "featured"

// Build comprehensive technical info including error context
let baseTechnicalInfo = "GIF picker error while \(action); context=\(queryContext)"
let fullTechnicalInfo: String

if let errorContext = self.context {
fullTechnicalInfo = "\(baseTechnicalInfo); \(errorContext.debugDescription)"
Comment thread
danieldaquino marked this conversation as resolved.
} else {
fullTechnicalInfo = baseTechnicalInfo
}

switch self {
case .unauthorized:
return .init(
user_visible_description: NSLocalizedString("You need an active Purple subscription to use GIF search.", comment: "Error shown when the user is not authorized to use the GIF picker."),
tip: NSLocalizedString("Make sure you're signed in with the right account and that your Purple subscription is active, then try again.", comment: "Advice shown when GIF picker access is denied."),
technical_info: "GIF picker unauthorized while \(action); context=\(queryContext)"
)
case .unauthorized(let context):
// For 401 errors, show the server's error message with context
let serverMessage = context?.extractedMessage

if let message = serverMessage, !message.isEmpty {
// Quote and provide context around the server message
let contextualizedMessage = String(format: NSLocalizedString("Access to the GIF service was denied by the server with the following message: \"%@\"", comment: "Error message format that quotes the server's error message"), message)
return .init(
user_visible_description: contextualizedMessage,
tip: NSLocalizedString("If the problem persists, copy the technical information and send it to support.", comment: "Advice shown when GIF picker access is denied with server message."),
technical_info: fullTechnicalInfo
)
} else {
// Fallback to generic message if no server message available
return .init(
user_visible_description: NSLocalizedString("Access to the GIF service was denied.", comment: "Generic error shown when the user is not authorized to use the GIF picker and no server message is available."),
tip: NSLocalizedString("This could be due to an expired subscription, authentication issue, or network problem. Copy the technical information and send it to support for help.", comment: "Advice shown when GIF picker access is denied without specific details."),
technical_info: fullTechnicalInfo
)
}
case .invalidURL:
return .init(
user_visible_description: NSLocalizedString("The GIF service is misconfigured.", comment: "Error shown when the GIF picker generated an invalid URL."),
tip: NSLocalizedString("Try again later. If this keeps happening, copy the technical information and send it to support.", comment: "Advice shown when the GIF picker URL is invalid."),
technical_info: "GIF picker invalid URL while \(action); context=\(queryContext)"
technical_info: fullTechnicalInfo
)
case .invalidResponse:
return .init(
user_visible_description: NSLocalizedString("The GIF service returned an unexpected response.", comment: "Error shown when the GIF picker receives an invalid server response."),
tip: NSLocalizedString("Try again in a moment. If it keeps happening, copy the technical information and send it to support.", comment: "Advice shown when the GIF picker receives an invalid server response."),
technical_info: "GIF picker invalid response while \(action); context=\(queryContext)"
technical_info: fullTechnicalInfo
)
case .decodingError(let decodingError, let rawResponse):
case .decodingError(let decodingError, let rawResponse, _):
// Include decoding error details for legacy compatibility
let responseText = rawResponse ?? "<unavailable>"
let detailedInfo = "\(baseTechnicalInfo); error=\(String(describing: decodingError)); response=\(responseText)"
let withContext = self.context.map { "; \($0.debugDescription)" } ?? ""
return .init(
user_visible_description: NSLocalizedString("We couldn't understand the GIF data from the server.", comment: "Error shown when GIF response parsing fails."),
tip: NSLocalizedString("Try again in a moment. If the problem continues, copy the technical information and send it to support.", comment: "Advice shown when GIF response parsing fails."),
technical_info: "GIF picker decoding error while \(action); context=\(queryContext); error=\(String(describing: decodingError)); response=\(responseText)"
technical_info: detailedInfo + withContext
)
case .networkError(let networkError):
case .networkError(let networkError, _):
return .init(
user_visible_description: NSLocalizedString("We couldn't reach the GIF service.", comment: "Error shown when GIF loading fails because of a network issue."),
tip: NSLocalizedString("Check your internet connection and try again.", comment: "Advice shown when GIF loading fails because of a network issue."),
technical_info: "GIF picker network error while \(action); context=\(queryContext); error=\(String(describing: networkError))"
technical_info: "\(baseTechnicalInfo); error=\(String(describing: networkError))" + (self.context.map { "; \($0.debugDescription)" } ?? "")
)
case .upstreamError(let statusCode, let message):
case .upstreamError(let statusCode, let message, _):
return .init(
user_visible_description: NSLocalizedString("The GIF service is temporarily unavailable.", comment: "Error shown when the upstream GIF service fails."),
tip: NSLocalizedString("Try again in a moment. If the problem keeps happening, copy the technical information and send it to support.", comment: "Advice shown when the upstream GIF service fails."),
technical_info: "GIF picker upstream error while \(action); context=\(queryContext); status=\(statusCode); message=\(message ?? "none")"
technical_info: "\(baseTechnicalInfo); status=\(statusCode); message=\(message ?? "none")" + (self.context.map { "; \($0.debugDescription)" } ?? "")
)
}
}
Expand Down
Loading
Loading