diff --git a/Tarnax/App.swift b/Tarnax/App.swift index 4497af3..6f05980 100644 --- a/Tarnax/App.swift +++ b/Tarnax/App.swift @@ -144,12 +144,12 @@ struct TorrentRow: View { var body: some View { VStack(alignment: .leading, spacing: 10) { (dynamicTypeSize.isAccessibilitySize ? AnyLayout(VStackLayout(alignment: .leading, spacing: 6)) : AnyLayout(HStackLayout(alignment: .top))) { - Image(systemName: torrent.progress >= 1 ? "checkmark.circle.fill" : "arrow.down.circle.fill").foregroundStyle(torrent.progress >= 1 ? .green : .indigo).font(.title2) + Image(systemName: torrent.complete ? "checkmark.circle.fill" : "arrow.down.circle.fill").foregroundStyle(torrent.complete ? .green : .indigo).font(.title2) Text(torrent.name).font(.headline).lineLimit(dynamicTypeSize.isAccessibilitySize ? nil : 2) if !dynamicTypeSize.isAccessibilitySize { Spacer() } Text(torrent.progress, format: .percent.precision(.fractionLength(1))).font(.subheadline.monospacedDigit()).foregroundStyle(.secondary) } - ProgressView(value: min(max(torrent.progress, 0), 1)).accessibilityLabel("Download progress") + ProgressView(value: torrent.complete ? 1 : min(max(torrent.progress, 0), 1)).tint(torrent.complete ? .green : .indigo).accessibilityLabel("Download progress") (dynamicTypeSize.isAccessibilitySize ? AnyLayout(VStackLayout(alignment: .leading, spacing: 6)) : AnyLayout(HStackLayout())) { Label(bytes(torrent.dlspeed) + "/s", systemImage: "arrow.down").accessibilityLabel("Download speed").accessibilityValue(bytes(torrent.dlspeed) + " per second") Label(bytes(torrent.upspeed) + "/s", systemImage: "arrow.up").accessibilityLabel("Upload speed").accessibilityValue(bytes(torrent.upspeed) + " per second") @@ -173,7 +173,7 @@ struct LibraryView: View { switch filter { case "Downloading": matches = t.active case "Seeding": matches = ["uploading", "stalledUP", "forcedUP"].contains(t.state) - case "Completed": matches = t.progress >= 1 + case "Completed": matches = t.complete case "Paused": matches = t.state.hasPrefix("paused") || t.state.hasPrefix("stopped") case "Stalled": matches = t.state.hasPrefix("stalled") case "Queued": matches = t.state.hasPrefix("queued") @@ -208,14 +208,21 @@ struct LibraryView: View { } Section(home ? "Actively downloading · \(filtered.count)" : "Torrents · \(filtered.count)") { ForEach(filtered) { torrent in - NavigationLink(value: torrent) { TorrentRow(torrent: torrent) } + NavigationLink(value: torrent) { + VStack(alignment: .leading) { + TorrentRow(torrent: torrent) + if model.pendingActions.contains(torrent.hash) { ProgressView("Applying action…") } + } + } .contextMenu { - Button("Resume", systemImage: "play") { Task { await model.action("resume", hash: torrent.hash) } } - Button("Pause", systemImage: "pause") { Task { await model.action("pause", hash: torrent.hash) } } + Button("Resume", systemImage: "play") { Task { await model.action("resume", hash: torrent.hash) } }.disabled(!torrent.paused || model.pendingActions.contains(torrent.hash)) + Button("Pause", systemImage: "pause") { Task { await model.action("pause", hash: torrent.hash) } }.disabled(torrent.paused || model.pendingActions.contains(torrent.hash)) } } if filtered.isEmpty { ContentUnavailableView(home ? "No active downloads" : "No matching torrents", systemImage: home ? "checkmark.circle" : "magnifyingglass", description: Text(home ? "Queued, paused, stalled and completed torrents are in Library." : "Try another search or filter.")) } } + if let feedback = model.actionFeedback { Text(feedback).font(.callout).accessibilityAddTraits(.updatesFrequently) } + if let error = model.historyError { Text(error).foregroundStyle(.red) } if let date = model.lastRefresh { Text("Updated \(date.formatted(date: .omitted, time: .standard)) · refreshes every 3 seconds").font(.caption2).foregroundStyle(.secondary) } } .navigationTitle(home ? "Downloading" : "Library") @@ -281,11 +288,11 @@ struct AddView: View { struct TransferHistoryChart: View { let history: TransferHistory let updatedAt: Date? - @State private var minutes = 5 + @State private var minutes = 1440 @State private var selection: Date? @Environment(\.dynamicTypeSize) private var dynamicTypeSize - private var end: Date { updatedAt ?? Date() } + private var end: Date { Date() } private var start: Date { end.addingTimeInterval(-Double(minutes * 60)) } private var samples: [TransferSample] { history.samples(from: start, through: end) } private var selectedSample: TransferSample? { @@ -304,7 +311,8 @@ struct TransferHistoryChart: View { Text("1m").tag(1) Text("5m").tag(5) Text("15m").tag(15) - }.pickerStyle(.segmented).frame(maxWidth: 190) + Text("24h").tag(1440) + }.pickerStyle(.segmented).frame(maxWidth: 270) } (dynamicTypeSize.isAccessibilitySize ? AnyLayout(VStackLayout(alignment: .leading, spacing: 8)) : AnyLayout(HStackLayout(spacing: 20))) { Label("Download", systemImage: "arrow.down.circle.fill").foregroundStyle(.blue) @@ -371,7 +379,7 @@ struct TransferHistoryChart: View { Text("Download \(bytes(sample.download))/s · Upload \(bytes(sample.upload))/s") }.font(.caption.monospacedDigit()).accessibilityElement(children: .combine) } else { - Text("Updates every 3 seconds · this session") + Text("24-hour local history · gaps indicate no measurements") .font(.caption).foregroundStyle(.secondary) } } diff --git a/Tarnax/Core.swift b/Tarnax/Core.swift index acd0436..6384bd4 100644 --- a/Tarnax/Core.swift +++ b/Tarnax/Core.swift @@ -1,7 +1,11 @@ import Foundation +import CoreFoundation import SwiftUI import Security import LocalAuthentication +#if canImport(UserNotifications) +import UserNotifications +#endif struct Credentials: Codable { var server = "" @@ -38,6 +42,8 @@ struct Torrent: Decodable, Identifiable, Hashable { var progress: Double; var size: Int64; var dlspeed: Int64; var upspeed: Int64; var eta: Int64 var ratio: Double; var category: String; var tags: String var id: String { hash } + var complete: Bool { progress >= 1 || ["uploading", "stalledUP", "forcedUP", "queuedUP", "pausedUP", "stoppedUP"].contains(state) } + var paused: Bool { state.hasPrefix("paused") || state.hasPrefix("stopped") } var active: Bool { ["downloading", "forcedDL", "metaDL", "forcedMetaDL"].contains(state) } } struct TorrentFile: Decodable, Identifiable { @@ -45,9 +51,46 @@ struct TorrentFile: Decodable, Identifiable { var id: Int { index } } func bytes(_ value: Int64) -> String { ByteCountFormatter.string(fromByteCount: value, countStyle: .binary) } +func duration(_ seconds: Double) -> String { + guard seconds.isFinite, seconds >= 0 else { return "Unlimited" } + if seconds < 60 { return seconds.formatted(.number.precision(.fractionLength(0...1))) + " sec" } + if seconds < 3600 { return (seconds / 60).formatted(.number.precision(.fractionLength(0...1))) + " min" } + return (seconds / 3600).formatted(.number.precision(.fractionLength(0...1))) + " hr" +} func eta(_ value: Int64) -> String { guard value >= 0 && value < 8_640_000 else { return "∞" } - return value < 3600 ? "\(value / 60)m" : "\(value / 3600)h \((value % 3600) / 60)m" + return duration(Double(value)) +} +// Explicit API unit metadata: unknown fields retain their raw value. +enum StatFormat { + static let seconds: Set = ["eta", "time_active", "seeding_time", "dl_time", "up_time", "uptime", "average_time_queue", "disk_cache_ttl", "peer_turnover_interval", "request_timeout", "web_ui_session_timeout", "web_ui_ban_duration", "announce_interval", "reannounce", "slow_torrent_inactive_timer", "stop_tracker_timeout"] + static let minutes: Set = ["max_seeding_time", "max_inactive_seeding_time", "rss_refresh_interval", "save_resume_data_interval"] + static let sizes: Set = ["size", "total_size", "total_wasted", "total_uploaded", "total_downloaded", "total_uploaded_session", "total_downloaded_session", "total_buffers_size", "free_space_on_disk", "dl_info_data", "up_info_data", "alltime_dl", "alltime_ul", "piece_size", "total_size", "amount_left", "completed"] + static let speeds: Set = ["dl_info_speed", "up_info_speed", "dl_rate_limit", "up_rate_limit", "dl_limit", "up_limit", "alt_dl_limit", "alt_up_limit", "dlspeed", "upspeed", "dl_speed", "up_speed", "dl_speed_avg", "up_speed_avg"] + static func display(_ value: Any, key: String, preference: Bool = false) -> String { + guard let number = value as? NSNumber else { return String(describing: value) } + if CFGetTypeID(number) == CFBooleanGetTypeID() { return number.boolValue ? "On" : "Off" } + let n = number.doubleValue + if n < 0 { return "\(number) (server default / unlimited)" } + if key == "refresh_interval" { return duration(n / 1000) } + if seconds.contains(key) { return duration(n) } + if minutes.contains(key) { return duration(n * 60) } + if preference && key == "disk_cache" { return bytes(Int64(min(n, Double(Int64.max / 1_048_576))) * 1_048_576) } + if preference && ["slow_torrent_dl_rate_threshold", "slow_torrent_ul_rate_threshold"].contains(key) { return bytes(Int64(min(n, Double(Int64.max / 1024))) * 1024) + "/s" } + if speeds.contains(key) { return n == 0 && key.contains("limit") ? "Unlimited" : bytes(number.int64Value) + "/s" } + if sizes.contains(key) { return bytes(number.int64Value) } + return number.stringValue + } + static func unit(_ key: String) -> String { + if key == "refresh_interval" { return "milliseconds" } + if seconds.contains(key) { return "seconds" } + if minutes.contains(key) { return "minutes" } + if key == "disk_cache" { return "MiB" } + if key.hasPrefix("slow_torrent_") && key.hasSuffix("rate_threshold") { return "KiB/s" } + if speeds.contains(key) { return "bytes/s" } + if sizes.contains(key) { return "bytes" } + return "API units" + } } // Private ephemeral cookie jar: sessions never enter shared storage or backups. @@ -127,19 +170,23 @@ final class Transport: NSObject, URLSessionTaskDelegate, @unchecked Sendable { func close() { session.invalidateAndCancel() } } -struct TransferSample: Identifiable { - let id = UUID() +struct TransferSample: Identifiable, Codable { + var id = UUID() let date: Date let download: Int64 let upload: Int64 let segment: Int } -struct TransferHistory { - static let retention: TimeInterval = 15 * 60 - static let maximumSamples = 600 +struct TransferHistory: Codable { + static let retention: TimeInterval = 24 * 60 * 60 + static let maximumSamples = 86_401 private(set) var samples: [TransferSample] = [] private var segment = 0 + mutating func prune(at now: Date = Date()) { + samples.removeAll { now.timeIntervalSince($0.date) > Self.retention || $0.date.timeIntervalSince(now) > 60 } + segment = samples.last?.segment ?? 0 + } func samples(from start: Date, through end: Date) -> [TransferSample] { let lower = start.timeIntervalSinceReferenceDate let upper = end.timeIntervalSinceReferenceDate @@ -158,11 +205,33 @@ struct TransferHistory { if date.timeIntervalSince(last.date) < 1 { samples.removeLast() } } samples.append(TransferSample(date: date, download: max(0, download), upload: max(0, upload), segment: segment)) - samples.removeAll { $0.date < date.addingTimeInterval(-Self.retention) } + while let first = samples.first, date.timeIntervalSince(first.date) > Self.retention { samples.removeFirst() } if samples.count > Self.maximumSamples { samples.removeFirst(samples.count - Self.maximumSamples) } } } +final class CompletionNotificationPresenter: NSObject, UNUserNotificationCenterDelegate { + func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification) async -> UNNotificationPresentationOptions { + [.banner, .sound, .list] + } +} + +struct HistoryStore { + struct Archive: Codable { var server: String; var user: String; var history: TransferHistory } + var url: URL = URL.applicationSupportDirectory.appendingPathComponent("Tarnax/transfer-history.json") + func load(for credentials: Credentials, at now: Date = Date()) throws -> TransferHistory { + guard FileManager.default.fileExists(atPath: url.path) else { return TransferHistory() } + let archive = try JSONDecoder().decode(Archive.self, from: Data(contentsOf: url)) + guard archive.server == credentials.server, archive.user == credentials.username else { return TransferHistory() } + var history = archive.history; history.prune(at: now); return history + } + func save(_ history: TransferHistory, for credentials: Credentials) throws { + try FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true) + try JSONEncoder().encode(Archive(server: credentials.server, user: credentials.username, history: history)).write(to: url, options: .atomic) + } + func clear() throws { if FileManager.default.fileExists(atPath: url.path) { try FileManager.default.removeItem(at: url) } } +} + @MainActor final class AppModel: ObservableObject { @Published var credentials = Vault.load() @Published var connected = false @@ -194,11 +263,47 @@ struct TransferHistory { private var preferencesTask: Task? private var refreshing = false var client: Transport? + var historyStore = HistoryStore() + @Published var historyError: String? + @Published var actionFeedback: String? + @Published private(set) var pendingActions: Set = [] + private let notificationPresenter = CompletionNotificationPresenter() + @Published var completionNotifications = UserDefaults.standard.bool(forKey: "completionNotifications") + private var observedCompletion: [String: Bool] = [:] + init() { + #if os(iOS) + UNUserNotificationCenter.current().delegate = notificationPresenter + #endif + do { transferHistory = try historyStore.load(for: credentials) } + catch { historyError = "Could not restore transfer history: \(error.localizedDescription)" } + } + func setCompletionNotifications(_ enabled: Bool) async { + #if canImport(UserNotifications) + do { + completionNotifications = enabled ? try await UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound, .badge]) : false + UserDefaults.standard.set(completionNotifications, forKey: "completionNotifications") + if enabled && !completionNotifications { error = "Notifications are disabled. Allow notifications for Tarnax in iOS Settings." } + } catch { self.error = error.localizedDescription } + #endif + } + private func observeCompletions(_ next: [Torrent]) async { + for torrent in next { + if completionNotifications, observedCompletion[torrent.hash] == false, torrent.complete { + #if canImport(UserNotifications) + let content = UNMutableNotificationContent() + content.title = "Download complete"; content.body = torrent.name; content.sound = .default + do { try await UNUserNotificationCenter.current().add(UNNotificationRequest(identifier: "complete-" + torrent.hash, content: content, trigger: nil)) } + catch { self.error = "Could not deliver completion notification: \(error.localizedDescription)" } + #endif + } + } + observedCompletion = Dictionary(uniqueKeysWithValues: next.map { ($0.hash, $0.complete) }) + } // Only cancellation signals and work belonging to a retired session are silent. func shouldReport(_ error: Error, from source: Transport) -> Bool { guard client === source else { return false } - return !(error is CancellationError) && (error as? URLError)?.code != .cancelled + return !(error is CancellationError) && !(Task.isCancelled && (error as? URLError)?.code == .cancelled) } func report(_ error: Error, from source: Transport) { if shouldReport(error, from: source) { self.error = error.localizedDescription } @@ -256,7 +361,9 @@ struct TransferHistory { guard connectionAttempt == attempt else { return } try Vault.save(connectionCredentials) clearPreferences() - transferHistory = TransferHistory() + do { transferHistory = try historyStore.load(for: connectionCredentials) } + catch { historyError = "Could not restore transfer history: \(error.localizedDescription)" } + observedCompletion = [:] client?.close(); client = next; installed = true version = String(decoding: v, as: UTF8.self); apiVersion = String(decoding: a, as: UTF8.self) connected = true; await refresh() @@ -271,6 +378,8 @@ struct TransferHistory { let (nextTorrents, nextInfo) = try await (list, info) try Task.checkCancellation() guard self.client === client else { return } + await observeCompletions(nextTorrents) + guard self.client === client else { return } torrents = nextTorrents stats = nextInfo["server_state"] as? [String: Any] ?? [:] let refreshedAt = Date() @@ -278,17 +387,31 @@ struct TransferHistory { let upload = stats["up_info_speed"] as? NSNumber { transferHistory.record(download: download.int64Value, upload: upload.int64Value, at: refreshedAt) } + do { try historyStore.save(transferHistory, for: client.credentials); historyError = nil } + catch { historyError = "Could not save transfer history: \(error.localizedDescription)" } lastRefresh = refreshedAt } catch { report(error, from: client) } } - func perform(_ endpoint: String, _ fields: [String: String]) async { - guard let client else { return } - do { _ = try await client.request(endpoint, fields); await refresh() } catch { report(error, from: client) } + @discardableResult func perform(_ endpoint: String, _ fields: [String: String]) async -> Bool { + guard let client, connected, !locked else { return false } + let key = fields["hashes"] ?? fields["hash"] ?? endpoint + guard !pendingActions.contains(key) else { return false } + pendingActions.insert(key); actionFeedback = nil + defer { pendingActions.remove(key) } + do { + _ = try await client.request(endpoint, fields) + guard self.client === client else { return false } + actionFeedback = "Action completed." + await refresh(); return true + } catch { + if shouldReport(error, from: client) { actionFeedback = "Action failed: " + error.localizedDescription } + report(error, from: client); return false + } } - func action(_ action: String, hash: String) async { + @discardableResult func action(_ action: String, hash: String) async -> Bool { let modern = version.replacingOccurrences(of: "v", with: "").compare("5.0", options: .numeric) != .orderedAscending let endpoint = action == "pause" ? (modern ? "stop" : "pause") : action == "resume" ? (modern ? "start" : "resume") : action - await perform("torrents/" + endpoint, ["hashes": hash]) + return await perform("torrents/" + endpoint, ["hashes": hash]) } func updateScene(_ phase: ScenePhase) async { foreground = phase == .active @@ -362,6 +485,10 @@ struct TransferHistory { clearPreferences() transferHistory = TransferHistory() client?.close(); client = nil; connected = false; torrents = []; stats = [:] - if forget { Vault.clear(); credentials = Credentials() } + observedCompletion = [:] + if forget { + do { try historyStore.clear() } catch { self.error = "Could not remove transfer history: \(error.localizedDescription)" } + Vault.clear(); credentials = Credentials() + } } } diff --git a/Tarnax/Details.swift b/Tarnax/Details.swift index d24ab2e..46a3c23 100644 --- a/Tarnax/Details.swift +++ b/Tarnax/Details.swift @@ -10,6 +10,7 @@ struct DetailView: View { @State private var peers: [String: Any] = [:] @State private var tab = "Overview" @State private var deleting = false + @State private var trackerToRemove: String? @State private var editor: EditAction? @State private var value = "" @State private var secondValue = "" @@ -20,13 +21,12 @@ struct DetailView: View { List { Section { TorrentRow(torrent: live) } Section { - (dynamicTypeSize.isAccessibilitySize ? AnyLayout(VStackLayout(alignment: .leading)) : AnyLayout(HStackLayout())) { - Button("Resume", systemImage: "play.fill") { Task { await model.action("resume", hash: torrent.hash) } }.frame(minHeight: 44) - if !dynamicTypeSize.isAccessibilitySize { Spacer() } - Button("Pause", systemImage: "pause.fill") { Task { await model.action("pause", hash: torrent.hash) } }.frame(minHeight: 44) - if !dynamicTypeSize.isAccessibilitySize { Spacer() } - Menu("More", systemImage: "ellipsis.circle") { controls }.frame(minWidth: 44, minHeight: 44) + ViewThatFits(in: .horizontal) { + HStack { primaryControls } + VStack(alignment: .leading) { primaryControls } }.buttonStyle(.bordered).controlSize(.regular) + if model.pendingActions.contains(torrent.hash) { ProgressView("Applying action…") } + if let feedback = model.actionFeedback { Text(feedback).font(.callout) } } Picker("Details", selection: $tab) { ForEach(["Overview", "Files", "Peers", "Trackers"], id: \.self) { Text($0) } }.pickerStyle(.menu) if let localError { Section { Text(localError).foregroundStyle(.red); Button("Retry") { Task { await load() } } } } @@ -49,6 +49,12 @@ struct DetailView: View { Button("Remove torrent only", role: .destructive) { Task { await remove(false) } } Button("Delete torrent and server files", role: .destructive) { Task { await remove(true) } } } message: { Text("Deleting server files cannot be undone.") } + .confirmationDialog("Remove this tracker?", isPresented: Binding(get: { trackerToRemove != nil }, set: { if !$0 { trackerToRemove = nil } }), titleVisibility: .visible) { + Button("Remove tracker", role: .destructive) { + if let url = trackerToRemove { Task { await model.perform("torrents/removeTrackers", ["hash": torrent.hash, "urls": url]); await load() } } + trackerToRemove = nil + } + } .sheet(item: $editor) { action in NavigationStack { Form { TextField(action.field, text: $value, axis: .vertical).textInputAutocapitalization(.never).autocorrectionDisabled() @@ -58,12 +64,20 @@ struct DetailView: View { var fields = [action.field: value] fields[action.singleHash ? "hash" : "hashes"] = torrent.hash if let second = action.second { fields[second] = secondValue } - await model.perform("torrents/" + action.endpoint, fields) - if model.error == nil { editor = nil; await load() } + let success = await model.perform("torrents/" + action.endpoint, fields) + if success { editor = nil; await load() } } } }.navigationTitle(action.title).toolbar { ToolbarItem(placement: .cancellationAction) { Button("Cancel") { editor = nil } } } } } } + @ViewBuilder var primaryControls: some View { + Button("Resume", systemImage: "play.fill") { Task { await model.action("resume", hash: torrent.hash) } } + .frame(minHeight: 44).disabled(!live.paused || model.pendingActions.contains(torrent.hash)) + Button("Pause", systemImage: "pause.fill") { Task { await model.action("pause", hash: torrent.hash) } } + .frame(minHeight: 44).disabled(live.paused || model.pendingActions.contains(torrent.hash)) + Menu("More", systemImage: "ellipsis.circle") { controls } + .frame(minHeight: 44).disabled(model.pendingActions.contains(torrent.hash) || !model.connected) + } var controls: some View { Group { Button("Force start") { Task { await model.perform("torrents/setForceStart", ["hashes": torrent.hash, "value": "true"]) } } @@ -74,8 +88,8 @@ struct DetailView: View { ForEach(["topPrio", "increasePrio", "decreasePrio", "bottomPrio"], id: \.self) { endpoint in Button(["topPrio":"Move to top", "increasePrio":"Move up", "decreasePrio":"Move down", "bottomPrio":"Move to bottom"][endpoint]!) { Task { await model.action(endpoint, hash: torrent.hash) } } } } Menu("Edit") { ForEach(EditAction.management) { action in Button(action.title) { value = ""; secondValue = ""; editor = action } } } - Button("Toggle sequential download") { Task { await model.action("toggleSequentialDownload", hash: torrent.hash) } } - Button("Toggle first / last piece priority") { Task { await model.action("toggleFirstLastPiecePrio", hash: torrent.hash) } } + Button("Toggle sequential download") { Task { await model.action("toggleSequentialDownload", hash: torrent.hash) } }.disabled(live.complete) + Button("Toggle first / last piece priority") { Task { await model.action("toggleFirstLastPiecePrio", hash: torrent.hash) } }.disabled(live.complete) Button("Delete…", role: .destructive) { deleting = true } } } @@ -84,7 +98,7 @@ struct DetailView: View { ForEach(files) { file in VStack(alignment: .leading, spacing: 6) { Text(file.name).font(.subheadline) - ProgressView(value: file.progress) + ProgressView(value: file.progress).tint(file.progress >= 1 ? .green : .indigo) HStack { Text(bytes(file.size)).font(.caption).foregroundStyle(.secondary) Spacer() @@ -117,7 +131,7 @@ struct DetailView: View { KeyValues(values: tracker) if let url = tracker["url"] as? String, url.contains("://") { Button("Edit tracker") { value = url; secondValue = ""; editor = EditAction(title: "Edit tracker", endpoint: "editTracker", field: "origUrl", second: "newUrl", singleHash: true) } - Button("Remove tracker", role: .destructive) { Task { await model.perform("torrents/removeTrackers", ["hash": torrent.hash, "urls": url]); await load() } } + Button("Remove tracker", role: .destructive) { trackerToRemove = url } } } } @@ -136,8 +150,8 @@ struct DetailView: View { } catch { if model.shouldReport(error, from: client) { localError = error.localizedDescription } } } func remove(_ files: Bool) async { - await model.perform("torrents/delete", ["hashes": torrent.hash, "deleteFiles": files ? "true" : "false"]) - if model.error == nil { dismiss() } + let success = await model.perform("torrents/delete", ["hashes": torrent.hash, "deleteFiles": files ? "true" : "false"]) + if success { dismiss() } } } struct EditAction: Identifiable { @@ -165,11 +179,11 @@ struct KeyValues: View { if dynamicTypeSize.isAccessibilitySize { VStack(alignment: .leading, spacing: 6) { Text(key.replacingOccurrences(of: "_", with: " ").capitalized) - Text(display(values[key]!)).foregroundStyle(.secondary).textSelection(.enabled) + Text(StatFormat.display(values[key]!, key: key)).foregroundStyle(.secondary).textSelection(.enabled) }.accessibilityElement(children: .combine) } else { LabeledContent(key.replacingOccurrences(of: "_", with: " ").capitalized) { - Text(display(values[key]!)).textSelection(.enabled).multilineTextAlignment(.trailing).font(.body.monospacedDigit()) + Text(StatFormat.display(values[key]!, key: key)).textSelection(.enabled).multilineTextAlignment(.trailing).font(.body.monospacedDigit()) } } } diff --git a/Tarnax/Settings.swift b/Tarnax/Settings.swift index 8173d38..85b2212 100644 --- a/Tarnax/Settings.swift +++ b/Tarnax/Settings.swift @@ -36,6 +36,8 @@ struct SettingsView: View { var body: some View { List { Section("On this device") { + Toggle("Download completion notifications", isOn: Binding(get: { model.completionNotifications }, set: { enabled in Task { await model.setCompletionNotifications(enabled) } })) + Text("Alerts are based on observed completions. While the app is suspended, completion is detected at the next foreground refresh.").font(.caption).foregroundStyle(.secondary) Toggle("Face ID / device authentication", isOn: Binding(get: { model.biometric }, set: { enabled in Task { await model.enableLock(enabled) } })) Text("Requires authentication when returning after the app enters the background. Device passcode is available as a fallback.").font(.caption).foregroundStyle(.secondary) } @@ -45,13 +47,13 @@ struct SettingsView: View { if !keys.isEmpty { NavigationLink { List(keys, id: \.self) { key in NavigationLink { PreferenceEditor(key: key, original: preferences[key]!) { await load() } } label: { - VStack(alignment: .leading) { Text(key.replacingOccurrences(of: "_", with: " ").capitalized); Text(key).font(.caption2).foregroundStyle(.secondary) } + VStack(alignment: .leading) { Text(key.replacingOccurrences(of: "_", with: " ").capitalized); Text(key).font(.caption2).foregroundStyle(.secondary); Text(StatFormat.display(preferences[key]!, key: key, preference: true)).font(.subheadline).foregroundStyle(.secondary) } } }.navigationTitle(group) } label: { LabeledContent(group, value: "\(keys.count)") } } } if loading { ProgressView("Loading server preferences…") } - } header: { Text("Server preferences") } footer: { Text("All preferences returned by this server are available. Values use the API’s native units (typically bytes/s, seconds or minutes). Unsupported or read-only changes are reported by the server or verified by re-reading. Password values may be masked by qBittorrent.") } + } header: { Text("Server preferences") } footer: { Text("All preferences returned by this server are available. Known durations, sizes and speeds are formatted for readability. Editors retain the exact API value and show its units. Unsupported or read-only changes are reported by the server or verified by re-reading. Password values may be masked by qBittorrent.") } Section("Connection") { Button("Reload preferences") { Task { await load() } } Button("Disconnect / change server") { model.disconnect() } @@ -103,8 +105,9 @@ struct PreferenceEditor: View { if isBool { Toggle("Enabled", isOn: $boolean) } else if sensitive { SecureField("New value", text: $text) } else { TextField(isComplex ? "JSON value" : "Value", text: $text, axis: .vertical).textInputAutocapitalization(.never).autocorrectionDisabled() } - Text(isBool ? "Boolean" : isNumber ? "Number · API units" : isComplex ? "JSON · preserve the returned structure" : "Text").font(.caption).foregroundStyle(.secondary) + Text(isBool ? "Boolean" : isNumber ? "Number · " + StatFormat.unit(key) : isComplex ? "JSON · preserve the returned structure" : "Text").font(.caption).foregroundStyle(.secondary) } + if isNumber { LabeledContent("Current value", value: StatFormat.display(original, key: key, preference: true)) } Section { Button(saving ? "Saving…" : "Save to server") { confirm = true }.disabled(saving) } footer: { Text("This edits qBittorrent on the server immediately. Web UI, authentication, network and path changes can interrupt access or move data. Review the exact API key and value before applying.") } @@ -144,7 +147,7 @@ struct PrivacyPolicyView: View { Text("Tarnax communicates directly with the HTTPS server you configure. Server addresses, sign-in credentials, torrent requests and management commands are sent to that server. Its operator may keep logs and has its own privacy practices.") } Section("On this device") { - Text("Connection credentials are stored in this device’s Keychain. App-lock preferences and navigation state are stored locally. Transfer history is kept in memory for the current session. Session cookies use a private, temporary network session.") + Text("Connection credentials are stored in this device’s Keychain. App-lock preferences and navigation state are stored locally. Timestamped transfer history is stored locally for a rolling 24 hours and removed when you forget credentials. Session cookies use a private, temporary network session.") } Section("No tracking") { Text("Tarnax includes no advertising, tracking SDKs or third-party analytics. It does not send your torrent library or credentials to a developer-operated service unless you choose such a server yourself.") diff --git a/Tests/ContractTests.swift b/Tests/ContractTests.swift index 2a769f5..98afa81 100644 --- a/Tests/ContractTests.swift +++ b/Tests/ContractTests.swift @@ -111,7 +111,7 @@ final class MockProtocol: URLProtocol, @unchecked Sendable { for failure in [URLError(.cancelled), URLError(.timedOut)] { MockProtocol.handler = { _ in throw failure } await model.loadPreferences() - check((model.error != nil) == (failure.code == .timedOut), "Only expected cancellation is silent: \(failure.code)") + check(model.error != nil, "Unexpected active-session cancellation and timeout remain visible: \(failure.code)") check(model.preferences["save_path"] as? String == "/downloads", "Failed reload retains preferences") } model.error = nil @@ -229,14 +229,16 @@ final class MockProtocol: URLProtocol, @unchecked Sendable { check(history.samples.count == 2, "Out-of-order speed samples cannot corrupt the timeline") history.record(download: 300, upload: 30, at: origin.addingTimeInterval(30)) check(history.samples.last?.segment == 1, "Background and network gaps start a new chart segment") - history.record(download: 400, upload: 40, at: origin.addingTimeInterval(1000)) - check(history.samples.count == 1, "History expires samples older than fifteen minutes") + history.record(download: 400, upload: 40, at: origin.addingTimeInterval(86_431)) + check(history.samples.count == 1, "History expires samples older than 24 hours") var dense = TransferHistory() - for second in 0...700 { dense.record(download: 1, upload: 2, at: origin.addingTimeInterval(Double(second))) } + for second in 0...86_500 { dense.record(download: 1, upload: 2, at: origin.addingTimeInterval(Double(second))) } check(dense.samples.count == TransferHistory.maximumSamples, "History storage remains bounded during frequent refreshes") check(model.transferHistory.samples.isEmpty, "Disconnected session has no previous server history") let historyTransport = try Transport(Credentials(server: "https://example.test"), configuration: config) let liveHistory = AppModel() + liveHistory.historyStore = HistoryStore(url: URL(fileURLWithPath: ProcessInfo.processInfo.environment["TARNAX_SCRATCH_ROOT"]!).appendingPathComponent("test-history.json")) + defer { try? liveHistory.historyStore.clear() } liveHistory.client = historyTransport; liveHistory.connected = true; liveHistory.locked = false MockProtocol.handler = { request in if request.url!.path.hasSuffix("torrents/info") { return (200, "[]") } @@ -253,6 +255,21 @@ final class MockProtocol: URLProtocol, @unchecked Sendable { for second in [0, 3, 6] { windowHistory.record(download: 1, upload: 2, at: origin.addingTimeInterval(Double(second))) } check(windowHistory.samples(from: origin.addingTimeInterval(3), through: origin.addingTimeInterval(6)).count == 2, "History window includes exact timestamp boundaries without new Date comparison entry points") check(windowHistory.samples(from: origin.addingTimeInterval(7), through: origin.addingTimeInterval(9)).isEmpty, "History window excludes samples outside its numeric time range") + let temp = URL(fileURLWithPath: ProcessInfo.processInfo.environment["TARNAX_SCRATCH_ROOT"]!).appendingPathComponent(UUID().uuidString).appendingPathComponent("history.json") + let store = HistoryStore(url: temp) + defer { try? FileManager.default.removeItem(at: temp.deletingLastPathComponent()) } + let scope = Credentials(server: "https://history.test", username: "alice") + try store.save(windowHistory, for: scope) + let restored = try store.load(for: scope, at: origin.addingTimeInterval(6)) + check(restored.samples.count == 3 && restored.samples.first?.download == 1, "History round trips timestamps and speeds across store instances") + check(try store.load(for: Credentials(server: "https://other.test"), at: origin).samples.isEmpty, "History never crosses server identities") + check(try store.load(for: scope, at: origin.addingTimeInterval(90_000)).samples.isEmpty, "Restoration prunes expired history") + try store.clear() + check(!FileManager.default.fileExists(atPath: temp.path), "Forgetting removes persistent history") + check(duration(45) == "45 sec" && duration(90) == "1.5 min" && duration(7200) == "2 hr", "Duration formatting retains seconds and scales to minutes and hours") + check(StatFormat.display(120, key: "max_seeding_time", preference: true) == "2 hr", "Seeding preferences convert API minutes correctly") + check(StatFormat.display(42, key: "total_peers") == "42", "Unit metadata leaves counters untouched") + check(StatFormat.display(1024, key: "dl_info_speed").hasSuffix("/s"), "Speeds retain per-second units") another.close(); transport.close(); print("\(checks) contract checks passed.") } } diff --git a/appstore/website/.phpunit.result.cache b/appstore/website/.phpunit.result.cache index 4483557..891c6b9 100644 --- a/appstore/website/.phpunit.result.cache +++ b/appstore/website/.phpunit.result.cache @@ -1 +1 @@ -{"version":2,"defects":{"Tests\\Feature\\CommerceTest::test_registration_cannot_grant_administration":7,"Tests\\Feature\\CommerceTest::test_checkout_uses_server_selected_product_and_customer":7,"Tests\\Feature\\CommerceTest::test_unconfigured_checkout_fails_closed":7,"Tests\\Feature\\CommerceTest::test_consent_records_explicit_choices":7,"Tests\\Feature\\CommerceTest::test_apple_verification_fails_closed_without_root":7},"times":{"Tests\\Unit\\ExampleTest::test_that_true_is_true":0.001,"Tests\\Feature\\CommerceTest::test_public_pages_and_authentication_views_render":0.021,"Tests\\Feature\\CommerceTest::test_registration_cannot_grant_administration":0.012,"Tests\\Feature\\CommerceTest::test_verified_payment_issues_only_one_license_on_retries":0.009,"Tests\\Feature\\CommerceTest::test_invalid_signature_and_stale_replay_never_grant_licenses":0.002,"Tests\\Feature\\CommerceTest::test_wrong_product_does_not_grant_license":0.001,"Tests\\Feature\\CommerceTest::test_unknown_customer_rolls_back_event_for_retry":0.002,"Tests\\Feature\\CommerceTest::test_activation_limits_validation_and_deactivation":0.005,"Tests\\Feature\\CommerceTest::test_refund_revokes_license_and_prevents_regrant":0.003,"Tests\\Feature\\CommerceTest::test_checkout_uses_server_selected_product_and_customer":0.017,"Tests\\Feature\\CommerceTest::test_unconfigured_checkout_fails_closed":0.003,"Tests\\Feature\\CommerceTest::test_downloads_require_license_signature_and_matching_customer":0.01,"Tests\\Feature\\CommerceTest::test_support_is_scoped_and_does_not_accept_another_ticket":0.007,"Tests\\Feature\\CommerceTest::test_customer_can_create_support_ticket":0.005,"Tests\\Feature\\CommerceTest::test_admin_requires_role_and_confirmed_two_factor":0.004,"Tests\\Feature\\CommerceTest::test_admin_can_edit_cms_and_mutations_are_audited":0.004,"Tests\\Feature\\CommerceTest::test_consent_records_explicit_choices":0.001,"Tests\\Feature\\CommerceTest::test_apple_verification_fails_closed_without_root":0.002,"Tests\\Feature\\CommerceTest::test_account_support_and_security_render_for_verified_customer":0.004,"Tests\\Feature\\CommerceTest::test_erasure_revokes_access_and_anonymises_profile":0.006,"Tests\\Feature\\ExampleTest::test_the_application_returns_a_successful_response":0.001}} \ No newline at end of file +{"version":2,"defects":{"Tests\\Feature\\CommerceTest::test_registration_cannot_grant_administration":7,"Tests\\Feature\\CommerceTest::test_checkout_uses_server_selected_product_and_customer":7,"Tests\\Feature\\CommerceTest::test_unconfigured_checkout_fails_closed":7,"Tests\\Feature\\CommerceTest::test_consent_records_explicit_choices":7,"Tests\\Feature\\CommerceTest::test_apple_verification_fails_closed_without_root":7,"Tests\\Feature\\CommerceTest::test_unverified_accounts_cannot_checkout_or_open_portal":7},"times":{"Tests\\Unit\\ExampleTest::test_that_true_is_true":0,"Tests\\Feature\\CommerceTest::test_public_pages_and_authentication_views_render":0.034,"Tests\\Feature\\CommerceTest::test_registration_cannot_grant_administration":0.015,"Tests\\Feature\\CommerceTest::test_verified_payment_issues_only_one_license_on_retries":0.014,"Tests\\Feature\\CommerceTest::test_invalid_signature_and_stale_replay_never_grant_licenses":0.006,"Tests\\Feature\\CommerceTest::test_wrong_product_does_not_grant_license":0.004,"Tests\\Feature\\CommerceTest::test_unknown_customer_rolls_back_event_for_retry":0.005,"Tests\\Feature\\CommerceTest::test_activation_limits_validation_and_deactivation":0.015,"Tests\\Feature\\CommerceTest::test_refund_revokes_license_and_prevents_regrant":0.012,"Tests\\Feature\\CommerceTest::test_checkout_uses_server_selected_product_and_customer":0.014,"Tests\\Feature\\CommerceTest::test_unconfigured_checkout_fails_closed":0.004,"Tests\\Feature\\CommerceTest::test_downloads_require_license_signature_and_matching_customer":0.021,"Tests\\Feature\\CommerceTest::test_support_is_scoped_and_does_not_accept_another_ticket":0.012,"Tests\\Feature\\CommerceTest::test_customer_can_create_support_ticket":0.012,"Tests\\Feature\\CommerceTest::test_admin_requires_role_and_confirmed_two_factor":0.009,"Tests\\Feature\\CommerceTest::test_admin_can_edit_cms_and_mutations_are_audited":0.011,"Tests\\Feature\\CommerceTest::test_consent_records_explicit_choices":0.003,"Tests\\Feature\\CommerceTest::test_apple_verification_fails_closed_without_root":0.005,"Tests\\Feature\\CommerceTest::test_account_support_and_security_render_for_verified_customer":0.01,"Tests\\Feature\\CommerceTest::test_erasure_revokes_access_and_anonymises_profile":0.015,"Tests\\Feature\\ExampleTest::test_the_application_returns_a_successful_response":0.008,"Tests\\Feature\\CommerceTest::test_refund_before_purchase_prevents_late_grant":0.006,"Tests\\Feature\\CommerceTest::test_subscription_events_revoke_and_restore_and_ignore_stale_events":0.013,"Tests\\Feature\\CommerceTest::test_admin_can_manage_navigation_and_reject_external_redirects":0.026,"Tests\\Feature\\CommerceTest::test_published_page_html_is_escaped":0.004,"Tests\\Feature\\CommerceTest::test_apple_notification_fails_closed_without_configuration":0.003,"Tests\\Feature\\CommerceTest::test_erased_customer_cannot_use_an_old_activation_token":0.02,"Tests\\Feature\\CommerceTest::test_unverified_accounts_cannot_checkout_or_open_portal":0.007,"Tests\\Feature\\CommerceTest::test_private_media_is_safely_served":0.003}} \ No newline at end of file diff --git a/appstore/website/app/Http/Controllers/AppleNotificationController.php b/appstore/website/app/Http/Controllers/AppleNotificationController.php index ab0cb9e..e14ec24 100644 --- a/appstore/website/app/Http/Controllers/AppleNotificationController.php +++ b/appstore/website/app/Http/Controllers/AppleNotificationController.php @@ -2,9 +2,32 @@ namespace App\Http\Controllers; +use App\Services\AppleVerifier; +use Carbon\Carbon; use Illuminate\Http\Request; +use Illuminate\Support\Facades\DB; class AppleNotificationController extends Controller { - // + public function __invoke(Request $r, AppleVerifier $verifier) + { + $v = $r->validate(['signedPayload' => 'required|string|max:60000']); + $event = $verifier->envelope($v['signedPayload']); + abort_unless(isset($event['notificationUUID'],$event['notificationType']) && ($event['data']['bundleId'] ?? '') === config('tarnax.apple.bundle_id') && ($event['data']['environment'] ?? '') === config('tarnax.apple.environment'), 422); + DB::transaction(function () use ($event, $verifier) { + if (! DB::table('webhook_events')->insertOrIgnore(['id' => 'apple:'.$event['notificationUUID'], 'type' => $event['notificationType'], 'processed_at' => now()])) { + return; + } + if (empty($event['data']['signedTransactionInfo'])) { + return; + } + $claims = $verifier->transaction($event['data']['signedTransactionInfo']); + $status = isset($claims['revocationDate']) ? 'revoked' : ((($claims['expiresDate'] ?? PHP_INT_MAX) < time() * 1000) ? 'expired' : 'verified'); + DB::table('purchases')->where('provider', 'apple')->where('transaction_id', 'apple:'.$claims['transactionId'])->where(function ($q) use ($claims) { + $q->whereNull('provider_updated_at')->orWhere('provider_updated_at', '<', Carbon::createFromTimestampMs($claims['signedDate'])); + })->update(['status' => $status, 'provider_updated_at' => Carbon::createFromTimestampMs($claims['signedDate']), 'updated_at' => now()]); + }); + + return response()->json(['received' => true]); + } } diff --git a/appstore/website/app/Http/Controllers/CommerceController.php b/appstore/website/app/Http/Controllers/CommerceController.php index 412b8b3..5390761 100644 --- a/appstore/website/app/Http/Controllers/CommerceController.php +++ b/appstore/website/app/Http/Controllers/CommerceController.php @@ -1,45 +1,98 @@ DB::table('pages')->where('published',true)->where('slug','features')->first()]);} - public function page(string $slug) {return view('page',['page'=>DB::table('pages')->where('slug',$slug)->where('published',true)->firstOrFail()]);} - public function releases() {return view('releases',['releases'=>DB::table('releases')->where('published',true)->latest()->paginate(15)]);} - public function account(Request $r) { - if(!$r->user()->account_token){$r->user()->forceFill(['account_token'=>(string)Str::uuid()])->save();} - return view('account',['licenses'=>DB::table('licenses')->where('user_id',$r->user()->id)->get(),'purchases'=>DB::table('purchases')->where('user_id',$r->user()->id)->latest()->get(),'releases'=>DB::table('releases')->where('published',true)->latest()->get()]); - } - public function downloadLink(Request $r,int $release) { - abort_unless(DB::table('licenses')->where('user_id',$r->user()->id)->where('status','active')->exists(),403); - abort_unless(DB::table('releases')->where('id',$release)->where('published',true)->exists(),404); - return redirect(URL::temporarySignedRoute('download.file',now()->addMinutes(5),['release'=>$release,'user'=>$r->user()->id])); - } - public function download(Request $r,int $release,int $user) { - abort_unless($r->user()->id===$user && DB::table('licenses')->where('user_id',$user)->where('status','active')->exists(),403); - $file=DB::table('releases')->where('id',$release)->where('published',true)->firstOrFail();abort_unless(Storage::disk('local')->exists($file->path),404); - return Storage::disk('local')->download($file->path,'Tarnax-'.$file->version.'.'.pathinfo($file->path,PATHINFO_EXTENSION)); - } - public function consent(Request $r) { - $v=$r->validate(['analytics'=>'required|boolean','marketing'=>'required|boolean']);$visitor=$r->cookie('tarnax_visitor');if(!Str::isUuid($visitor??'')){$visitor=(string)Str::uuid();} - DB::table('consents')->insert([...$v,'visitor_id'=>$visitor,'user_id'=>$r->user()?->id,'policy_version'=>config('tarnax.consent_version'),'created_at'=>now(),'updated_at'=>now()]); - return response()->json(['saved'=>true])->cookie('tarnax_visitor',$visitor,525600,'/',null,$r->isSecure(),true,false,'lax')->cookie('tarnax_consent',json_encode([...$v,'version'=>config('tarnax.consent_version')]),525600,'/',null,$r->isSecure(),false,false,'lax'); - } - public function export(Request $r) { - $data=['profile'=>$r->user()->only('name','email','created_at'),'purchases'=>DB::table('purchases')->where('user_id',$r->user()->id)->get(),'support'=>DB::table('ticket_messages')->where('user_id',$r->user()->id)->get(),'consent'=>DB::table('consents')->where('user_id',$r->user()->id)->get(),'privacy_requests'=>DB::table('privacy_requests')->where('user_id',$r->user()->id)->get()]; - return response()->streamDownload(fn()=>print(json_encode($data,JSON_PRETTY_PRINT)),'tarnax-personal-data.json',['Content-Type'=>'application/json']); - } - public function deletion(Request $r) { - DB::table('privacy_requests')->updateOrInsert(['user_id'=>$r->user()->id,'type'=>'deletion','status'=>'pending'],['created_at'=>now(),'updated_at'=>now()]); - return back()->with('status','Deletion requested. Support will review retained billing records and respond within the applicable deadline.'); - } - public function apple(Request $r,AppleVerifier $verifier) { - $v=$r->validate(['signed_transaction'=>'required|string|max:25000']);$claims=$verifier->verify($v['signed_transaction'],$r->user()->account_token??''); - DB::transaction(function()use($r,$claims){$id='apple:'.$claims['transactionId'];$purchase=DB::table('purchases')->where('transaction_id',$id)->lockForUpdate()->first();abort_if($purchase && $purchase->user_id!==$r->user()->id,403);if(!$purchase){DB::table('purchases')->insert(['user_id'=>$r->user()->id,'provider'=>'apple','transaction_id'=>$id,'status'=>'verified','created_at'=>now(),'updated_at'=>now()]);}}); - return back()->with('status','Apple purchase verified. App Store entitlements remain separate from direct licenses.'); - } + +class CommerceController extends Controller +{ + public function home() + { + return view('home', ['features' => DB::table('pages')->where('published', true)->where('slug', 'features')->first()]); + } + + public function page(string $slug) + { + return view('page', ['page' => DB::table('pages')->where('slug', $slug)->where('published', true)->firstOrFail()]); + } + + public function releases() + { + return view('releases', ['releases' => DB::table('releases')->where('published', true)->latest()->paginate(15)]); + } + + public function account(Request $r) + { + if (! $r->user()->account_token) { + $r->user()->forceFill(['account_token' => (string) Str::uuid()])->save(); + } + + return view('account', ['licenses' => DB::table('licenses')->where('user_id', $r->user()->id)->get(), 'purchases' => DB::table('purchases')->where('user_id', $r->user()->id)->latest()->get(), 'releases' => DB::table('releases')->where('published', true)->latest()->get()]); + } + + public function downloadLink(Request $r, int $release) + { + abort_unless(DB::table('licenses')->where('user_id', $r->user()->id)->where('status', 'active')->exists(), 403); + abort_unless(DB::table('releases')->where('id', $release)->where('published', true)->exists(), 404); + + return redirect(URL::temporarySignedRoute('download.file', now()->addMinutes(5), ['release' => $release, 'user' => $r->user()->id])); + } + + public function download(Request $r, int $release, int $user) + { + abort_unless($r->user()->id === $user && DB::table('licenses')->where('user_id', $user)->where('status', 'active')->exists(), 403); + $file = DB::table('releases')->where('id', $release)->where('published', true)->firstOrFail(); + abort_unless(Storage::disk('local')->exists($file->path), 404); + + return Storage::disk('local')->download($file->path, 'Tarnax-'.$file->version.'.'.pathinfo($file->path, PATHINFO_EXTENSION)); + } + + public function consent(Request $r) + { + $v = $r->validate(['analytics' => 'required|boolean', 'marketing' => 'required|boolean']); + $visitor = $r->cookie('tarnax_visitor'); + if (! Str::isUuid($visitor ?? '')) { + $visitor = (string) Str::uuid(); + } + DB::table('consents')->insert([...$v, 'visitor_id' => $visitor, 'user_id' => $r->user()?->id, 'policy_version' => config('tarnax.consent_version'), 'created_at' => now(), 'updated_at' => now()]); + + return response()->json(['saved' => true])->cookie('tarnax_visitor', $visitor, 525600, '/', null, $r->isSecure(), true, false, 'lax')->cookie('tarnax_consent', json_encode([...$v, 'version' => config('tarnax.consent_version')]), 525600, '/', null, $r->isSecure(), false, false, 'lax'); + } + + public function export(Request $r) + { + $data = ['profile' => $r->user()->only('name', 'email', 'created_at'), 'purchases' => DB::table('purchases')->where('user_id', $r->user()->id)->get(), 'support' => DB::table('ticket_messages')->where('user_id', $r->user()->id)->get(), 'consent' => DB::table('consents')->where('user_id', $r->user()->id)->get(), 'privacy_requests' => DB::table('privacy_requests')->where('user_id', $r->user()->id)->get()]; + + return response()->streamDownload(fn () => print (json_encode($data, JSON_PRETTY_PRINT)), 'tarnax-personal-data.json', ['Content-Type' => 'application/json']); + } + + public function deletion(Request $r) + { + DB::table('privacy_requests')->updateOrInsert(['user_id' => $r->user()->id, 'type' => 'deletion', 'status' => 'pending'], ['created_at' => now(), 'updated_at' => now()]); + + return back()->with('status', 'Deletion requested. Support will review retained billing records and respond within the applicable deadline.'); + } + + public function apple(Request $r, AppleVerifier $verifier) + { + $v = $r->validate(['signed_transaction' => 'required|string|max:25000']); + $claims = $verifier->verify($v['signed_transaction'], $r->user()->account_token ?? ''); + DB::transaction(function () use ($r, $claims) { + $id = 'apple:'.$claims['transactionId']; + $purchase = DB::table('purchases')->where('transaction_id', $id)->lockForUpdate()->first(); + abort_if($purchase && $purchase->user_id !== $r->user()->id, 403); + abort_if($purchase && in_array($purchase->status, ['revoked', 'expired']), 403, 'Purchase is no longer valid.'); + if (! $purchase) { + DB::table('purchases')->insert(['user_id' => $r->user()->id, 'provider' => 'apple', 'transaction_id' => $id, 'status' => 'verified', 'provider_updated_at' => Carbon::createFromTimestampMs($claims['signedDate']), 'created_at' => now(), 'updated_at' => now()]); + } + }); + + return back()->with('status','Apple purchase verified. App Store entitlements remain separate from direct licenses.'); + } } diff --git a/appstore/website/app/Http/Controllers/LicenseController.php b/appstore/website/app/Http/Controllers/LicenseController.php index c281f5c..c3924cd 100644 --- a/appstore/website/app/Http/Controllers/LicenseController.php +++ b/appstore/website/app/Http/Controllers/LicenseController.php @@ -1,10 +1,27 @@ validate(['license_key'=>'required|string|max:100','device_id'=>'required|string|min:16|max:255']);return response()->json($service->activate($v['license_key'],$v['device_id']));} - public function validateLicense(Request $r,LicenseService $service): JsonResponse {return response()->json($service->check($r->bearerToken()??''));} - public function deactivate(Request $r,LicenseService $service): JsonResponse {return response()->json($service->check($r->bearerToken()??'',true));} +use Illuminate\Http\JsonResponse; +use Illuminate\Http\Request; + +class LicenseController extends Controller +{ + public function activate(Request $r, LicenseService $service): JsonResponse + { + $v = $r->validate(['license_key' => 'required|string|max:100', 'device_id' => 'required|string|min:16|max:255']); + + return response()->json($service->activate($v['license_key'], $v['device_id'])); + } + + public function validateLicense(Request $r, LicenseService $service): JsonResponse + { + return response()->json($service->check($r->bearerToken() ?? '')); + } + + public function deactivate(Request $r, LicenseService $service): JsonResponse + { + return response()->json($service->check($r->bearerToken() ?? '', true)); + } } diff --git a/appstore/website/app/Http/Controllers/PaddleController.php b/appstore/website/app/Http/Controllers/PaddleController.php index 4810acd..4dcd35b 100644 --- a/appstore/website/app/Http/Controllers/PaddleController.php +++ b/appstore/website/app/Http/Controllers/PaddleController.php @@ -1,52 +1,118 @@ user();if(!$user->account_token){$user->forceFill(['account_token'=>(string)Str::uuid()])->save();} - $base=$config['environment']==='production'?'https://api.paddle.com':'https://sandbox-api.paddle.com'; - $response=Http::withToken($config['api_key'])->timeout(15)->post($base.'/transactions',['items'=>[['price_id'=>$config['price_id'],'quantity'=>1]],'collection_mode'=>'automatic','custom_data'=>['account_token'=>$user->account_token]]); - abort_unless($response->successful(),502,'Payment provider unavailable.'); - return response()->json(['transaction_id'=>$response->json('data.id')]); - } - public function webhook(Request $r,LicenseService $licenses): JsonResponse { - $secret=config('tarnax.paddle.webhook_secret');abort_unless($secret,503); - $parts=[];foreach(explode(';',$r->header('Paddle-Signature','')) as $part){$pair=explode('=',trim($part),2);if(count($pair)===2){$parts[$pair[0]][]=$pair[1];}} - $ts=$parts['ts'][0]??'';abort_unless(ctype_digit($ts) && abs(time()-(int)$ts)<=300,401); - $expected=hash_hmac('sha256',$ts.':'.$r->getContent(),$secret);$valid=false;foreach($parts['h1']??[] as $signature){$valid=$valid||hash_equals($expected,$signature);}abort_unless($valid,401); - $event=$r->validate(['event_id'=>'required|string|max:100','event_type'=>'required|string|max:100','occurred_at'=>'required|date','data'=>'required|array']); - DB::transaction(function()use($event,$licenses){ - $inserted=DB::table('webhook_events')->insertOrIgnore(['id'=>$event['event_id'],'type'=>$event['event_type'],'processed_at'=>now()]);if(!$inserted){return;} - $d=$event['data'];$type=$event['event_type'];$at=Carbon::parse($event['occurred_at']); - if($type==='transaction.completed'){ - abort_unless(($d['status']??'')==='completed',422); - $matching=collect($d['items']??[])->contains(fn($i)=>($i['price']['id']??null)===config('tarnax.paddle.price_id') && ($i['quantity']??0)>=1);if(!$matching){return;} - $user=User::where('account_token',$d['custom_data']['account_token']??'')->first();abort_unless($user,422,'Unknown customer.'); - $purchase=DB::table('purchases')->where('transaction_id',$d['id'])->lockForUpdate()->first(); - if($purchase && ($purchase->status==='refunded' || Carbon::parse($purchase->provider_updated_at)->gte($at))){return;} - $id=$purchase?->id??DB::table('purchases')->insertGetId(['user_id'=>$user->id,'provider'=>'paddle','transaction_id'=>$d['id'],'subscription_id'=>$d['subscription_id']??null,'status'=>'completed','total'=>(int)($d['details']['totals']['grand_total']??0),'currency'=>$d['currency_code']??'USD','provider_updated_at'=>$at,'created_at'=>now(),'updated_at'=>now()]); - $licenses->issue($user->id,$id); - $user->notify(new AccountUpdate('Your Tarnax license is ready','Your purchase has been confirmed. Your license and downloads are in your account.',url('/account'))); - } - if($type==='adjustment.updated' || $type==='adjustment.created'){ - if(($d['status']??'')!=='approved' || !in_array($d['action']??'',['refund','chargeback','chargeback_warning'])){return;} - $purchase=DB::table('purchases')->where('transaction_id',$d['transaction_id']??'')->lockForUpdate()->first(); - if($purchase){DB::table('purchases')->where('id',$purchase->id)->update(['status'=>'refunded','provider_updated_at'=>$at,'updated_at'=>now()]);DB::table('licenses')->where('purchase_id',$purchase->id)->update(['status'=>'revoked','updated_at'=>now()]);} - } - if(in_array($type,['subscription.canceled','subscription.paused','subscription.past_due'])){ - $purchases=DB::table('purchases')->where('subscription_id',$d['id']??'')->where('provider_updated_at','<',$at)->get(); - foreach($purchases as $purchase){DB::table('licenses')->where('purchase_id',$purchase->id)->update(['status'=>'revoked','updated_at'=>now()]);DB::table('purchases')->where('id',$purchase->id)->update(['status'=>$d['status'],'provider_updated_at'=>$at]);} - } - },3); - return response()->json(['received'=>true]); - } + +class PaddleController extends Controller +{ + public function checkout(Request $r): JsonResponse + { + $config = config('tarnax.paddle'); + abort_unless($config['api_key'] && $config['price_id'] && $config['client_token'], 503, 'Checkout is awaiting merchant configuration.'); + $user = $r->user(); + if (! $user->account_token) { + $user->forceFill(['account_token' => (string) Str::uuid()])->save(); + } + $base = $config['environment'] === 'production' ? 'https://api.paddle.com' : 'https://sandbox-api.paddle.com'; + $response = Http::withToken($config['api_key'])->timeout(15)->post($base.'/transactions', ['items' => [['price_id' => $config['price_id'], 'quantity' => 1]], 'collection_mode' => 'automatic', 'custom_data' => ['account_token' => $user->account_token]]); + abort_unless($response->successful(), 502, 'Payment provider unavailable.'); + + return response()->json(['transaction_id' => $response->json('data.id')]); + } + + public function webhook(Request $r, LicenseService $licenses): JsonResponse + { + $secret = config('tarnax.paddle.webhook_secret'); + abort_unless($secret, 503); + $parts = []; + foreach (explode(';', $r->header('Paddle-Signature', '')) as $part) { + $pair = explode('=', trim($part), 2); + if (count($pair) === 2) { + $parts[$pair[0]][] = $pair[1]; + } + } + $ts = $parts['ts'][0] ?? ''; + abort_unless(ctype_digit($ts) && abs(time() - (int) $ts) <= 300, 401); + $expected = hash_hmac('sha256', $ts.':'.$r->getContent(), $secret); + $valid = false; + foreach ($parts['h1'] ?? [] as $signature) { + $valid = $valid || hash_equals($expected, $signature); + }abort_unless($valid, 401); + $event = $r->validate(['event_id' => 'required|string|max:100', 'event_type' => 'required|string|max:100', 'occurred_at' => 'required|date', 'data' => 'required|array']); + DB::transaction(function () use ($event, $licenses) { + $inserted = DB::table('webhook_events')->insertOrIgnore(['id' => $event['event_id'], 'type' => $event['event_type'], 'processed_at' => now()]); + if (! $inserted) { + return; + } + $d = $event['data']; + $type = $event['event_type']; + $at = Carbon::parse($event['occurred_at']); + if ($type === 'transaction.completed') { + abort_unless(($d['status'] ?? '') === 'completed' && ! empty($d['id']), 422); + DB::table('payment_locks')->insertOrIgnore(['id' => $d['id']]); + DB::table('payment_locks')->where('id', $d['id'])->lockForUpdate()->first(); + abort_unless(config('tarnax.paddle.price_id'), 503); + $matching = collect($d['items'] ?? [])->contains(fn ($i) => ($i['price']['id'] ?? null) === config('tarnax.paddle.price_id') && ($i['quantity'] ?? 0) >= 1); + if (! $matching) { + return; + } + if (DB::table('refund_blocks')->where('transaction_id', $d['id'])->exists()) { + return; + } + $user = User::where('account_token', $d['custom_data']['account_token'] ?? '')->first(); + abort_unless($user, 422, 'Unknown customer.'); + $purchase = DB::table('purchases')->where('transaction_id', $d['id'])->lockForUpdate()->first(); + if ($purchase && ($purchase->status === 'refunded' || Carbon::parse($purchase->provider_updated_at)->gte($at))) { + return; + } + $id = $purchase?->id ?? DB::table('purchases')->insertGetId(['user_id' => $user->id, 'provider' => 'paddle', 'transaction_id' => $d['id'], 'subscription_id' => $d['subscription_id'] ?? null, 'status' => 'completed', 'total' => (int) ($d['details']['totals']['grand_total'] ?? 0), 'currency' => $d['currency_code'] ?? 'USD', 'provider_updated_at' => $at, 'created_at' => now(), 'updated_at' => now()]); + $licenses->issue($user->id, $id); + if (! empty($d['subscription_id'])) { + $state = DB::table('subscription_states')->where('id', $d['subscription_id'])->first(); + if ($state && ! in_array($state->status, ['active', 'trialing'])) { + DB::table('licenses')->where('purchase_id', $id)->update(['status' => 'revoked']); + } + } + $user->notify(new AccountUpdate('Your Tarnax license is ready', 'Your purchase has been confirmed. Your license and downloads are in your account.', url('/account'), 'purchase-confirmed')); + } + if ($type === 'adjustment.updated' || $type === 'adjustment.created') { + if (($d['status'] ?? '') !== 'approved' || ! in_array($d['action'] ?? '', ['refund', 'chargeback', 'chargeback_warning'])) { + return; + } + abort_unless(! empty($d['transaction_id']), 422); + DB::table('payment_locks')->insertOrIgnore(['id' => $d['transaction_id']]); + DB::table('payment_locks')->where('id', $d['transaction_id'])->lockForUpdate()->first(); + DB::table('refund_blocks')->updateOrInsert(['transaction_id' => $d['transaction_id']], ['occurred_at' => $at]); + $purchase = DB::table('purchases')->where('transaction_id', $d['transaction_id'])->lockForUpdate()->first(); + if ($purchase) { + DB::table('purchases')->where('id', $purchase->id)->update(['status' => 'refunded', 'provider_updated_at' => $at, 'updated_at' => now()]); + DB::table('licenses')->where('purchase_id', $purchase->id)->update(['status' => 'revoked', 'updated_at' => now()]); + } + } + if (str_starts_with($type, 'subscription.') && ! empty($d['id']) && in_array($d['status'] ?? '', ['active', 'trialing', 'canceled', 'paused', 'past_due'])) { + DB::table('subscription_states')->insertOrIgnore(['id' => $d['id'], 'status' => $d['status'], 'occurred_at' => $at]); + $state = DB::table('subscription_states')->where('id', $d['id'])->lockForUpdate()->first(); + if (Carbon::parse($state->occurred_at)->gt($at)) { + return; + } + DB::table('subscription_states')->where('id', $d['id'])->update(['status' => $d['status'], 'occurred_at' => $at]); + $purchases = DB::table('purchases')->where('subscription_id', $d['id'])->where('status', '!=', 'refunded')->lockForUpdate()->get(); + foreach ($purchases as $purchase) { + DB::table('licenses')->where('purchase_id', $purchase->id)->update(['status' => in_array($d['status'], ['active', 'trialing']) ? 'active' : 'revoked', 'updated_at' => now()]); + DB::table('purchases')->where('id', $purchase->id)->update(['status' => $d['status'], 'provider_updated_at' => $at, 'updated_at' => now()]); + } + } + }, 3); + + return response()->json(['received' => true]); + } } diff --git a/appstore/website/app/Http/Middleware/RequireAdmin.php b/appstore/website/app/Http/Middleware/RequireAdmin.php index e042411..3253f90 100644 --- a/appstore/website/app/Http/Middleware/RequireAdmin.php +++ b/appstore/website/app/Http/Middleware/RequireAdmin.php @@ -1,12 +1,20 @@ user()?->is_admin,403); - if(!$request->user()->two_factor_confirmed_at){return redirect('/account/security')->with('status','Enable and confirm two-factor authentication before entering administration.');} - return $next($request); - } + +class RequireAdmin +{ + public function handle(Request $request, Closure $next): Response + { + abort_unless($request->user()?->is_admin, 403); + if (! $request->user()->two_factor_confirmed_at) { + return redirect('/account/security')->with('status', 'Enable and confirm two-factor authentication before entering administration.'); + } + + return $next($request); + } } diff --git a/appstore/website/app/Livewire/Admin/Cms.php b/appstore/website/app/Livewire/Admin/Cms.php index 5a2c0af..ce818ca 100644 --- a/appstore/website/app/Livewire/Admin/Cms.php +++ b/appstore/website/app/Livewire/Admin/Cms.php @@ -1,53 +1,186 @@ user()?->is_admin && auth()->user()->two_factor_confirmed_at,403);} - private function section(): string {abort_unless(in_array($this->section,self::TABLES),404);return $this->section;} - public function newRecord(): void {$this->reset('form','editing','upload');} - public function updatedSection(): void {$this->reset('form','editing','upload');} - private function audit(string $action,string $resource): void {DB::table('audit_logs')->insert(['user_id'=>auth()->id(),'action'=>$action,'resource'=>$resource,'created_at'=>now(),'updated_at'=>now()]);} - public function edit(int $id): void { - $section=$this->section();abort_unless(in_array($section,['pages','releases']),403); - $row=DB::table($section)->find($id);abort_unless($row,404);$this->editing=$id; - $this->form=array_intersect_key((array)$row,array_flip($section==='pages'?['slug','title','body','description','published']:['version','channel','platform','notes','published'])); - } - public function save(): void { - $section=$this->section();abort_unless(in_array($section,['pages','releases']),403); - if($section==='pages'){$data=$this->validate(['form.slug'=>'required|alpha_dash|max:100|unique:pages,slug,'.($this->editing??'NULL'),'form.title'=>'required|string|max:200','form.body'=>'required|string|max:100000','form.description'=>'nullable|string|max:255','form.published'=>'boolean'])['form'];} - else { - $data=$this->validate(['form.version'=>'required|regex:/^[0-9]+\.[0-9]+\.[0-9]+(?:-[a-zA-Z0-9.]+)?$/|max:50|unique:releases,version,'.($this->editing??'NULL'),'form.channel'=>'required|in:stable,beta','form.platform'=>'required|string|max:50','form.notes'=>'required|string|max:20000','form.published'=>'boolean'])['form']; - $this->validate(['upload'=>($this->editing?'nullable':'required').'|file|extensions:zip,dmg,pkg|max:1048576']); - if($this->upload){$path=$this->upload->store('releases','local');$data+=['path'=>$path,'sha256'=>hash_file('sha256',Storage::disk('local')->path($path)),'bytes'=>Storage::disk('local')->size($path)];} - } - $data['published']=$data['published']??false;$data['updated_at']=now(); - if($this->editing){DB::table($section)->where('id',$this->editing)->update($data);$id=$this->editing;}else{$data['created_at']=now();$id=DB::table($section)->insertGetId($data);} - $this->audit('saved',$section.':'.$id);$this->reset('form','editing','upload');session()->flash('status','Saved successfully.'); - } - public function revoke(int $id): void {abort_unless($this->section()==='licenses',403);DB::table('licenses')->where('id',$id)->update(['status'=>'revoked','updated_at'=>now()]);$this->audit('revoked','license:'.$id);} - public function saveSettings(): void { - abort_unless($this->section()==='settings',403);$v=$this->validate(['form.headline'=>'required|string|max:120','form.tagline'=>'required|string|max:500','form.price_label'=>'required|string|max:100'])['form'];foreach($v as $key=>$value){DB::table('settings')->updateOrInsert(['key'=>$key],['value'=>$value]);}$this->audit('saved','settings');session()->flash('status','Settings saved.'); - } - public function fulfillDeletion(int $id): void { - abort_unless($this->section()==='privacy_requests',403); - DB::transaction(function()use($id){$request=DB::table('privacy_requests')->where('id',$id)->lockForUpdate()->first();abort_unless($request && $request->type==='deletion' && $request->status==='pending',422);abort_if($request->user_id===auth()->id(),422,'Cannot erase your own administrator account.'); - $user=\App\Models\User::findOrFail($request->user_id);abort_if($user->is_admin,422,'Remove administrator access before erasure.'); - DB::table('licenses')->where('user_id',$user->id)->update(['status'=>'revoked','updated_at'=>now()]);DB::table('activations')->whereIn('license_id',DB::table('licenses')->where('user_id',$user->id)->select('id'))->delete(); - DB::table('tickets')->where('user_id',$user->id)->delete();DB::table('ticket_messages')->where('user_id',$user->id)->delete();DB::table('consents')->where('user_id',$user->id)->delete();DB::table('sessions')->where('user_id',$user->id)->delete();DB::table('password_reset_tokens')->where('email',$user->email)->delete(); - $user->forceFill(['name'=>'Deleted customer','email'=>'deleted-'.$user->id.'@invalid.example','password'=>bin2hex(random_bytes(32)),'remember_token'=>null,'account_token'=>null,'two_factor_secret'=>null,'two_factor_recovery_codes'=>null])->save(); - DB::table('privacy_requests')->where('id',$id)->update(['status'=>'fulfilled','updated_at'=>now()]);$this->audit('privacy erasure fulfilled; billing records retained','user:'.$user->id); - }); - } - public function render() { - $section=$this->section();$query=DB::table($section);if($section==='users'){$query->select('id','name','email','is_admin','email_verified_at');}elseif($section==='licenses'){$query->select('id','user_id','purchase_id','status','device_limit');} - return view('livewire.admin.cms',['sections'=>self::TABLES,'rows'=>$query->orderBy($section==='settings'?'key':'id','desc')->limit(100)->get()]); - } +use Livewire\Attributes\Locked; +use Livewire\Component; +use Livewire\WithFileUploads; + +class Cms extends Component +{ + use WithFileUploads; + + public string $section = 'pages'; + + public array $form = []; + + public $upload; + + #[Locked] + public ?int $editing = null; + + private const TABLES = ['pages', 'releases', 'users', 'purchases', 'licenses', 'tickets', 'privacy_requests', 'consents', 'audit_logs', 'settings', 'navigation', 'media', 'redirects', 'email_templates']; + + public function boot(): void + { + abort_unless(auth()->user()?->is_admin && auth()->user()->two_factor_confirmed_at && auth()->user()->hasVerifiedEmail(), 403); + } + + private function section(): string + { + abort_unless(in_array($this->section, self::TABLES), 404); + + return $this->section; + } + + public function newRecord(): void + { + $this->reset('form', 'editing', 'upload'); + } + + public function updatedSection(): void + { + $this->reset('form', 'editing', 'upload'); + } + + private function audit(string $action, string $resource): void + { + DB::table('audit_logs')->insert(['user_id' => auth()->id(), 'action' => $action, 'resource' => $resource, 'created_at' => now(), 'updated_at' => now()]); + } + + public function edit(int $id): void + { + $section = $this->section(); + abort_unless(in_array($section, ['pages', 'releases']), 403); + $row = DB::table($section)->find($id); + abort_unless($row, 404); + $this->editing = $id; + $this->form = array_intersect_key((array) $row, array_flip($section === 'pages' ? ['slug', 'title', 'body', 'description', 'published'] : ['version', 'channel', 'platform', 'notes', 'published'])); + } + + public function save(): void + { + $section = $this->section(); + abort_unless(in_array($section, ['pages', 'releases']), 403); + if ($section === 'pages') { + $data = $this->validate(['form.slug' => 'required|alpha_dash|max:100|unique:pages,slug,'.($this->editing ?? 'NULL'), 'form.title' => 'required|string|max:200', 'form.body' => 'required|string|max:100000', 'form.description' => 'nullable|string|max:255', 'form.published' => 'boolean'])['form']; + } else { + $data = $this->validate(['form.version' => 'required|regex:/^[0-9]+\.[0-9]+\.[0-9]+(?:-[a-zA-Z0-9.]+)?$/|max:50|unique:releases,version,'.($this->editing ?? 'NULL'), 'form.channel' => 'required|in:stable,beta', 'form.platform' => 'required|string|max:50', 'form.notes' => 'required|string|max:20000', 'form.published' => 'boolean'])['form']; + $this->validate(['upload' => ($this->editing ? 'nullable' : 'required').'|file|extensions:zip,dmg,pkg|max:1048576']); + if ($this->upload) { + $path = $this->upload->store('releases', 'local'); + $data += ['path' => $path, 'sha256' => hash_file('sha256', Storage::disk('local')->path($path)), 'bytes' => Storage::disk('local')->size($path)]; + } + } + $data['published'] = $data['published'] ?? false; + $data['updated_at'] = now(); + if ($this->editing) { + DB::table($section)->where('id', $this->editing)->update($data); + $id = $this->editing; + } else { + $data['created_at'] = now(); + $id = DB::table($section)->insertGetId($data); + } + $this->audit('saved', $section.':'.$id); + $this->reset('form', 'editing', 'upload'); + session()->flash('status', 'Saved successfully.'); + } + + public function revoke(int $id): void + { + abort_unless($this->section() === 'licenses', 403); + DB::table('licenses')->where('id', $id)->update(['status' => 'revoked', 'updated_at' => now()]); + $this->audit('revoked', 'license:'.$id); + } + + public function saveResource(): void + { + $section = $this->section(); + abort_unless(in_array($section, ['navigation', 'media', 'redirects', 'email_templates']), 403); + $rules = match ($section) { + 'navigation' => ['form.label' => 'required|string|max:80', 'form.path' => ['required', 'string', 'regex:~^/(?!/)[a-zA-Z0-9/_?.=&%#-]*$~'], 'form.position' => 'required|integer|min:0|max:1000', 'form.published' => 'boolean'], + 'redirects' => ['form.source' => ['required', 'string', 'max:200', 'regex:~^/(?!/)[a-zA-Z0-9/_-]+$~', 'unique:redirects,source'], 'form.destination' => ['required', 'string', 'max:200', 'different:form.source', 'regex:~^/(?!/)[a-zA-Z0-9/_-]+$~']], + 'media' => ['form.title' => 'required|string|max:150', 'form.alt' => 'required|string|max:255', 'upload' => 'required|image|mimes:png,jpg,jpeg,webp|dimensions:max_width=6000,max_height=6000|max:10240'], + 'email_templates' => ['form.slug' => 'required|alpha_dash|max:80|unique:email_templates,slug', 'form.subject' => 'required|string|max:200', 'form.body' => 'required|string|max:10000'], + }; + $validated = $this->validate($rules); + $data = $validated['form']; + if ($section === 'media') { + $data += ['path' => $this->upload->store('media', 'local'), 'mime' => $this->upload->getMimeType(), 'bytes' => $this->upload->getSize()]; + } + if ($section === 'navigation') { + $data['published'] = $data['published'] ?? false; + } + DB::table($section)->insert([...$data, 'created_at' => now(), 'updated_at' => now()]); + $this->audit('created', $section); + $this->reset('form', 'upload'); + } + + public function remove(int $id): void + { + $section = $this->section(); + abort_unless(in_array($section, ['navigation', 'media', 'redirects', 'email_templates']), 403); + $row = DB::table($section)->find($id); + abort_unless($row, 404); + DB::table($section)->where('id', $id)->delete(); + if ($section === 'media') { + Storage::disk('local')->delete($row->path); + }$this->audit('deleted', $section.':'.$id); + } + + public function setDeviceLimit(int $id): void + { + abort_unless($this->section() === 'licenses', 403); + $v = $this->validate(['form.device_limit' => 'required|integer|min:1|max:100']); + DB::table('licenses')->where('id', $id)->update(['device_limit' => $v['form']['device_limit'], 'updated_at' => now()]); + $this->audit('device limit updated', 'license:'.$id); + } + + public function saveSettings(): void + { + abort_unless($this->section() === 'settings', 403); + $v = $this->validate(['form.headline' => 'required|string|max:120', 'form.tagline' => 'required|string|max:500', 'form.price_label' => 'required|string|max:100'])['form']; + foreach ($v as $key => $value) { + DB::table('settings')->updateOrInsert(['key' => $key], ['value' => $value]); + }$this->audit('saved', 'settings'); + session()->flash('status', 'Settings saved.'); + } + + public function fulfillDeletion(int $id): void + { + abort_unless($this->section() === 'privacy_requests', 403); + DB::transaction(function () use ($id) { + $request = DB::table('privacy_requests')->where('id', $id)->lockForUpdate()->first(); + abort_unless($request && $request->type === 'deletion' && $request->status === 'pending', 422); + abort_if($request->user_id === auth()->id(), 422, 'Cannot erase your own administrator account.'); + $user = User::findOrFail($request->user_id); + abort_if($user->is_admin, 422, 'Remove administrator access before erasure.'); + DB::table('licenses')->where('user_id', $user->id)->update(['status' => 'revoked', 'updated_at' => now()]); + DB::table('activations')->whereIn('license_id', DB::table('licenses')->where('user_id', $user->id)->select('id'))->delete(); + DB::table('tickets')->where('user_id', $user->id)->delete(); + DB::table('ticket_messages')->where('user_id', $user->id)->delete(); + DB::table('consents')->where('user_id', $user->id)->delete(); + DB::table('sessions')->where('user_id', $user->id)->delete(); + DB::table('password_reset_tokens')->where('email', $user->email)->delete(); + $user->forceFill(['name' => 'Deleted customer', 'email' => 'deleted-'.$user->id.'@invalid.example', 'password' => bin2hex(random_bytes(32)), 'remember_token' => null, 'account_token' => null, 'two_factor_secret' => null, 'two_factor_recovery_codes' => null])->save(); + DB::table('privacy_requests')->where('id', $id)->update(['status' => 'fulfilled', 'updated_at' => now()]); + $this->audit('privacy erasure fulfilled; billing records retained', 'user:'.$user->id); + }); + } + + public function render() + { + $section = $this->section(); + $query = DB::table($section); + if ($section === 'users') { + $query->select('id', 'name', 'email', 'is_admin', 'email_verified_at'); + } elseif ($section === 'licenses') { + $query->select('id','user_id','purchase_id','status','device_limit'); + } + + return view('livewire.admin.cms',['sections' => self::TABLES, 'rows' => $query->orderBy($section === 'settings' ? 'key' : 'id','desc')->limit(100)->get()]); + } } diff --git a/appstore/website/app/Livewire/SupportDesk.php b/appstore/website/app/Livewire/SupportDesk.php index 0cd63f3..050fafb 100644 --- a/appstore/website/app/Livewire/SupportDesk.php +++ b/appstore/website/app/Livewire/SupportDesk.php @@ -1,34 +1,95 @@ find($this->ticketId);abort_unless($ticket && (auth()->user()->is_admin || $ticket->user_id===auth()->id()),403);return $ticket; } - public function select(int $id): void {$this->ticketId=$id;$this->ticket();} - public function create(): void { - $this->validate(['subject'=>'required|string|max:200','body'=>'required|string|min:10|max:10000']); - $this->ticketId=DB::transaction(function(){ $id=DB::table('tickets')->insertGetId(['user_id'=>auth()->id(),'subject'=>$this->subject,'status'=>'open','created_at'=>now(),'updated_at'=>now()]);DB::table('ticket_messages')->insert(['ticket_id'=>$id,'user_id'=>auth()->id(),'body'=>$this->body,'created_at'=>now(),'updated_at'=>now()]);return $id;}); - if(config('tarnax.support_email')){Notification::route('mail',config('tarnax.support_email'))->notify(new AccountUpdate('New Tarnax support ticket',$this->subject,url('/support')));} - $this->reset('subject','body'); - } - public function send(): void { - $ticket=$this->ticket();$this->validate(['reply'=>'required|string|min:2|max:10000']); - DB::table('ticket_messages')->insert(['ticket_id'=>$ticket->id,'user_id'=>auth()->id(),'body'=>$this->reply,'created_at'=>now(),'updated_at'=>now()]); - DB::table('tickets')->where('id',$ticket->id)->update(['status'=>'open','updated_at'=>now()]); - $notification=new AccountUpdate('Your Tarnax support ticket has a reply','Sign in to read the reply.',url('/support')); - if(auth()->user()->is_admin){User::findOrFail($ticket->user_id)->notify($notification);}elseif(config('tarnax.support_email')){Notification::route('mail',config('tarnax.support_email'))->notify($notification);} - $this->reply=''; - } - public function close(): void {$ticket=$this->ticket();DB::table('tickets')->where('id',$ticket->id)->update(['status'=>'closed','updated_at'=>now()]);} - public function render() { - $query=DB::table('tickets');if(!auth()->user()->is_admin){$query->where('user_id',auth()->id());} - $messages=collect();if($this->ticketId){$this->ticket();$messages=DB::table('ticket_messages')->join('users','users.id','=','ticket_messages.user_id')->where('ticket_id',$this->ticketId)->select('ticket_messages.*','users.name')->orderBy('ticket_messages.id')->get();} - return view('livewire.support-desk',['tickets'=>$query->latest('updated_at')->limit(100)->get(),'messages'=>$messages]); - } +use Livewire\Attributes\Locked; +use Livewire\Component; + +class SupportDesk extends Component +{ + public function boot(): void + { + abort_unless(auth()->check() && auth()->user()->hasVerifiedEmail(), 403); + if (auth()->user()->is_admin) { + abort_unless(auth()->user()->two_factor_confirmed_at, 403); + } + } + + public string $subject = ''; + + public string $body = ''; + + public string $reply = ''; + + #[Locked] + public ?int $ticketId = null; + + private function ticket() + { + $ticket = DB::table('tickets')->find($this->ticketId); + abort_unless($ticket && (auth()->user()->is_admin || $ticket->user_id === auth()->id()), 403); + + return $ticket; + } + + public function select(int $id): void + { + $this->ticketId = $id; + $this->ticket(); + } + + public function create(): void + { + $this->validate(['subject' => 'required|string|max:200', 'body' => 'required|string|min:10|max:10000']); + $this->ticketId = DB::transaction(function () { + $id = DB::table('tickets')->insertGetId(['user_id' => auth()->id(), 'subject' => $this->subject, 'status' => 'open', 'created_at' => now(), 'updated_at' => now()]); + DB::table('ticket_messages')->insert(['ticket_id' => $id, 'user_id' => auth()->id(), 'body' => $this->body, 'created_at' => now(), 'updated_at' => now()]); + + return $id; + }); + if (config('tarnax.support_email')) { + Notification::route('mail', config('tarnax.support_email'))->notify(new AccountUpdate('New Tarnax support ticket', $this->subject, url('/support'), 'support-update')); + } + $this->reset('subject', 'body'); + } + + public function send(): void + { + $ticket = $this->ticket(); + $this->validate(['reply' => 'required|string|min:2|max:10000']); + DB::table('ticket_messages')->insert(['ticket_id' => $ticket->id, 'user_id' => auth()->id(), 'body' => $this->reply, 'created_at' => now(), 'updated_at' => now()]); + DB::table('tickets')->where('id', $ticket->id)->update(['status' => 'open', 'updated_at' => now()]); + $notification = new AccountUpdate('Your Tarnax support ticket has a reply', 'Sign in to read the reply.', url('/support'), 'support-update'); + if (auth()->user()->is_admin) { + User::findOrFail($ticket->user_id)->notify($notification); + } elseif (config('tarnax.support_email')) { + Notification::route('mail', config('tarnax.support_email'))->notify($notification); + } + $this->reply = ''; + } + + public function close(): void + { + $ticket = $this->ticket(); + DB::table('tickets')->where('id', $ticket->id)->update(['status' => 'closed', 'updated_at' => now()]); + } + + public function render() + { + $query = DB::table('tickets'); + if (! auth()->user()->is_admin) { + $query->where('user_id', auth()->id()); + } + $messages = collect(); + if ($this->ticketId) { + $this->ticket(); + $messages = DB::table('ticket_messages')->join('users', 'users.id', '=', 'ticket_messages.user_id')->where('ticket_id', $this->ticketId)->select('ticket_messages.*', 'users.name')->orderBy('ticket_messages.id')->get(); + } + + return view('livewire.support-desk', ['tickets' => $query->latest('updated_at')->limit(100)->get(), 'messages' => $messages]); + } } diff --git a/appstore/website/app/Models/User.php b/appstore/website/app/Models/User.php index 49272ea..8ed02af 100644 --- a/appstore/website/app/Models/User.php +++ b/appstore/website/app/Models/User.php @@ -2,14 +2,16 @@ namespace App\Models; -use Illuminate\Contracts\Auth\MustVerifyEmail; -use Laravel\Fortify\TwoFactorAuthenticatable; +use App\Notifications\QueuedResetPassword; +use App\Notifications\QueuedVerifyEmail; use Database\Factories\UserFactory; +use Illuminate\Contracts\Auth\MustVerifyEmail; use Illuminate\Database\Eloquent\Attributes\Fillable; use Illuminate\Database\Eloquent\Attributes\Hidden; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Notifications\Notifiable; +use Laravel\Fortify\TwoFactorAuthenticatable; #[Fillable(['name', 'email', 'password'])] #[Hidden(['password', 'remember_token', 'two_factor_secret', 'two_factor_recovery_codes'])] @@ -23,6 +25,16 @@ class User extends Authenticatable implements MustVerifyEmail * * @return array */ + public function sendEmailVerificationNotification(): void + { + $this->notify(new QueuedVerifyEmail); + } + + public function sendPasswordResetNotification($token): void + { + $this->notify(new QueuedResetPassword($token)); + } + protected function casts(): array { return [ diff --git a/appstore/website/app/Notifications/AccountUpdate.php b/appstore/website/app/Notifications/AccountUpdate.php index 5fc77ba..2619190 100644 --- a/appstore/website/app/Notifications/AccountUpdate.php +++ b/appstore/website/app/Notifications/AccountUpdate.php @@ -1,12 +1,31 @@ afterCommit(); } - public function via(object $notifiable): array {return ['mail'];} - public function toMail(object $notifiable): MailMessage {return (new MailMessage)->subject($this->subject)->line($this->message)->action('Open your account',$this->url);} +use Illuminate\Notifications\Notification; +use Illuminate\Support\Facades\DB; + +class AccountUpdate extends Notification implements ShouldQueue +{ + use Queueable; + + public function __construct(public string $subject, public string $message, public string $url, public ?string $template = null) + { + $this->afterCommit(); + } + + public function via(object $notifiable): array + { + return ['mail']; + } + + public function toMail(object $notifiable): MailMessage + { + $template = $this->template ? DB::table('email_templates')->where('slug', $this->template)->first() : null; + + return (new MailMessage)->subject($template?->subject ?? $this->subject)->line($template?->body ?? $this->message)->action('Open your account', $this->url); + } } diff --git a/appstore/website/app/Notifications/QueuedResetPassword.php b/appstore/website/app/Notifications/QueuedResetPassword.php index b65f2f6..c44073d 100644 --- a/appstore/website/app/Notifications/QueuedResetPassword.php +++ b/appstore/website/app/Notifications/QueuedResetPassword.php @@ -2,53 +2,11 @@ namespace App\Notifications; +use Illuminate\Auth\Notifications\ResetPassword; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldQueue; -use Illuminate\Notifications\Messages\MailMessage; -use Illuminate\Notifications\Notification; -class QueuedResetPassword extends Notification +class QueuedResetPassword extends ResetPassword implements ShouldQueue { use Queueable; - - /** - * Create a new notification instance. - */ - public function __construct() - { - // - } - - /** - * Get the notification's delivery channels. - * - * @return array - */ - public function via(object $notifiable): array - { - return ['mail']; - } - - /** - * Get the mail representation of the notification. - */ - public function toMail(object $notifiable): MailMessage - { - return (new MailMessage) - ->line('The introduction to the notification.') - ->action('Notification Action', url('/')) - ->line('Thank you for using our application!'); - } - - /** - * Get the array representation of the notification. - * - * @return array - */ - public function toArray(object $notifiable): array - { - return [ - // - ]; - } } diff --git a/appstore/website/app/Notifications/QueuedVerifyEmail.php b/appstore/website/app/Notifications/QueuedVerifyEmail.php index 7e8a29a..3babd29 100644 --- a/appstore/website/app/Notifications/QueuedVerifyEmail.php +++ b/appstore/website/app/Notifications/QueuedVerifyEmail.php @@ -2,53 +2,11 @@ namespace App\Notifications; +use Illuminate\Auth\Notifications\VerifyEmail; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldQueue; -use Illuminate\Notifications\Messages\MailMessage; -use Illuminate\Notifications\Notification; -class QueuedVerifyEmail extends Notification +class QueuedVerifyEmail extends VerifyEmail implements ShouldQueue { use Queueable; - - /** - * Create a new notification instance. - */ - public function __construct() - { - // - } - - /** - * Get the notification's delivery channels. - * - * @return array - */ - public function via(object $notifiable): array - { - return ['mail']; - } - - /** - * Get the mail representation of the notification. - */ - public function toMail(object $notifiable): MailMessage - { - return (new MailMessage) - ->line('The introduction to the notification.') - ->action('Notification Action', url('/')) - ->line('Thank you for using our application!'); - } - - /** - * Get the array representation of the notification. - * - * @return array - */ - public function toArray(object $notifiable): array - { - return [ - // - ]; - } } diff --git a/appstore/website/app/Providers/AppServiceProvider.php b/appstore/website/app/Providers/AppServiceProvider.php index 5cb3145..e30bc5b 100644 --- a/appstore/website/app/Providers/AppServiceProvider.php +++ b/appstore/website/app/Providers/AppServiceProvider.php @@ -2,6 +2,8 @@ namespace App\Providers; +use Illuminate\Cache\RateLimiting\Limit; +use Illuminate\Support\Facades\RateLimiter; use Illuminate\Support\ServiceProvider; class AppServiceProvider extends ServiceProvider @@ -19,7 +21,7 @@ class AppServiceProvider extends ServiceProvider */ public function boot(): void { - \Illuminate\Support\Facades\RateLimiter::for('license', fn ($request) => \Illuminate\Cache\RateLimiting\Limit::perMinute(30)->by($request->ip())); + RateLimiter::for('license', fn ($request) => Limit::perMinute(30)->by($request->ip())); // } } diff --git a/appstore/website/app/Providers/FortifyServiceProvider.php b/appstore/website/app/Providers/FortifyServiceProvider.php index 8186a47..fa0efdc 100644 --- a/appstore/website/app/Providers/FortifyServiceProvider.php +++ b/appstore/website/app/Providers/FortifyServiceProvider.php @@ -29,13 +29,13 @@ class FortifyServiceProvider extends ServiceProvider */ public function boot(): void { - Fortify::loginView(fn () => view('auth.form', ['mode'=>'login'])); - Fortify::registerView(fn () => view('auth.form', ['mode'=>'register'])); - Fortify::requestPasswordResetLinkView(fn () => view('auth.form', ['mode'=>'forgot-password'])); - Fortify::resetPasswordView(fn ($request) => view('auth.form', ['mode'=>'reset-password', 'request'=>$request])); + Fortify::loginView(fn () => view('auth.form', ['mode' => 'login'])); + Fortify::registerView(fn () => view('auth.form', ['mode' => 'register'])); + Fortify::requestPasswordResetLinkView(fn () => view('auth.form', ['mode' => 'forgot-password'])); + Fortify::resetPasswordView(fn ($request) => view('auth.form', ['mode' => 'reset-password', 'request' => $request])); Fortify::verifyEmailView(fn () => view('auth.verify')); - Fortify::confirmPasswordView(fn () => view('auth.form', ['mode'=>'confirm-password'])); - Fortify::twoFactorChallengeView(fn () => view('auth.form', ['mode'=>'two-factor-challenge'])); + Fortify::confirmPasswordView(fn () => view('auth.form', ['mode' => 'confirm-password'])); + Fortify::twoFactorChallengeView(fn () => view('auth.form', ['mode' => 'two-factor-challenge'])); Fortify::createUsersUsing(CreateNewUser::class); Fortify::updateUserProfileInformationUsing(UpdateUserProfileInformation::class); Fortify::updateUserPasswordsUsing(UpdateUserPassword::class); diff --git a/appstore/website/app/Services/AppleVerifier.php b/appstore/website/app/Services/AppleVerifier.php index 940f333..8529031 100644 --- a/appstore/website/app/Services/AppleVerifier.php +++ b/appstore/website/app/Services/AppleVerifier.php @@ -1,30 +1,93 @@ decode($parts[0]),true,512,JSON_THROW_ON_ERROR); - abort_unless(($header['alg']??'')==='ES256' && count($header['x5c']??[])>=2 && count($header['x5c'])<=3,422); - $certs=array_map(fn($c)=>$this->pem($c),$header['x5c']);$root=file_get_contents($rootPath); - foreach($certs as $index=>$cert){ - $parsed=openssl_x509_parse($cert);abort_unless($parsed && $parsed['validFrom_time_t']<=time() && $parsed['validTo_time_t']>=time(),422); - $issuer=$certs[$index+1]??$root; - abort_unless(openssl_x509_verify($cert,openssl_pkey_get_public($issuer))===1,422,'Invalid Apple certificate chain.'); - } - $leaf=openssl_x509_parse($certs[0]);abort_unless(isset($leaf['extensions']['1.2.840.113635.100.6.11.1']),422,'Not an Apple transaction signing certificate.'); - $sig=$this->decode($parts[2]);abort_unless(strlen($sig)===64,422); - $der='';foreach([substr($sig,0,32),substr($sig,32)] as $integer){$integer=ltrim($integer,"\0");if($integer==='' || (ord($integer[0])&128)){$integer="\0".$integer;}$der.="\x02".chr(strlen($integer)).$integer;} - $der="\x30".chr(strlen($der)).$der; - abort_unless(openssl_verify($parts[0].'.'.$parts[1],$der,$certs[0],OPENSSL_ALGO_SHA256)===1,422,'Invalid transaction signature.'); - $claims=json_decode($this->decode($parts[1]),true,512,JSON_THROW_ON_ERROR); - abort_unless(config('tarnax.apple.bundle_id') && config('tarnax.apple.product_id'),503); - abort_unless(($claims['bundleId']??'')===config('tarnax.apple.bundle_id') && ($claims['productId']??'')===config('tarnax.apple.product_id') && ($claims['environment']??'')===config('tarnax.apple.environment') && strtolower($claims['appAccountToken']??'')===strtolower($accountToken),422,'Transaction does not match this account or product.'); - abort_if(isset($claims['revocationDate']) || (($claims['expiresDate']??PHP_INT_MAX)=time()*1000-86400000,422,'A recent signed transaction is required.'); - return $claims; - } +class AppleVerifier +{ + private function json(string $data): array + { + try { + $result = json_decode($data, true, 512, JSON_THROW_ON_ERROR); + } catch (\JsonException) { + abort(422, 'Invalid signed JSON.'); + }abort_unless(is_array($result), 422); + + return $result; + } + + private function decode(string $data): string + { + $result = base64_decode(strtr($data, '-_', '+/'), true); + abort_unless($result !== false, 422); + + return $result; + } + + private function pem(string $cert): string + { + return "-----BEGIN CERTIFICATE-----\n".chunk_split($cert, 64, "\n")."-----END CERTIFICATE-----\n"; + } + + public function envelope(string $jws): array + { + $rootPath = config('tarnax.apple.root_certificate'); + abort_unless($rootPath && is_file($rootPath), 503, 'Apple verification is not configured.'); + $parts = explode('.', $jws); + abort_unless(count($parts) === 3, 422); + $header = $this->json($this->decode($parts[0])); + abort_unless(($header['alg'] ?? '') === 'ES256' && count($header['x5c'] ?? []) >= 2 && count($header['x5c']) <= 3, 422); + $certs = array_map(fn ($c) => $this->pem($c), $header['x5c']); + $root = file_get_contents($rootPath); + foreach ($certs as $index => $cert) { + $parsed = openssl_x509_parse($cert); + abort_unless($parsed && $parsed['validFrom_time_t'] <= time() && $parsed['validTo_time_t'] >= time(), 422); + $issuer = $certs[$index + 1] ?? $root; + abort_unless(openssl_x509_verify($cert, openssl_pkey_get_public($issuer)) === 1, 422, 'Invalid Apple certificate chain.'); + } + $untrusted = tempnam(sys_get_temp_dir(), 'tarnax-apple-'); + try { + file_put_contents($untrusted, implode("\n", array_slice($certs, 1))); + $trusted = openssl_x509_checkpurpose($certs[0], X509_PURPOSE_ANY, [$rootPath], $untrusted); + abort_unless($trusted === true || $trusted === 1, 422, 'Invalid certificate constraints.'); + } finally { + unlink($untrusted); + } + $leaf = openssl_x509_parse($certs[0]); + abort_unless(isset($leaf['extensions']['1.2.840.113635.100.6.11.1']), 422, 'Not an Apple transaction signing certificate.'); + $sig = $this->decode($parts[2]); + abort_unless(strlen($sig) === 64, 422); + $der = ''; + foreach ([substr($sig, 0, 32), substr($sig, 32)] as $integer) { + $integer = ltrim($integer, "\0"); + if ($integer === '' || (ord($integer[0]) & 128)) { + $integer = "\0".$integer; + }$der .= "\x02".chr(strlen($integer)).$integer; + } + $der = "\x30".chr(strlen($der)).$der; + abort_unless(openssl_verify($parts[0].'.'.$parts[1], $der, $certs[0], OPENSSL_ALGO_SHA256) === 1, 422, 'Invalid transaction signature.'); + $claims = $this->json($this->decode($parts[1])); + abort_unless(isset($claims['signedDate']) && $claims['signedDate'] <= time() * 1000 + 300000, 422); + + return $claims; + } + + public function transaction(string $jws): array + { + $claims = $this->envelope($jws); + abort_unless(config('tarnax.apple.bundle_id') && config('tarnax.apple.product_id'), 503); + abort_unless(($claims['bundleId'] ?? '') === config('tarnax.apple.bundle_id') && ($claims['productId'] ?? '') === config('tarnax.apple.product_id') && ($claims['environment'] ?? '') === config('tarnax.apple.environment') && isset($claims['transactionId']), 422, 'Transaction does not match this app.'); + + return $claims; + } + + public function verify(string $jws, string $accountToken): array + { + $claims = $this->transaction($jws); + abort_unless($accountToken !== '' && strtolower($claims['appAccountToken'] ?? '') === strtolower($accountToken), 422, 'Transaction does not match this account.'); + abort_if(isset($claims['revocationDate']) || (($claims['expiresDate'] ?? PHP_INT_MAX) < time() * 1000), 403, 'Purchase is no longer valid.'); + abort_unless($claims['signedDate'] >= time() * 1000 - 86400000,422,'A recent signed transaction is required.'); + + return $claims; + } } diff --git a/appstore/website/app/Services/LicenseService.php b/appstore/website/app/Services/LicenseService.php index 74db609..ab2918a 100644 --- a/appstore/website/app/Services/LicenseService.php +++ b/appstore/website/app/Services/LicenseService.php @@ -1,34 +1,52 @@ where('purchase_id',$purchaseId)->exists()){return;} - $key='TNX-'.Str::upper(bin2hex(random_bytes(20))); - DB::table('licenses')->insert(['user_id'=>$userId,'purchase_id'=>$purchaseId,'key_hash'=>hash('sha256',$key),'key_encrypted'=>Crypt::encryptString($key),'status'=>'active','device_limit'=>2,'created_at'=>now(),'updated_at'=>now()]); - } - public function activate(string $key,string $device): array { - return DB::transaction(function()use($key,$device){ - $license=DB::table('licenses')->where('key_hash',hash('sha256',$key))->lockForUpdate()->first(); - abort_unless($license && $license->status==='active',403,'Invalid or revoked license.'); - $deviceHash=hash('sha256',$device);$existing=DB::table('activations')->where('license_id',$license->id)->where('device_hash',$deviceHash)->first(); - abort_if(!$existing && DB::table('activations')->where('license_id',$license->id)->count()>=$license->device_limit,409,'Device limit reached.'); - $token=bin2hex(random_bytes(32)); - DB::table('activations')->updateOrInsert(['license_id'=>$license->id,'device_hash'=>$deviceHash],['token_hash'=>hash('sha256',$token),'last_seen_at'=>now(),'created_at'=>now(),'updated_at'=>now()]); - return ['activation_token'=>$token,'valid'=>true,'check_again_in'=>86400]; - },3); - } - public function check(string $token,bool $deactivate=false): array { - return DB::transaction(function()use($token,$deactivate){ - $activation=DB::table('activations')->where('token_hash',hash('sha256',$token))->first(); - abort_unless($activation,403,'Invalid activation.'); - $license=DB::table('licenses')->where('id',$activation->license_id)->lockForUpdate()->first(); - if($deactivate){DB::table('activations')->where('id',$activation->id)->delete();return ['deactivated'=>true];} - abort_unless($license->status==='active',403,'License revoked.'); - DB::table('activations')->where('id',$activation->id)->update(['last_seen_at'=>now()]); - return ['valid'=>true,'check_again_in'=>86400]; - },3); - } + +class LicenseService +{ + public function issue(int $userId, int $purchaseId): void + { + if (DB::table('licenses')->where('purchase_id', $purchaseId)->exists()) { + return; + } + $key = 'TNX-'.Str::upper(bin2hex(random_bytes(20))); + DB::table('licenses')->insert(['user_id' => $userId, 'purchase_id' => $purchaseId, 'key_hash' => hash('sha256', $key), 'key_encrypted' => Crypt::encryptString($key), 'status' => 'active', 'device_limit' => 2, 'created_at' => now(), 'updated_at' => now()]); + } + + public function activate(string $key, string $device): array + { + return DB::transaction(function () use ($key, $device) { + $license = DB::table('licenses')->where('key_hash', hash('sha256', $key))->lockForUpdate()->first(); + abort_unless($license && $license->status === 'active', 403, 'Invalid or revoked license.'); + $deviceHash = hash('sha256', $device); + $existing = DB::table('activations')->where('license_id', $license->id)->where('device_hash', $deviceHash)->first(); + abort_if(! $existing && DB::table('activations')->where('license_id', $license->id)->count() >= $license->device_limit, 409, 'Device limit reached.'); + $token = bin2hex(random_bytes(32)); + DB::table('activations')->updateOrInsert(['license_id' => $license->id, 'device_hash' => $deviceHash], ['token_hash' => hash('sha256', $token), 'last_seen_at' => now(), 'created_at' => now(), 'updated_at' => now()]); + + return ['activation_token' => $token, 'valid' => true, 'check_again_in' => 86400]; + }, 3); + } + + public function check(string $token, bool $deactivate = false): array + { + return DB::transaction(function () use ($token, $deactivate) { + $activation = DB::table('activations')->where('token_hash', hash('sha256', $token))->first(); + abort_unless($activation, 403, 'Invalid activation.'); + $license = DB::table('licenses')->where('id', $activation->license_id)->lockForUpdate()->first(); + if ($deactivate) { + DB::table('activations')->where('id', $activation->id)->delete(); + + return ['deactivated' => true]; + } + abort_unless($license->status === 'active', 403, 'License revoked.'); + DB::table('activations')->where('id', $activation->id)->update(['last_seen_at' => now()]); + + return ['valid' => true, 'check_again_in' => 86400]; + }, 3); + } } diff --git a/appstore/website/bootstrap/providers.php b/appstore/website/bootstrap/providers.php index 0ad9c57..5ffd769 100644 --- a/appstore/website/bootstrap/providers.php +++ b/appstore/website/bootstrap/providers.php @@ -1,6 +1,9 @@ ['environment'=>env('PADDLE_ENVIRONMENT','sandbox'),'api_key'=>env('PADDLE_API_KEY'),'client_token'=>env('PADDLE_CLIENT_TOKEN'),'price_id'=>env('PADDLE_PRICE_ID'),'webhook_secret'=>env('PADDLE_WEBHOOK_SECRET')], - 'apple'=>['url'=>env('APPLE_STORE_URL'),'bundle_id'=>env('APPLE_BUNDLE_ID'),'product_id'=>env('APPLE_PRODUCT_ID'),'environment'=>env('APPLE_ENVIRONMENT','Sandbox'),'root_certificate'=>env('APPLE_ROOT_CERTIFICATE')], - 'support_email'=>env('SUPPORT_EMAIL'),'consent_version'=>'2026-11-04', + 'paddle' => ['environment' => env('PADDLE_ENVIRONMENT', 'sandbox'), 'api_key' => env('PADDLE_API_KEY'), 'client_token' => env('PADDLE_CLIENT_TOKEN'), 'price_id' => env('PADDLE_PRICE_ID'), 'webhook_secret' => env('PADDLE_WEBHOOK_SECRET')], + 'apple' => ['url' => env('APPLE_STORE_URL'), 'bundle_id' => env('APPLE_BUNDLE_ID'), 'product_id' => env('APPLE_PRODUCT_ID'), 'environment' => env('APPLE_ENVIRONMENT', 'Sandbox'), 'root_certificate' => env('APPLE_ROOT_CERTIFICATE')], + 'support_email' => env('SUPPORT_EMAIL'), 'consent_version' => '2026-11-04', ]; diff --git a/appstore/website/database/migrations/2026_11_04_163430_create_commerce_tables.php b/appstore/website/database/migrations/2026_11_04_163430_create_commerce_tables.php index dbefc7a..b7fb52d 100644 --- a/appstore/website/database/migrations/2026_11_04_163430_create_commerce_tables.php +++ b/appstore/website/database/migrations/2026_11_04_163430_create_commerce_tables.php @@ -1,22 +1,123 @@ boolean('is_admin')->default(false);$t->uuid('account_token')->nullable()->unique();}); - Schema::create('pages',function(Blueprint $t){$t->id();$t->string('slug')->unique();$t->string('title');$t->text('body');$t->string('description')->nullable();$t->boolean('published')->default(false);$t->timestamps();}); - Schema::create('settings',function(Blueprint $t){$t->string('key')->primary();$t->text('value')->nullable();}); - Schema::create('releases',function(Blueprint $t){$t->id();$t->string('version')->unique();$t->string('channel')->default('stable');$t->string('platform')->default('macOS');$t->text('notes');$t->string('path');$t->string('sha256',64);$t->unsignedBigInteger('bytes');$t->boolean('published')->default(false);$t->timestamps();}); - Schema::create('purchases',function(Blueprint $t){$t->id();$t->foreignId('user_id')->constrained()->restrictOnDelete();$t->string('provider');$t->string('transaction_id')->unique();$t->string('subscription_id')->nullable()->index();$t->string('status');$t->unsignedBigInteger('total')->default(0);$t->string('currency',3)->default('USD');$t->timestamp('provider_updated_at')->nullable();$t->timestamps();}); - Schema::create('licenses',function(Blueprint $t){$t->id();$t->foreignId('user_id')->constrained()->cascadeOnDelete();$t->foreignId('purchase_id')->unique()->constrained()->cascadeOnDelete();$t->string('key_hash',64)->unique();$t->text('key_encrypted');$t->string('status')->default('active');$t->unsignedInteger('device_limit')->default(2);$t->timestamps();}); - Schema::create('activations',function(Blueprint $t){$t->id();$t->foreignId('license_id')->constrained()->cascadeOnDelete();$t->string('device_hash',64);$t->string('token_hash',64)->unique();$t->timestamp('last_seen_at');$t->timestamps();$t->unique(['license_id','device_hash']);}); - Schema::create('webhook_events',function(Blueprint $t){$t->string('id')->primary();$t->string('type');$t->timestamp('processed_at');}); - Schema::create('tickets',function(Blueprint $t){$t->id();$t->foreignId('user_id')->constrained()->cascadeOnDelete();$t->string('subject');$t->string('status')->default('open');$t->timestamps();}); - Schema::create('ticket_messages',function(Blueprint $t){$t->id();$t->foreignId('ticket_id')->constrained()->cascadeOnDelete();$t->foreignId('user_id')->constrained()->cascadeOnDelete();$t->text('body');$t->timestamps();}); - Schema::create('consents',function(Blueprint $t){$t->id();$t->uuid('visitor_id')->index();$t->foreignId('user_id')->nullable()->constrained()->nullOnDelete();$t->string('policy_version');$t->boolean('analytics');$t->boolean('marketing');$t->timestamps();}); - Schema::create('privacy_requests',function(Blueprint $t){$t->id();$t->foreignId('user_id')->constrained()->cascadeOnDelete();$t->string('type');$t->string('status')->default('pending');$t->timestamps();}); - Schema::create('audit_logs',function(Blueprint $t){$t->id();$t->foreignId('user_id')->nullable()->constrained()->nullOnDelete();$t->string('action');$t->string('resource');$t->timestamps();}); - } - public function down(): void {foreach(['audit_logs','privacy_requests','consents','ticket_messages','tickets','webhook_events','activations','licenses','purchases','releases','settings','pages'] as $table){Schema::dropIfExists($table);}Schema::table('users',function(Blueprint $t){$t->dropColumn(['is_admin','account_token']);});} + +return new class extends Migration +{ + public function up(): void + { + Schema::table('users', function (Blueprint $t) { + $t->boolean('is_admin')->default(false); + $t->uuid('account_token')->nullable()->unique(); + }); + Schema::create('pages', function (Blueprint $t) { + $t->id(); + $t->string('slug')->unique(); + $t->string('title'); + $t->text('body'); + $t->string('description')->nullable(); + $t->boolean('published')->default(false); + $t->timestamps(); + }); + Schema::create('settings', function (Blueprint $t) { + $t->string('key')->primary(); + $t->text('value')->nullable(); + }); + Schema::create('releases', function (Blueprint $t) { + $t->id(); + $t->string('version')->unique(); + $t->string('channel')->default('stable'); + $t->string('platform')->default('macOS'); + $t->text('notes'); + $t->string('path'); + $t->string('sha256', 64); + $t->unsignedBigInteger('bytes'); + $t->boolean('published')->default(false); + $t->timestamps(); + }); + Schema::create('purchases', function (Blueprint $t) { + $t->id(); + $t->foreignId('user_id')->constrained()->restrictOnDelete(); + $t->string('provider'); + $t->string('transaction_id')->unique(); + $t->string('subscription_id')->nullable()->index(); + $t->string('status'); + $t->unsignedBigInteger('total')->default(0); + $t->string('currency', 3)->default('USD'); + $t->timestamp('provider_updated_at')->nullable(); + $t->timestamps(); + }); + Schema::create('licenses', function (Blueprint $t) { + $t->id(); + $t->foreignId('user_id')->constrained()->cascadeOnDelete(); + $t->foreignId('purchase_id')->unique()->constrained()->cascadeOnDelete(); + $t->string('key_hash', 64)->unique(); + $t->text('key_encrypted'); + $t->string('status')->default('active'); + $t->unsignedInteger('device_limit')->default(2); + $t->timestamps(); + }); + Schema::create('activations', function (Blueprint $t) { + $t->id(); + $t->foreignId('license_id')->constrained()->cascadeOnDelete(); + $t->string('device_hash', 64); + $t->string('token_hash', 64)->unique(); + $t->timestamp('last_seen_at'); + $t->timestamps(); + $t->unique(['license_id', 'device_hash']); + }); + Schema::create('webhook_events', function (Blueprint $t) { + $t->string('id')->primary(); + $t->string('type'); + $t->timestamp('processed_at'); + }); + Schema::create('tickets', function (Blueprint $t) { + $t->id(); + $t->foreignId('user_id')->constrained()->cascadeOnDelete(); + $t->string('subject'); + $t->string('status')->default('open'); + $t->timestamps(); + }); + Schema::create('ticket_messages', function (Blueprint $t) { + $t->id(); + $t->foreignId('ticket_id')->constrained()->cascadeOnDelete(); + $t->foreignId('user_id')->constrained()->cascadeOnDelete(); + $t->text('body'); + $t->timestamps(); + }); + Schema::create('consents', function (Blueprint $t) { + $t->id(); + $t->uuid('visitor_id')->index(); + $t->foreignId('user_id')->nullable()->constrained()->nullOnDelete(); + $t->string('policy_version'); + $t->boolean('analytics'); + $t->boolean('marketing'); + $t->timestamps(); + }); + Schema::create('privacy_requests', function (Blueprint $t) { + $t->id(); + $t->foreignId('user_id')->constrained()->cascadeOnDelete(); + $t->string('type'); + $t->string('status')->default('pending'); + $t->timestamps(); + }); + Schema::create('audit_logs', function (Blueprint $t) { + $t->id(); + $t->foreignId('user_id')->nullable()->constrained()->nullOnDelete(); + $t->string('action'); + $t->string('resource'); + $t->timestamps(); + }); + } + + public function down(): void + { + foreach (['audit_logs', 'privacy_requests', 'consents', 'ticket_messages', 'tickets', 'webhook_events', 'activations', 'licenses', 'purchases', 'releases', 'settings', 'pages'] as $table) { + Schema::dropIfExists($table); + }Schema::table('users', function (Blueprint $t) { + $t->dropColumn(['is_admin', 'account_token']); + }); + } }; diff --git a/appstore/website/database/migrations/2026_11_04_164659_add_commerce_operations_tables.php b/appstore/website/database/migrations/2026_11_04_164659_add_commerce_operations_tables.php index 88fa2f3..a0eacd2 100644 --- a/appstore/website/database/migrations/2026_11_04_164659_add_commerce_operations_tables.php +++ b/appstore/website/database/migrations/2026_11_04_164659_add_commerce_operations_tables.php @@ -6,19 +6,56 @@ use Illuminate\Support\Facades\Schema; return new class extends Migration { - /** - * Run the migrations. - */ public function up(): void { - // + Schema::create('payment_locks', function (Blueprint $t) { + $t->string('id')->primary(); + }); + Schema::create('refund_blocks', function (Blueprint $t) { + $t->string('transaction_id')->primary(); + $t->timestamp('occurred_at'); + }); + Schema::create('subscription_states', function (Blueprint $t) { + $t->string('id')->primary(); + $t->string('status'); + $t->timestamp('occurred_at'); + }); + Schema::create('navigation', function (Blueprint $t) { + $t->id(); + $t->string('label'); + $t->string('path'); + $t->unsignedInteger('position')->default(0); + $t->boolean('published')->default(true); + $t->timestamps(); + }); + Schema::create('media', function (Blueprint $t) { + $t->id(); + $t->string('title'); + $t->string('alt'); + $t->string('path'); + $t->string('mime'); + $t->unsignedBigInteger('bytes'); + $t->timestamps(); + }); + Schema::create('redirects', function (Blueprint $t) { + $t->id(); + $t->string('source')->unique(); + $t->string('destination'); + $t->timestamps(); + }); + Schema::create('email_templates', function (Blueprint $t) { + $t->id(); + $t->string('slug')->unique(); + $t->string('subject'); + $t->text('body'); + $t->timestamps(); + }); } - /** - * Reverse the migrations. - */ public function down(): void { - // + foreach (['payment_locks', 'email_templates', 'redirects', 'media', 'navigation', 'subscription_states', 'refund_blocks'] as $table) { + Schema::dropIfExists($table); + } } }; diff --git a/appstore/website/database/seeders/DatabaseSeeder.php b/appstore/website/database/seeders/DatabaseSeeder.php index 0aa5fa0..5fd992e 100644 --- a/appstore/website/database/seeders/DatabaseSeeder.php +++ b/appstore/website/database/seeders/DatabaseSeeder.php @@ -1,17 +1,26 @@ ['Built around your server','Tarnax is a native remote for your own qBittorrent server. Monitor transfers, organise your library and control file priorities. Requires the qBittorrent Web UI API and a trusted HTTPS connection. Credentials are managed by the native app.'], - 'privacy'=>['Privacy notice — template','DRAFT — replace and review before publishing.\n\nController: [legal entity, address, contact]. We process account details, purchase references, support messages, device identifier hashes and consent choices to deliver software, secure accounts and provide support. Specify the lawful basis for each purpose.\n\nPaddle processes direct payments as merchant of record. Apple handles App Store purchases. Identify hosting, mail and other processors, international transfers and safeguards. We do not receive payment card details.\n\nSet retention periods for accounts, support, security and billing records. Explain access, correction, erasure, restriction, portability, objection and complaint rights, and name the supervisory authority. Use your account to export data or request deletion. Specify response deadlines and contact.'], - 'terms'=>['Terms of use — template','DRAFT — legal review required.\n\nProvider: [legal name and address]. Describe supported software, system requirements, price, tax, license scope, device limit, permitted uses, updates, support and termination. Use only content you are authorised to access and share.\n\nDirect purchases are processed by Paddle. App Store purchases are subject to Apple’s applicable terms. Distribution entitlements are separate. State applicable consumer rights, warranties, liability limits, governing law and dispute process without excluding mandatory rights.'], - 'refunds'=>['Refund policy — template','DRAFT — legal review required.\n\nFor direct purchases contact [support address] with your transaction reference. State the applicable refund period, eligibility and process in agreement with Paddle merchant terms and mandatory consumer rights. Explain digital-content cancellation consent where required. Approved refunds revoke direct licenses. App Store refunds are requested through Apple.'], - 'cookies'=>['Cookie policy — template','DRAFT — review cookie inventory and retention periods.\n\nEssential session and CSRF cookies protect your account. Tarnax preference and visitor cookies remember choices and record consent for up to one year. Optional analytics and marketing are disabled by default. No analytics or advertising scripts ship in this project. Change or withdraw choices at any time using Cookie preferences. Document any trackers before enabling them.'] - ]; - foreach($pages as $slug=>[$title,$body]){DB::table('pages')->insertOrIgnore(['slug'=>$slug,'title'=>$title,'body'=>str_replace('\\n',"\n",$body),'description'=>$title,'published'=>true,'created_at'=>now(),'updated_at'=>now()]);} - foreach(['headline'=>'Your server. Your control.','tagline'=>'A native iPhone and iPad remote for your own qBittorrent server. Follow live transfers, organise your library and manage the details from wherever you are.','price_label'=>'Pricing coming soon'] as $key=>$value){DB::table('settings')->insertOrIgnore(compact('key','value'));} - } + +class DatabaseSeeder extends Seeder +{ + public function run(): void + { + $pages = [ + 'features' => ['Built around your server', 'Tarnax is a native remote for your own qBittorrent server. Monitor transfers, organise your library and control file priorities. Requires the qBittorrent Web UI API and a trusted HTTPS connection. Credentials are managed by the native app.'], + 'privacy' => ['Privacy notice — template', 'DRAFT — replace and review before publishing.\n\nController: [legal entity, address, contact]. We process account details, purchase references, support messages, device identifier hashes and consent choices to deliver software, secure accounts and provide support. Specify the lawful basis for each purpose.\n\nPaddle processes direct payments as merchant of record. Apple handles App Store purchases. Identify hosting, mail and other processors, international transfers and safeguards. We do not receive payment card details.\n\nSet retention periods for accounts, support, security and billing records. Explain access, correction, erasure, restriction, portability, objection and complaint rights, and name the supervisory authority. Use your account to export data or request deletion. Specify response deadlines and contact.'], + 'terms' => ['Terms of use — template', 'DRAFT — legal review required.\n\nProvider: [legal name and address]. Describe supported software, system requirements, price, tax, license scope, device limit, permitted uses, updates, support and termination. Use only content you are authorised to access and share.\n\nDirect purchases are processed by Paddle. App Store purchases are subject to Apple’s applicable terms. Distribution entitlements are separate. State applicable consumer rights, warranties, liability limits, governing law and dispute process without excluding mandatory rights.'], + 'refunds' => ['Refund policy — template', 'DRAFT — legal review required.\n\nFor direct purchases contact [support address] with your transaction reference. State the applicable refund period, eligibility and process in agreement with Paddle merchant terms and mandatory consumer rights. Explain digital-content cancellation consent where required. Approved refunds revoke direct licenses. App Store refunds are requested through Apple.'], + 'cookies' => ['Cookie policy — template', 'DRAFT — review cookie inventory and retention periods.\n\nEssential session and CSRF cookies protect your account. Tarnax preference and visitor cookies remember choices and record consent for up to one year. Optional analytics and marketing are disabled by default. No analytics or advertising scripts ship in this project. Change or withdraw choices at any time using Cookie preferences. Document any trackers before enabling them.'], + ]; + foreach ($pages as $slug => [$title,$body]) { + DB::table('pages')->insertOrIgnore(['slug' => $slug, 'title' => $title, 'body' => str_replace('\\n', "\n", $body), 'description' => $title, 'published' => true, 'created_at' => now(), 'updated_at' => now()]); + } + foreach (['headline' => 'Your server. Your control.', 'tagline' => 'A native iPhone and iPad remote for your own qBittorrent server. Follow live transfers, organise your library and manage the details from wherever you are.', 'price_label' => 'Pricing coming soon'] as $key => $value) { + DB::table('settings')->insertOrIgnore(compact('key', 'value')); + } + } } diff --git a/appstore/website/phpunit.xml b/appstore/website/phpunit.xml index 53e5500..fc2aab5 100644 --- a/appstore/website/phpunit.xml +++ b/appstore/website/phpunit.xml @@ -18,6 +18,7 @@ + diff --git a/appstore/website/resources/views/components/layout.blade.php b/appstore/website/resources/views/components/layout.blade.php index 9befe95..bc2dca5 100644 --- a/appstore/website/resources/views/components/layout.blade.php +++ b/appstore/website/resources/views/components/layout.blade.php @@ -1,6 +1,6 @@ @props(['title'=>'Tarnax — Your server. Your control.','description'=>'A native remote for your qBittorrent server.']) {{ $title }}@vite(['resources/css/app.css','resources/js/app.js']) @livewireStyles -
tarnax/ software, considered.
+
tarnax/ software, considered.
@if(session('status'))
{{ session('status') }}
@endif @if($errors->any())@endif {{ $slot }}
© {{ date('Y') }} Tarnax · Independent software@foreach(['privacy','terms','refunds','cookies'] as $slug){{ ucfirst($slug) }}@endforeach

Requires your own qBittorrent server. Use only content you are authorised to access and share. Not affiliated with the qBittorrent project.

diff --git a/appstore/website/resources/views/livewire/admin/cms.blade.php b/appstore/website/resources/views/livewire/admin/cms.blade.php index a345525..96e6a94 100644 --- a/appstore/website/resources/views/livewire/admin/cms.blade.php +++ b/appstore/website/resources/views/livewire/admin/cms.blade.php @@ -1,4 +1,12 @@
Protected by administrator access and confirmed 2FA. Showing the latest 100 records per section. Mutations are audited.
@if(session('status'))

{{ session('status') }}

@endif @if($errors->any())@endif @if(in_array($section,['pages','releases']))

{{ $editing?'Edit':'Create' }} {{ rtrim($section,'s') }}

@foreach($section==='pages'?['slug','title','description']:['version','channel','platform'] as $field)@endforeach@if($section==='releases')@endif
@endif @if($section==='settings')

Storefront settings

@foreach(['headline','tagline','price_label'] as $field)@endforeach
@endif -
@if($rows->isNotEmpty())@foreach(array_keys((array)$rows->first()) as $key)@endforeach@endif@forelse($rows as $row)@foreach((array)$row as $key=>$value)@endforeach@empty@endforelse
{{ $key }}Actions
{{ \Illuminate\Support\Str::limit((string)$value,160) }}@if(in_array($section,['pages','releases']))@elseif($section==='licenses')@elseif($section==='privacy_requests' && $row->status==='pending')@endif
No records yet.
\ No newline at end of file +@if(in_array($section,['navigation','media','redirects','email_templates'])) +

Add {{ $section }}

+@foreach(match($section){'navigation'=>['label','path','position'],'media'=>['title','alt'],'redirects'=>['source','destination'],'email_templates'=>['slug','subject','body']} as $field) +@endforeach +@if($section==='media')@endif +@if($section==='navigation')@endif +
@endif +@if($section==='licenses')@endif +
@if($rows->isNotEmpty())@foreach(array_keys((array)$rows->first()) as $key)@endforeach@endif@forelse($rows as $row)@foreach((array)$row as $key=>$value)@endforeach@empty@endforelse
{{ $key }}Actions
{{ \Illuminate\Support\Str::limit((string)$value,160) }}@if(in_array($section,['pages','releases']))@elseif(in_array($section,['navigation','media','redirects','email_templates']))@elseif($section==='licenses') @elseif($section==='privacy_requests' && $row->status==='pending')@endif
No records yet.
\ No newline at end of file diff --git a/appstore/website/routes/api.php b/appstore/website/routes/api.php index c13769d..a1c08b2 100644 --- a/appstore/website/routes/api.php +++ b/appstore/website/routes/api.php @@ -1,10 +1,15 @@ middleware('throttle:120,1'); -Route::prefix('v1/licenses')->middleware('throttle:license')->group(function(){ - Route::post('activate',[LicenseController::class,'activate']); - Route::post('validate',[LicenseController::class,'validateLicense']); - Route::post('deactivate',[LicenseController::class,'deactivate']); +use Illuminate\Support\Facades\Route; + +Route::post('/webhooks/paddle', [PaddleController::class, 'webhook'])->middleware('throttle:120,1'); +Route::prefix('v1/licenses')->middleware('throttle:license')->group(function () { + Route::post('activate', [LicenseController::class, 'activate']); + Route::post('validate', [LicenseController::class, 'validateLicense']); + Route::post('deactivate', [LicenseController::class, 'deactivate']); }); + +Route::post('/webhooks/apple', AppleNotificationController::class)->middleware('throttle:120,1'); diff --git a/appstore/website/routes/console.php b/appstore/website/routes/console.php index 397f658..5c7ff2c 100644 --- a/appstore/website/routes/console.php +++ b/appstore/website/routes/console.php @@ -1,8 +1,22 @@ argument('email'))->first();if(!$user){$this->error('Register and verify this account first.');return 1;} - if(!$user->hasVerifiedEmail()){$this->error('Verify the email address first.');return 1;} - $user->forceFill(['is_admin'=>true])->save();$this->info('Administrator granted. Confirm 2FA in account security before opening /admin.');return 0; +use Illuminate\Support\Facades\Artisan; + +Artisan::command('tarnax:admin {email}', function () { + $user = User::where('email', $this->argument('email'))->first(); + if (! $user) { + $this->error('Register and verify this account first.'); + + return 1; + } + if (! $user->hasVerifiedEmail()) { + $this->error('Verify the email address first.'); + + return 1; + } + $user->forceFill(['is_admin' => true])->save(); + $this->info('Administrator granted. Confirm 2FA in account security before opening /admin.'); + + return 0; })->purpose('Promote a verified customer to administrator'); diff --git a/appstore/website/routes/web.php b/appstore/website/routes/web.php index 3ecd714..80178aa 100644 --- a/appstore/website/routes/web.php +++ b/appstore/website/routes/web.php @@ -1,20 +1,39 @@ name('home'); -Route::get('/pages/{slug}',[Shop::class,'page'])->name('page'); -Route::get('/releases',[Shop::class,'releases'])->name('releases'); -Route::post('/consent',[Shop::class,'consent'])->middleware('throttle:30,1')->name('consent'); -Route::middleware(['auth','verified'])->group(function(){ - Route::get('/account',[Shop::class,'account'])->name('account'); - Route::view('/account/security','security')->name('security'); - Route::get('/support',fn()=>view('support'))->name('support'); - Route::post('/checkout',[PaddleController::class,'checkout'])->middleware('throttle:10,1')->name('checkout'); - Route::post('/account/apple',[Shop::class,'apple'])->middleware('throttle:10,1')->name('apple.verify'); - Route::get('/downloads/{release}',[Shop::class,'downloadLink'])->name('download.link'); - Route::get('/download/{release}/{user}',[Shop::class,'download'])->middleware('signed')->name('download.file'); - Route::get('/account/export',[Shop::class,'export'])->middleware('password.confirm')->name('privacy.export'); - Route::post('/account/deletion',[Shop::class,'deletion'])->middleware('password.confirm')->name('privacy.delete'); - Route::get('/admin',fn()=>view('admin'))->middleware([\App\Http\Middleware\RequireAdmin::class,'password.confirm'])->name('admin'); +use App\Http\Middleware\RequireAdmin; +use Illuminate\Http\Request; +use Illuminate\Support\Facades\DB; +use Illuminate\Support\Facades\Route; +use Illuminate\Support\Facades\Storage; + +Route::get('/', [Shop::class, 'home'])->name('home'); +Route::get('/pages/{slug}', [Shop::class, 'page'])->name('page'); +Route::get('/releases', [Shop::class, 'releases'])->name('releases'); +Route::post('/consent', [Shop::class, 'consent'])->middleware('throttle:30,1')->name('consent'); +Route::middleware(['auth', 'verified'])->group(function () { + Route::get('/account', [Shop::class, 'account'])->name('account'); + Route::view('/account/security', 'security')->middleware('password.confirm')->name('security'); + Route::get('/support', fn () => view('support'))->name('support'); + Route::post('/checkout', [PaddleController::class, 'checkout'])->middleware('throttle:10,1')->name('checkout'); + Route::post('/account/apple', [Shop::class, 'apple'])->middleware('throttle:10,1')->name('apple.verify'); + Route::get('/downloads/{release}', [Shop::class, 'downloadLink'])->name('download.link'); + Route::get('/download/{release}/{user}', [Shop::class, 'download'])->middleware('signed')->name('download.file'); + Route::get('/account/export', [Shop::class, 'export'])->middleware('password.confirm')->name('privacy.export'); + Route::post('/account/deletion', [Shop::class, 'deletion'])->middleware('password.confirm')->name('privacy.delete'); + Route::get('/admin', fn () => view('admin'))->middleware([RequireAdmin::class, 'password.confirm'])->name('admin'); +}); + +Route::get('/media/{id}', function (int $id) { + $file = DB::table('media')->find($id); + abort_unless($file, 404); + + return Storage::disk('local')->response($file->path, null, ['Content-Type' => $file->mime, 'X-Content-Type-Options' => 'nosniff']); +})->name('media'); +Route::fallback(function (Request $r) { + $redirect = DB::table('redirects')->where('source', '/'.$r->path())->first(); + abort_unless($redirect, 404); + + return redirect($redirect->destination,301); }); diff --git a/appstore/website/tests/Feature/CommerceTest.php b/appstore/website/tests/Feature/CommerceTest.php index ff6ba78..78afd74 100644 --- a/appstore/website/tests/Feature/CommerceTest.php +++ b/appstore/website/tests/Feature/CommerceTest.php @@ -1,63 +1,298 @@ seed();Notification::fake();config(['tarnax.paddle.webhook_secret'=>'test-secret','tarnax.paddle.price_id'=>'pri_test']);} - private function customer(array $attributes=[]): User {return User::factory()->create(['account_token'=>(string)Str::uuid(),...$attributes]);} - private function event(User $user,string $id='evt_1'): array {return ['event_id'=>$id,'event_type'=>'transaction.completed','occurred_at'=>now()->toISOString(),'data'=>['id'=>'txn_1','status'=>'completed','custom_data'=>['account_token'=>$user->account_token],'items'=>[['price'=>['id'=>'pri_test'],'quantity'=>1]],'details'=>['totals'=>['grand_total'=>'4900']],'currency_code'=>'USD']];} - private function deliver(array $event,?int $timestamp=null,?string $signature=null) { - $body=json_encode($event);$ts=$timestamp??time();$sig=$signature??hash_hmac('sha256',$ts.':'.$body,'test-secret'); - return $this->call('POST','/api/webhooks/paddle',[],[],[],['CONTENT_TYPE'=>'application/json','HTTP_ACCEPT'=>'application/json','HTTP_PADDLE_SIGNATURE'=>'ts='.$ts.';h1='.$sig],$body); - } - private function license(User $user): object {$this->deliver($this->event($user))->assertOk();return DB::table('licenses')->first();} - public function test_public_pages_and_authentication_views_render(): void {foreach(['/','/releases','/pages/privacy','/pages/terms','/pages/features','/login','/register','/forgot-password'] as $url){$this->get($url)->assertOk();}$this->get('/account')->assertRedirect('/login');} - public function test_registration_cannot_grant_administration(): void {$this->post('/register',['name'=>'Customer','email'=>'new@example.com','password'=>'StrongPass123!','password_confirmation'=>'StrongPass123!','is_admin'=>true])->assertRedirect();$this->assertFalse(User::where('email','new@example.com')->first()->is_admin);} - public function test_verified_payment_issues_only_one_license_on_retries(): void {$user=$this->customer();$event=$this->event($user);$this->deliver($event)->assertOk();$this->deliver($event)->assertOk();$event['event_id']='evt_2';$this->deliver($event)->assertOk();$this->assertDatabaseCount('purchases',1);$this->assertDatabaseCount('licenses',1);} - public function test_invalid_signature_and_stale_replay_never_grant_licenses(): void {$user=$this->customer();$this->deliver($this->event($user),null,'invalid')->assertUnauthorized();$this->deliver($this->event($user),time()-600)->assertUnauthorized();$this->assertDatabaseCount('licenses',0);} - public function test_wrong_product_does_not_grant_license(): void {$event=$this->event($this->customer());$event['data']['items'][0]['price']['id']='pri_other';$this->deliver($event)->assertOk();$this->assertDatabaseCount('licenses',0);} - public function test_unknown_customer_rolls_back_event_for_retry(): void {$event=$this->event($this->customer());$event['data']['custom_data']['account_token']=(string)Str::uuid();$this->deliver($event)->assertUnprocessable();$this->assertDatabaseCount('webhook_events',0);} - public function test_activation_limits_validation_and_deactivation(): void { - $license=$this->license($this->customer());$key=Crypt::decryptString($license->key_encrypted); - $token=$this->postJson('/api/v1/licenses/activate',['license_key'=>$key,'device_id'=>str_repeat('a',20)])->assertOk()->json('activation_token'); - $this->postJson('/api/v1/licenses/activate',['license_key'=>$key,'device_id'=>str_repeat('b',20)])->assertOk(); - $this->postJson('/api/v1/licenses/activate',['license_key'=>$key,'device_id'=>str_repeat('c',20)])->assertConflict(); - $this->withToken($token)->postJson('/api/v1/licenses/validate')->assertOk()->assertJson(['valid'=>true]); - $this->withToken($token)->postJson('/api/v1/licenses/deactivate')->assertOk(); - $this->withToken($token)->postJson('/api/v1/licenses/validate')->assertForbidden(); - } - public function test_refund_revokes_license_and_prevents_regrant(): void { - $user=$this->customer();$license=$this->license($user);$key=Crypt::decryptString($license->key_encrypted);$token=app(LicenseService::class)->activate($key,str_repeat('a',20))['activation_token']; - $refund=['event_id'=>'evt_refund','event_type'=>'adjustment.updated','occurred_at'=>now()->addSecond()->toISOString(),'data'=>['status'=>'approved','action'=>'refund','transaction_id'=>'txn_1']];$this->deliver($refund)->assertOk(); - $this->withToken($token)->postJson('/api/v1/licenses/validate')->assertForbidden();$this->deliver($this->event($user,'evt_late'))->assertOk();$this->assertDatabaseHas('licenses',['status'=>'revoked']); - } - public function test_checkout_uses_server_selected_product_and_customer(): void {$user=$this->customer();config(['tarnax.paddle.api_key'=>'secret','tarnax.paddle.client_token'=>'token']);Http::fake(['*'=>Http::response(['data'=>['id'=>'txn_checkout']])]);$this->actingAs($user)->postJson('/checkout',['price_id'=>'attacker','custom_data'=>['account_token'=>'other']])->assertOk()->assertJson(['transaction_id'=>'txn_checkout']);Http::assertSent(fn($r)=>$r['items'][0]['price_id']==='pri_test' && $r['custom_data']['account_token']===$user->account_token);} - public function test_unconfigured_checkout_fails_closed(): void {config(['tarnax.paddle.api_key'=>null]);$this->actingAs($this->customer())->postJson('/checkout')->assertStatus(503);} - public function test_downloads_require_license_signature_and_matching_customer(): void { - Storage::fake('local');Storage::disk('local')->put('releases/app.zip','binary');$release=DB::table('releases')->insertGetId(['version'=>'1.0.0','channel'=>'stable','platform'=>'macOS','notes'=>'Release','path'=>'releases/app.zip','sha256'=>hash('sha256','binary'),'bytes'=>6,'published'=>true,'created_at'=>now(),'updated_at'=>now()]); - $user=$this->customer();$other=$this->customer();$this->actingAs($user)->get('/downloads/'.$release)->assertForbidden();$this->license($user); - $this->get('/downloads/'.$release)->assertRedirect();$url=URL::temporarySignedRoute('download.file',now()->addMinutes(5),['release'=>$release,'user'=>$user->id]);$this->get($url)->assertOk();$this->actingAs($other)->get($url)->assertForbidden(); - $expired=URL::temporarySignedRoute('download.file',now()->subMinute(),['release'=>$release,'user'=>$user->id]);$this->actingAs($user)->get($expired)->assertForbidden(); - } - public function test_support_is_scoped_and_does_not_accept_another_ticket(): void {$owner=$this->customer();$other=$this->customer();$ticket=DB::table('tickets')->insertGetId(['user_id'=>$owner->id,'subject'=>'Private conversation','status'=>'open','created_at'=>now(),'updated_at'=>now()]);Livewire::actingAs($other)->test(SupportDesk::class)->assertDontSee('Private conversation')->call('select',$ticket)->assertForbidden();} - public function test_customer_can_create_support_ticket(): void {Livewire::actingAs($this->customer())->test(SupportDesk::class)->set('subject','Connection help')->set('body','Please help me connect my server.')->call('create')->assertHasNoErrors();$this->assertDatabaseCount('tickets',1);$this->assertDatabaseCount('ticket_messages',1);} - public function test_admin_requires_role_and_confirmed_two_factor(): void {$this->actingAs($this->customer())->get('/admin')->assertForbidden();$admin=$this->customer(['is_admin'=>true]);$this->actingAs($admin)->get('/admin')->assertRedirect('/account/security');Livewire::actingAs($admin)->test(Cms::class)->assertForbidden();} - public function test_admin_can_edit_cms_and_mutations_are_audited(): void {$admin=$this->customer(['is_admin'=>true,'two_factor_confirmed_at'=>now()]);Livewire::actingAs($admin)->test(Cms::class)->set('form',['slug'=>'new-page','title'=>'New page','body'=>'Safe content','published'=>true])->call('save')->assertHasNoErrors();$this->assertDatabaseHas('pages',['slug'=>'new-page']);$this->assertDatabaseCount('audit_logs',1);} - public function test_consent_records_explicit_choices(): void {$this->postJson('/consent',['analytics'=>false,'marketing'=>false])->assertOk();$this->assertDatabaseHas('consents',['analytics'=>false,'marketing'=>false]);} - public function test_apple_verification_fails_closed_without_root(): void {config(['tarnax.apple.root_certificate'=>null]);$this->actingAs($this->customer())->postJson('/account/apple',['signed_transaction'=>'untrusted.payload.signature'])->assertStatus(503);$this->assertDatabaseCount('purchases',0);} - public function test_account_support_and_security_render_for_verified_customer(): void {$this->actingAs($this->customer());foreach(['/account','/support','/account/security'] as $url){$this->get($url)->assertOk();}} - public function test_erasure_revokes_access_and_anonymises_profile(): void {$user=$this->customer();$this->license($user);$id=DB::table('privacy_requests')->insertGetId(['user_id'=>$user->id,'type'=>'deletion','status'=>'pending','created_at'=>now(),'updated_at'=>now()]);$admin=$this->customer(['is_admin'=>true,'two_factor_confirmed_at'=>now()]);Livewire::actingAs($admin)->test(Cms::class)->set('section','privacy_requests')->call('fulfillDeletion',$id)->assertHasNoErrors();$this->assertDatabaseHas('users',['id'=>$user->id,'name'=>'Deleted customer']);$this->assertDatabaseHas('licenses',['user_id'=>$user->id,'status'=>'revoked']);$this->assertDatabaseHas('privacy_requests',['id'=>$id,'status'=>'fulfilled']);} +use Tests\TestCase; + +class CommerceTest extends TestCase +{ + use RefreshDatabase; + + protected function setUp(): void + { + parent::setUp(); + $this->seed(); + Notification::fake(); + config(['tarnax.paddle.webhook_secret' => 'test-secret', 'tarnax.paddle.price_id' => 'pri_test']); + } + + private function customer(array $attributes = []): User + { + return User::factory()->create(['account_token' => (string) Str::uuid(), ...$attributes]); + } + + private function event(User $user, string $id = 'evt_1'): array + { + return ['event_id' => $id, 'event_type' => 'transaction.completed', 'occurred_at' => now()->toISOString(), 'data' => ['id' => 'txn_1', 'status' => 'completed', 'custom_data' => ['account_token' => $user->account_token], 'items' => [['price' => ['id' => 'pri_test'], 'quantity' => 1]], 'details' => ['totals' => ['grand_total' => '4900']], 'currency_code' => 'USD']]; + } + + private function deliver(array $event, ?int $timestamp = null, ?string $signature = null) + { + $body = json_encode($event); + $ts = $timestamp ?? time(); + $sig = $signature ?? hash_hmac('sha256', $ts.':'.$body, 'test-secret'); + + return $this->call('POST', '/api/webhooks/paddle', [], [], [], ['CONTENT_TYPE' => 'application/json', 'HTTP_ACCEPT' => 'application/json', 'HTTP_PADDLE_SIGNATURE' => 'ts='.$ts.';h1='.$sig], $body); + } + + private function license(User $user): object + { + $this->deliver($this->event($user))->assertOk(); + + return DB::table('licenses')->first(); + } + + public function test_public_pages_and_authentication_views_render(): void + { + foreach (['/', '/releases', '/pages/privacy', '/pages/terms', '/pages/features', '/login', '/register', '/forgot-password'] as $url) { + $this->get($url)->assertOk(); + }$this->get('/account')->assertRedirect('/login'); + } + + public function test_registration_cannot_grant_administration(): void + { + $this->post('/register', ['name' => 'Customer', 'email' => 'new@example.com', 'password' => 'StrongPass123!', 'password_confirmation' => 'StrongPass123!', 'is_admin' => true])->assertRedirect(); + $this->assertFalse(User::where('email', 'new@example.com')->first()->is_admin); + } + + public function test_verified_payment_issues_only_one_license_on_retries(): void + { + $user = $this->customer(); + $event = $this->event($user); + $this->deliver($event)->assertOk(); + $this->deliver($event)->assertOk(); + $event['event_id'] = 'evt_2'; + $this->deliver($event)->assertOk(); + $this->assertDatabaseCount('purchases', 1); + $this->assertDatabaseCount('licenses', 1); + } + + public function test_invalid_signature_and_stale_replay_never_grant_licenses(): void + { + $user = $this->customer(); + $this->deliver($this->event($user), null, 'invalid')->assertUnauthorized(); + $this->deliver($this->event($user), time() - 600)->assertUnauthorized(); + $this->assertDatabaseCount('licenses', 0); + } + + public function test_wrong_product_does_not_grant_license(): void + { + $event = $this->event($this->customer()); + $event['data']['items'][0]['price']['id'] = 'pri_other'; + $this->deliver($event)->assertOk(); + $this->assertDatabaseCount('licenses', 0); + } + + public function test_unknown_customer_rolls_back_event_for_retry(): void + { + $event = $this->event($this->customer()); + $event['data']['custom_data']['account_token'] = (string) Str::uuid(); + $this->deliver($event)->assertUnprocessable(); + $this->assertDatabaseCount('webhook_events', 0); + } + + public function test_activation_limits_validation_and_deactivation(): void + { + $license = $this->license($this->customer()); + $key = Crypt::decryptString($license->key_encrypted); + $token = $this->postJson('/api/v1/licenses/activate', ['license_key' => $key, 'device_id' => str_repeat('a', 20)])->assertOk()->json('activation_token'); + $this->postJson('/api/v1/licenses/activate', ['license_key' => $key, 'device_id' => str_repeat('b', 20)])->assertOk(); + $this->postJson('/api/v1/licenses/activate', ['license_key' => $key, 'device_id' => str_repeat('c', 20)])->assertConflict(); + $this->withToken($token)->postJson('/api/v1/licenses/validate')->assertOk()->assertJson(['valid' => true]); + $this->withToken($token)->postJson('/api/v1/licenses/deactivate')->assertOk(); + $this->withToken($token)->postJson('/api/v1/licenses/validate')->assertForbidden(); + } + + public function test_refund_revokes_license_and_prevents_regrant(): void + { + $user = $this->customer(); + $license = $this->license($user); + $key = Crypt::decryptString($license->key_encrypted); + $token = app(LicenseService::class)->activate($key, str_repeat('a', 20))['activation_token']; + $refund = ['event_id' => 'evt_refund', 'event_type' => 'adjustment.updated', 'occurred_at' => now()->addSecond()->toISOString(), 'data' => ['status' => 'approved', 'action' => 'refund', 'transaction_id' => 'txn_1']]; + $this->deliver($refund)->assertOk(); + $this->withToken($token)->postJson('/api/v1/licenses/validate')->assertForbidden(); + $this->deliver($this->event($user, 'evt_late'))->assertOk(); + $this->assertDatabaseHas('licenses', ['status' => 'revoked']); + } + + public function test_checkout_uses_server_selected_product_and_customer(): void + { + $user = $this->customer(); + config(['tarnax.paddle.api_key' => 'secret', 'tarnax.paddle.client_token' => 'token']); + Http::fake(['*' => Http::response(['data' => ['id' => 'txn_checkout']])]); + $this->actingAs($user)->postJson('/checkout', ['price_id' => 'attacker', 'custom_data' => ['account_token' => 'other']])->assertOk()->assertJson(['transaction_id' => 'txn_checkout']); + Http::assertSent(fn ($r) => $r['items'][0]['price_id'] === 'pri_test' && $r['custom_data']['account_token'] === $user->account_token); + } + + public function test_unconfigured_checkout_fails_closed(): void + { + config(['tarnax.paddle.api_key' => null]); + $this->actingAs($this->customer())->postJson('/checkout')->assertStatus(503); + } + + public function test_downloads_require_license_signature_and_matching_customer(): void + { + Storage::fake('local'); + Storage::disk('local')->put('releases/app.zip', 'binary'); + $release = DB::table('releases')->insertGetId(['version' => '1.0.0', 'channel' => 'stable', 'platform' => 'macOS', 'notes' => 'Release', 'path' => 'releases/app.zip', 'sha256' => hash('sha256', 'binary'), 'bytes' => 6, 'published' => true, 'created_at' => now(), 'updated_at' => now()]); + $user = $this->customer(); + $other = $this->customer(); + $this->actingAs($user)->get('/downloads/'.$release)->assertForbidden(); + $this->license($user); + $this->get('/downloads/'.$release)->assertRedirect(); + $url = URL::temporarySignedRoute('download.file', now()->addMinutes(5), ['release' => $release, 'user' => $user->id]); + $this->get($url)->assertOk(); + $this->actingAs($other)->get($url)->assertForbidden(); + $expired = URL::temporarySignedRoute('download.file', now()->subMinute(), ['release' => $release, 'user' => $user->id]); + $this->actingAs($user)->get($expired)->assertForbidden(); + } + + public function test_support_is_scoped_and_does_not_accept_another_ticket(): void + { + $owner = $this->customer(); + $other = $this->customer(); + $ticket = DB::table('tickets')->insertGetId(['user_id' => $owner->id, 'subject' => 'Private conversation', 'status' => 'open', 'created_at' => now(), 'updated_at' => now()]); + Livewire::actingAs($other)->test(SupportDesk::class)->assertDontSee('Private conversation')->call('select', $ticket)->assertForbidden(); + } + + public function test_customer_can_create_support_ticket(): void + { + Livewire::actingAs($this->customer())->test(SupportDesk::class)->set('subject', 'Connection help')->set('body', 'Please help me connect my server.')->call('create')->assertHasNoErrors(); + $this->assertDatabaseCount('tickets', 1); + $this->assertDatabaseCount('ticket_messages', 1); + } + + public function test_admin_requires_role_and_confirmed_two_factor(): void + { + $this->actingAs($this->customer())->get('/admin')->assertForbidden(); + $admin = $this->customer(['is_admin' => true]); + $this->actingAs($admin)->get('/admin')->assertRedirect('/account/security'); + Livewire::actingAs($admin)->test(Cms::class)->assertForbidden(); + } + + public function test_admin_can_edit_cms_and_mutations_are_audited(): void + { + $admin = $this->customer(['is_admin' => true, 'two_factor_confirmed_at' => now()]); + Livewire::actingAs($admin)->test(Cms::class)->set('form', ['slug' => 'new-page', 'title' => 'New page', 'body' => 'Safe content', 'published' => true])->call('save')->assertHasNoErrors(); + $this->assertDatabaseHas('pages', ['slug' => 'new-page']); + $this->assertDatabaseCount('audit_logs', 1); + } + + public function test_consent_records_explicit_choices(): void + { + $this->postJson('/consent', ['analytics' => false, 'marketing' => false])->assertOk(); + $this->assertDatabaseHas('consents', ['analytics' => false, 'marketing' => false]); + } + + public function test_apple_verification_fails_closed_without_root(): void + { + config(['tarnax.apple.root_certificate' => null]); + $this->actingAs($this->customer())->postJson('/account/apple', ['signed_transaction' => 'untrusted.payload.signature'])->assertStatus(503); + $this->assertDatabaseCount('purchases', 0); + } + + public function test_account_support_and_security_render_for_verified_customer(): void + { + $this->actingAs($this->customer())->withSession(['auth.password_confirmed_at' => time()]); + foreach (['/account', '/support', '/account/security'] as $url) { + $this->get($url)->assertOk(); + } + } + + public function test_erasure_revokes_access_and_anonymises_profile(): void + { + $user = $this->customer(); + $this->license($user); + $id = DB::table('privacy_requests')->insertGetId(['user_id' => $user->id, 'type' => 'deletion', 'status' => 'pending', 'created_at' => now(), 'updated_at' => now()]); + $admin = $this->customer(['is_admin' => true, 'two_factor_confirmed_at' => now()]); + Livewire::actingAs($admin)->test(Cms::class)->set('section', 'privacy_requests')->call('fulfillDeletion', $id)->assertHasNoErrors(); + $this->assertDatabaseHas('users', ['id' => $user->id, 'name' => 'Deleted customer']); + $this->assertDatabaseHas('licenses', ['user_id' => $user->id, 'status' => 'revoked']); + $this->assertDatabaseHas('privacy_requests', ['id' => $id, 'status' => 'fulfilled']); + } + + public function test_refund_before_purchase_prevents_late_grant(): void + { + $user = $this->customer(); + $this->deliver(['event_id' => 'early_refund', 'event_type' => 'adjustment.created', 'occurred_at' => now()->toISOString(), 'data' => ['status' => 'approved', 'action' => 'refund', 'transaction_id' => 'txn_1']])->assertOk(); + $this->deliver($this->event($user))->assertOk(); + $this->assertDatabaseCount('licenses', 0); + } + + public function test_subscription_events_revoke_and_restore_and_ignore_stale_events(): void + { + $user = $this->customer(); + $event = $this->event($user); + $event['data']['subscription_id'] = 'sub_1'; + $this->deliver($event)->assertOk(); + $send = function ($id, $status, $seconds) { + return $this->deliver(['event_id' => $id, 'event_type' => 'subscription.updated', 'occurred_at' => now()->addSeconds($seconds)->toISOString(), 'data' => ['id' => 'sub_1', 'status' => $status]]); + }; + $send('pause', 'paused', 10)->assertOk(); + $this->assertDatabaseHas('licenses', ['status' => 'revoked']); + $send('resume', 'active', 20)->assertOk(); + $this->assertDatabaseHas('licenses', ['status' => 'active']); + $send('old_pause', 'paused', 5)->assertOk(); + $this->assertDatabaseHas('licenses', ['status' => 'active']); + } + + public function test_admin_can_manage_navigation_and_reject_external_redirects(): void + { + $admin = $this->customer(['is_admin' => true, 'two_factor_confirmed_at' => now()]); + Livewire::actingAs($admin)->test(Cms::class)->set('section', 'navigation')->set('form', ['label' => 'Features', 'path' => '/pages/features', 'position' => 1, 'published' => true])->call('saveResource')->assertHasNoErrors(); + $this->assertDatabaseHas('navigation', ['label' => 'Features']); + Livewire::actingAs($admin)->test(Cms::class)->set('section', 'redirects')->set('form', ['source' => '/old', 'destination' => '//evil.example'])->call('saveResource')->assertHasErrors('form.destination'); + } + + public function test_published_page_html_is_escaped(): void + { + DB::table('pages')->where('slug', 'features')->update(['body' => '']); + $this->get('/pages/features')->assertSee('<script>', false)->assertDontSee('', false); + } + + public function test_apple_notification_fails_closed_without_configuration(): void + { + config(['tarnax.apple.root_certificate' => null]); + $this->postJson('/api/webhooks/apple', ['signedPayload' => 'untrusted.payload.signature'])->assertStatus(503); + } + + public function test_erased_customer_cannot_use_an_old_activation_token(): void + { + $user = $this->customer(); + $license = $this->license($user); + $token = app(LicenseService::class)->activate(Crypt::decryptString($license->key_encrypted), str_repeat('x', 20))['activation_token']; + $id = DB::table('privacy_requests')->insertGetId(['user_id' => $user->id, 'type' => 'deletion', 'status' => 'pending', 'created_at' => now(), 'updated_at' => now()]); + $admin = $this->customer(['is_admin' => true, 'two_factor_confirmed_at' => now()]); + Livewire::actingAs($admin)->test(Cms::class)->set('section', 'privacy_requests')->call('fulfillDeletion', $id); + $this->withToken($token)->postJson('/api/v1/licenses/validate')->assertForbidden(); + } + + public function test_unverified_accounts_cannot_checkout_or_open_portal(): void + { + $user = $this->customer(['email_verified_at' => null]); + $this->actingAs($user)->get('/account')->assertRedirect('/email/verify'); + $this->postJson('/checkout')->assertForbidden(); + } + + public function test_private_media_is_safely_served(): void + { + Storage::fake('local'); + Storage::disk('local')->put('media/test.png','test image'); + $id = DB::table('media')->insertGetId(['title' => 'App', 'alt' => 'Icon', 'path' => 'media/test.png', 'mime' => 'image/png', 'bytes' => 10, 'created_at' => now(), 'updated_at' => now()]); + $this->get('/media/'.$id)->assertOk()->assertHeader('X-Content-Type-Options','nosniff'); + } } diff --git a/appstore/website/tests/Feature/ExampleTest.php b/appstore/website/tests/Feature/ExampleTest.php index 228f4c0..588cb70 100644 --- a/appstore/website/tests/Feature/ExampleTest.php +++ b/appstore/website/tests/Feature/ExampleTest.php @@ -3,11 +3,13 @@ namespace Tests\Feature; // use Illuminate\Foundation\Testing\RefreshDatabase; +use Illuminate\Foundation\Testing\RefreshDatabase; use Tests\TestCase; class ExampleTest extends TestCase { - use \Illuminate\Foundation\Testing\RefreshDatabase; + use RefreshDatabase; + /** * A basic test example. */ diff --git a/docs/VALIDATION.md b/docs/VALIDATION.md index 7369e94..fa4bd09 100644 --- a/docs/VALIDATION.md +++ b/docs/VALIDATION.md @@ -90,3 +90,18 @@ Added bounded session history to dashboard refreshes, with 1-, 5- and 15-minute Build 5 crashed on the physical iPhone: the retrieved report and matching executable identified a null weak-linked Foundation Date >= call in chart filtering. Build 6 uses numeric timestamp comparisons instead; inspection confirmed no weak-linked Date >= or <= entry points remain. User confirmed Home and graph work on the iPhone after build 6 installation. Build 7 adds blue/indigo/violet download strokes, teal dashed upload strokes, independent translucent gradient fills, endpoint/selection dots, a soft plot background and coordinated speed values. Signed Release build passed and binary compatibility inspection passed. Installed on the iPhone; automatic launch was blocked because the phone was locked. No new network or sampling behavior was introduced by the colour change. + +## Accumulated iOS fixes — 5 November 2026 + +- Retained session-owned, deduplicated Settings loading and root-owned dashboard polling. Tightened cancellation reporting: retired sessions and explicit Swift task cancellation stay silent; URLError.cancelled is silent only when its task is cancelled. Unexpected cancellation, timeout, HTTP and decoding failures remain visible. +- Timestamped upload/download history now uses an atomic Codable archive in Application Support, scoped to the configured server and username. Restored during model initialization and connection; rolling 24-hour pruning, bounded one-second sampling, and segment breaks after missing measurements. Failed refreshes never fabricate data. Forget credentials removes the local archive. Persistence failures are visible on Home/Library. Default chart window is 24 hours. +- Completed/seeding torrents use green progress in the shared Library/detail row; completed files also use green bars. Home retains its original downloading-only state filter. +- Detail action controls use ViewThatFits to wrap vertically on narrow layouts or large text. Existing advanced actions remain in More. Native bordered buttons give pressed feedback; per-torrent pending state prevents duplicate actions and shows progress, success and failure. Inapplicable pause/resume and completed download-priority actions are disabled. Torrent deletion and tracker removal require confirmation. Mutation success is returned directly instead of inferred from an unrelated global alert. +- Explicit unit metadata formats known server/detail statistics and preference summaries as readable durations, sizes and speeds. Editors preserve raw values and state the input units. Unknown fields remain raw rather than guessing from their names. Seeding limits use minutes; slow-torrent inactivity uses seconds; refresh interval uses milliseconds; cache uses MiB. Checked against qBittorrent API documentation and AppController source. +- Added opt-in local completion notifications in Settings, requesting system permission and presenting foreground banners/sound. Only an observed incomplete-to-complete transition alerts; initial existing completions do not flood notifications. No background polling entitlement or server push was added: suspended apps detect completion on their next foreground refresh; killed-app completions are not guaranteed notifications. Real device notification delivery remains to be verified. +- Credentials remain in the existing Keychain service; HTTPS transport, private cookies, server configuration and existing qBittorrent endpoints are preserved. +- Temporary build/test files and logs are under ~/Developer/Scratch/Tarnax per user instruction. Unrelated website changes were left untouched. + +Device acceptance still required: narrow iPhone/iPad widths and largest Dynamic Type, live server actions, Settings navigation under poor connectivity, background/relaunch graph restoration, notification permission denial and successful completion delivery. No signed deployment performed for this update. + +Final verification: unsigned generic iOS device Debug build **BUILD SUCCEEDED**; **91 executable model/transport/persistence/formatting contract checks passed**, including Settings view cancellation and real-error reporting; three provisioning-profile tests passed; project/Info.plist validation and diff whitespace checks passed. Logs: ~/Developer/Scratch/Tarnax/build-verified.log and tests-verified.log. Physical-device and live-server verification remain outstanding.