Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eac5910dbb | ||
|
|
736765180c | ||
|
|
6bb62ebf69 | ||
|
|
211115c9bc |
@@ -0,0 +1,25 @@
|
||||
import Foundation
|
||||
|
||||
/// Cross-platform issue filter used by the shared SonarDesk application state.
|
||||
/// UI-specific colours/icons belong in the platform view layer.
|
||||
enum IssueFilter: String, CaseIterable, Identifiable, Hashable {
|
||||
case all = "All Issues"
|
||||
case bugs = "Bugs"
|
||||
case vulnerabilities = "Vulnerabilities"
|
||||
case codeSmells = "Code Smells"
|
||||
|
||||
var id: String { rawValue }
|
||||
|
||||
var apiType: String? {
|
||||
switch self {
|
||||
case .all:
|
||||
return nil
|
||||
case .bugs:
|
||||
return "BUG"
|
||||
case .vulnerabilities:
|
||||
return "VULNERABILITY"
|
||||
case .codeSmells:
|
||||
return "CODE_SMELL"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import Foundation
|
||||
|
||||
struct SonarServer: Codable, Identifiable, Hashable {
|
||||
var id = UUID()
|
||||
var name: String
|
||||
var baseURL: String
|
||||
}
|
||||
|
||||
struct Project: Codable, Identifiable, Hashable {
|
||||
var id: String { key }
|
||||
let key: String
|
||||
let name: String
|
||||
let qualifier: String?
|
||||
let visibility: String?
|
||||
let lastAnalysisDate: String?
|
||||
}
|
||||
|
||||
struct ProjectsResponse: Codable {
|
||||
let components: [Project]
|
||||
let paging: Paging?
|
||||
}
|
||||
|
||||
struct Paging: Codable { let pageIndex: Int?; let pageSize: Int?; let total: Int? }
|
||||
|
||||
struct Measure: Codable, Hashable { let metric: String; let value: String?; let bestValue: Bool? }
|
||||
struct MeasuresComponent: Codable { let key: String; let name: String; let measures: [Measure] }
|
||||
struct MeasuresResponse: Codable { let component: MeasuresComponent }
|
||||
|
||||
struct QualityGateResponse: Codable { let projectStatus: ProjectStatus }
|
||||
struct ProjectStatus: Codable { let status: String; let conditions: [GateCondition]? }
|
||||
struct GateCondition: Codable, Hashable {
|
||||
let status: String
|
||||
let metricKey: String
|
||||
let comparator: String?
|
||||
let errorThreshold: String?
|
||||
let actualValue: String?
|
||||
}
|
||||
|
||||
struct Issue: Codable, Identifiable, Hashable {
|
||||
var id: String { key }
|
||||
let key: String
|
||||
let rule: String?
|
||||
let severity: String?
|
||||
let component: String?
|
||||
let project: String?
|
||||
let line: Int?
|
||||
let message: String?
|
||||
let type: String?
|
||||
let status: String?
|
||||
let creationDate: String?
|
||||
let effort: String?
|
||||
}
|
||||
|
||||
struct IssuesResponse: Codable { let total: Int?; let p: Int?; let ps: Int?; let issues: [Issue] }
|
||||
|
||||
struct ServerStatusResponse: Codable { let id: String?; let version: String?; let status: String? }
|
||||
|
||||
struct ProjectSnapshot: Identifiable {
|
||||
var id: String { project.key }
|
||||
let project: Project
|
||||
var measures: [String: String] = [:]
|
||||
var qualityGate: String = "UNKNOWN"
|
||||
}
|
||||
|
||||
struct TokenGenerateResponse: Codable {
|
||||
let login: String?
|
||||
let name: String?
|
||||
let token: String
|
||||
let createdAt: String?
|
||||
let type: String?
|
||||
}
|
||||
|
||||
struct CreatedProjectCredentials: Identifiable {
|
||||
let id = UUID()
|
||||
let projectKey: String
|
||||
let projectName: String
|
||||
let tokenName: String
|
||||
let token: String
|
||||
let serverURL: String
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
import Foundation
|
||||
#if os(macOS)
|
||||
import AppKit
|
||||
#endif
|
||||
|
||||
/// SonarDesk's user-visible iCloud Drive document store.
|
||||
/// Tokens remain in iCloud Keychain and are never written to this folder.
|
||||
final class ICloudDriveStore {
|
||||
static let shared = ICloudDriveStore()
|
||||
static let containerIdentifier = "iCloud.net.absdev.SonarDesk"
|
||||
|
||||
enum StoreError: LocalizedError {
|
||||
case unavailable
|
||||
case notDownloaded
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .unavailable:
|
||||
return "SonarDesk cannot access its iCloud Drive container. Check iCloud Drive and the SonarDesk iCloud capability/signing."
|
||||
case .notDownloaded:
|
||||
return "The SonarDesk iCloud Drive folder is not currently available on this Mac."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct Status {
|
||||
let available: Bool
|
||||
let containerIdentifier: String
|
||||
let documentsURL: URL?
|
||||
let message: String
|
||||
}
|
||||
|
||||
private let fm = FileManager.default
|
||||
private init() {}
|
||||
|
||||
/// Returns the public Documents scope of the SonarDesk ubiquity container.
|
||||
/// No local fallback is permitted: persistent SonarDesk files are iCloud-only.
|
||||
func root(create: Bool = true) throws -> URL {
|
||||
guard let container = fm.url(forUbiquityContainerIdentifier: Self.containerIdentifier) else {
|
||||
throw StoreError.unavailable
|
||||
}
|
||||
|
||||
let documents = container.appendingPathComponent("Documents", isDirectory: true)
|
||||
if create {
|
||||
try createHierarchy(at: documents)
|
||||
// Touch a harmless marker so the public document scope is materialised immediately.
|
||||
let marker = documents.appendingPathComponent(".sonardesk", isDirectory: false)
|
||||
if !fm.fileExists(atPath: marker.path) {
|
||||
try Data("SonarDesk iCloud Drive data store\n".utf8).write(to: marker, options: .atomic)
|
||||
}
|
||||
}
|
||||
return documents
|
||||
}
|
||||
|
||||
func status() -> Status {
|
||||
guard let container = fm.url(forUbiquityContainerIdentifier: Self.containerIdentifier) else {
|
||||
return Status(available: false,
|
||||
containerIdentifier: Self.containerIdentifier,
|
||||
documentsURL: nil,
|
||||
message: "Unavailable — the signed app has no access to its iCloud Drive container.")
|
||||
}
|
||||
let documents = container.appendingPathComponent("Documents", isDirectory: true)
|
||||
do {
|
||||
try createHierarchy(at: documents)
|
||||
return Status(available: true,
|
||||
containerIdentifier: Self.containerIdentifier,
|
||||
documentsURL: documents,
|
||||
message: "Connected")
|
||||
} catch {
|
||||
return Status(available: false,
|
||||
containerIdentifier: Self.containerIdentifier,
|
||||
documentsURL: documents,
|
||||
message: error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
func dataDirectory(create: Bool = true) throws -> URL { try directory("Data", create: create) }
|
||||
func reportsDirectory(create: Bool = true) throws -> URL { try directory("Reports", create: create) }
|
||||
func backupsDirectory(create: Bool = true) throws -> URL { try directory("Backups", create: create) }
|
||||
func historyDirectory(create: Bool = true) throws -> URL { try directory("Data/history", create: create) }
|
||||
func exportsDirectory(_ name: String, create: Bool = true) throws -> URL { try directory("Reports/\(name)", create: create) }
|
||||
|
||||
private func directory(_ relative: String, create: Bool) throws -> URL {
|
||||
let url = try root(create: create).appendingPathComponent(relative, isDirectory: true)
|
||||
if create { try fm.createDirectory(at: url, withIntermediateDirectories: true) }
|
||||
return url
|
||||
}
|
||||
|
||||
func read<T: Decodable>(_ type: T.Type, fileName: String) throws -> T? {
|
||||
let url = try dataDirectory().appendingPathComponent(fileName)
|
||||
guard fm.fileExists(atPath: url.path) else { return nil }
|
||||
try ensureDownloaded(url)
|
||||
let coordinator = NSFileCoordinator(filePresenter: nil)
|
||||
var coordinationError: NSError?
|
||||
var result: Result<T, Error>?
|
||||
coordinator.coordinate(readingItemAt: url, options: [], error: &coordinationError) { coordinatedURL in
|
||||
result = Result {
|
||||
let data = try Data(contentsOf: coordinatedURL, options: [.mappedIfSafe])
|
||||
let decoder = JSONDecoder()
|
||||
decoder.dateDecodingStrategy = .iso8601
|
||||
return try decoder.decode(type, from: data)
|
||||
}
|
||||
}
|
||||
if let coordinationError { throw coordinationError }
|
||||
return try result?.get()
|
||||
}
|
||||
|
||||
func write<T: Encodable>(_ value: T, fileName: String) throws {
|
||||
let url = try dataDirectory().appendingPathComponent(fileName)
|
||||
let encoder = JSONEncoder()
|
||||
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
|
||||
encoder.dateEncodingStrategy = .iso8601
|
||||
let data = try encoder.encode(value)
|
||||
try coordinatedAtomicWrite(data, to: url, backupName: fileName)
|
||||
}
|
||||
|
||||
func coordinatedAtomicWrite(_ data: Data, to url: URL, backupName: String? = nil) throws {
|
||||
let coordinator = NSFileCoordinator(filePresenter: nil)
|
||||
var coordinationError: NSError?
|
||||
var writeError: Error?
|
||||
coordinator.coordinate(writingItemAt: url, options: .forReplacing, error: &coordinationError) { coordinatedURL in
|
||||
do {
|
||||
if fm.fileExists(atPath: coordinatedURL.path), let backupName {
|
||||
let backupDir = try backupsDirectory()
|
||||
let stamp = ISO8601DateFormatter().string(from: Date()).replacingOccurrences(of: ":", with: "-")
|
||||
let backup = backupDir.appendingPathComponent("\(stamp)-\(backupName)")
|
||||
try? fm.copyItem(at: coordinatedURL, to: backup)
|
||||
pruneBackups(named: backupName, in: backupDir)
|
||||
}
|
||||
try data.write(to: coordinatedURL, options: [.atomic, .completeFileProtectionUnlessOpen])
|
||||
} catch { writeError = error }
|
||||
}
|
||||
if let coordinationError { throw coordinationError }
|
||||
if let writeError { throw writeError }
|
||||
}
|
||||
|
||||
private func ensureDownloaded(_ url: URL) throws {
|
||||
let values = try url.resourceValues(forKeys: [.isUbiquitousItemKey, .ubiquitousItemDownloadingStatusKey])
|
||||
guard values.isUbiquitousItem == true else { return }
|
||||
|
||||
let status = values.ubiquitousItemDownloadingStatus
|
||||
if status != .current && status != .downloaded {
|
||||
try fm.startDownloadingUbiquitousItem(at: url)
|
||||
throw StoreError.notDownloaded
|
||||
}
|
||||
}
|
||||
|
||||
private func pruneBackups(named fileName: String, in directory: URL) {
|
||||
guard let files = try? fm.contentsOfDirectory(at: directory, includingPropertiesForKeys: [.contentModificationDateKey]) else { return }
|
||||
let matching = files.filter { $0.lastPathComponent.hasSuffix("-\(fileName)") }.sorted {
|
||||
let l = (try? $0.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate) ?? .distantPast
|
||||
let r = (try? $1.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate) ?? .distantPast
|
||||
return l > r
|
||||
}
|
||||
for old in matching.dropFirst(10) { try? fm.removeItem(at: old) }
|
||||
}
|
||||
|
||||
func revealRoot() throws {
|
||||
#if os(macOS)
|
||||
let url = try root()
|
||||
NSWorkspace.shared.open(url)
|
||||
#endif
|
||||
}
|
||||
|
||||
private func createHierarchy(at root: URL) throws {
|
||||
try fm.createDirectory(at: root, withIntermediateDirectories: true)
|
||||
for relative in ["Data", "Data/history", "Reports", "Reports/PDF", "Reports/CSV", "Reports/JSON", "Backups"] {
|
||||
try fm.createDirectory(at: root.appendingPathComponent(relative, isDirectory: true), withIntermediateDirectories: true)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import Foundation
|
||||
import Security
|
||||
|
||||
enum KeychainService {
|
||||
static let service = "net.absdev.SonarDesk"
|
||||
|
||||
static func saveToken(_ token: String, serverID: UUID) throws {
|
||||
try saveLocal(token, account: serverID.uuidString)
|
||||
}
|
||||
|
||||
static func token(serverID: UUID) -> String? {
|
||||
readWithLegacyMigration(account: serverID.uuidString)
|
||||
}
|
||||
|
||||
static func saveProjectToken(_ token: String, serverID: UUID, projectKey: String) throws {
|
||||
try saveLocal(token, account: "project|\(serverID.uuidString)|\(projectKey)")
|
||||
}
|
||||
|
||||
static func projectToken(serverID: UUID, projectKey: String) -> String? {
|
||||
readWithLegacyMigration(account: "project|\(serverID.uuidString)|\(projectKey)")
|
||||
}
|
||||
|
||||
static func deleteToken(serverID: UUID) {
|
||||
deleteAll(account: serverID.uuidString)
|
||||
}
|
||||
|
||||
private static func query(account: String, synchronizable: CFTypeRef) -> [String: Any] {
|
||||
[kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: service,
|
||||
kSecAttrAccount as String: account,
|
||||
kSecAttrSynchronizable as String: synchronizable]
|
||||
}
|
||||
|
||||
private static func saveLocal(_ value: String, account: String) throws {
|
||||
let clean = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !clean.isEmpty else { throw KeychainError.emptyToken }
|
||||
let data = Data(clean.utf8)
|
||||
let localQuery = query(account: account, synchronizable: kCFBooleanFalse)
|
||||
let updateStatus = SecItemUpdate(localQuery as CFDictionary,
|
||||
[kSecValueData as String: data] as CFDictionary)
|
||||
if updateStatus == errSecSuccess { return }
|
||||
guard updateStatus == errSecItemNotFound else { throw KeychainError.status(updateStatus) }
|
||||
|
||||
var add = localQuery
|
||||
add[kSecValueData as String] = data
|
||||
add[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
|
||||
let status = SecItemAdd(add as CFDictionary, nil)
|
||||
guard status == errSecSuccess else { throw KeychainError.status(status) }
|
||||
}
|
||||
|
||||
private static func readWithLegacyMigration(account: String) -> String? {
|
||||
if let local = read(account: account, synchronizable: kCFBooleanFalse) { return local }
|
||||
guard let legacy = read(account: account, synchronizable: kSecAttrSynchronizableAny) else { return nil }
|
||||
// Best-effort migration from older iCloud/synchronizable Keychain entries.
|
||||
try? saveLocal(legacy, account: account)
|
||||
return legacy
|
||||
}
|
||||
|
||||
private static func read(account: String, synchronizable: CFTypeRef) -> String? {
|
||||
var lookup = query(account: account, synchronizable: synchronizable)
|
||||
lookup[kSecReturnData as String] = true
|
||||
lookup[kSecMatchLimit as String] = kSecMatchLimitOne
|
||||
var item: CFTypeRef?
|
||||
guard SecItemCopyMatching(lookup as CFDictionary, &item) == errSecSuccess,
|
||||
let data = item as? Data,
|
||||
let value = String(data: data, encoding: .utf8),
|
||||
!value.isEmpty else { return nil }
|
||||
return value
|
||||
}
|
||||
|
||||
private static func deleteAll(account: String) {
|
||||
SecItemDelete(query(account: account, synchronizable: kSecAttrSynchronizableAny) as CFDictionary)
|
||||
}
|
||||
}
|
||||
|
||||
enum KeychainError: LocalizedError {
|
||||
case emptyToken
|
||||
case status(OSStatus)
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .emptyToken: return "The SonarQube token cannot be empty."
|
||||
case .status(let status):
|
||||
return SecCopyErrorMessageString(status, nil) as String? ?? "Keychain error (\(status))."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension KeychainService {
|
||||
private static let licenseTokenAccount = "sonardesk.license-token"
|
||||
|
||||
static func saveLicenseToken(_ token: String) throws {
|
||||
try saveLocal(token, account: licenseTokenAccount)
|
||||
}
|
||||
|
||||
static func licenseToken() -> String? {
|
||||
readWithLegacyMigration(account: licenseTokenAccount)
|
||||
}
|
||||
|
||||
static func deleteLicenseToken() {
|
||||
deleteAll(account: licenseTokenAccount)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
import Foundation
|
||||
|
||||
actor SonarQubeClient {
|
||||
let server: SonarServer
|
||||
private let token: String
|
||||
private let session: URLSession
|
||||
|
||||
init(server: SonarServer, token: String) {
|
||||
self.server = server
|
||||
self.token = token
|
||||
let config = URLSessionConfiguration.ephemeral
|
||||
config.timeoutIntervalForRequest = 30
|
||||
config.timeoutIntervalForResource = 60
|
||||
config.requestCachePolicy = .reloadIgnoringLocalCacheData
|
||||
config.urlCache = nil
|
||||
config.httpCookieStorage = nil
|
||||
config.httpShouldSetCookies = false
|
||||
config.httpAdditionalHeaders = ["Accept": "application/json"]
|
||||
self.session = URLSession(configuration: config)
|
||||
}
|
||||
|
||||
func serverStatus() async throws -> ServerStatusResponse { try await get("api/system/status") }
|
||||
|
||||
func projects(page: Int = 1, pageSize: Int = 100) async throws -> ProjectsResponse {
|
||||
try await get("api/components/search", query: ["qualifiers":"TRK", "p":"\(page)", "ps":"\(pageSize)"])
|
||||
}
|
||||
|
||||
func measures(projectKey: String) async throws -> MeasuresResponse {
|
||||
let metrics = ["bugs","vulnerabilities","code_smells","security_hotspots","coverage","duplicated_lines_density","ncloc","complexity","reliability_rating","security_rating","sqale_rating"].joined(separator: ",")
|
||||
return try await get("api/measures/component", query: ["component": projectKey, "metricKeys": metrics])
|
||||
}
|
||||
|
||||
func qualityGate(projectKey: String) async throws -> QualityGateResponse {
|
||||
try await get("api/qualitygates/project_status", query: ["projectKey": projectKey])
|
||||
}
|
||||
|
||||
func createProject(name: String, key: String, visibility: String? = nil) async throws {
|
||||
var fields = ["name": name, "project": key]
|
||||
if let visibility, !visibility.isEmpty { fields["visibility"] = visibility }
|
||||
try await postForm("api/projects/create", fields: fields)
|
||||
}
|
||||
|
||||
func generateProjectAnalysisToken(projectKey: String, tokenName: String) async throws -> TokenGenerateResponse {
|
||||
try await postForm("api/user_tokens/generate", fields: [
|
||||
"name": tokenName,
|
||||
"type": "PROJECT_ANALYSIS_TOKEN",
|
||||
"projectKey": projectKey
|
||||
])
|
||||
}
|
||||
|
||||
func issues(projectKey: String? = nil, type: String? = nil, page: Int = 1, pageSize: Int = 25) async throws -> IssuesResponse {
|
||||
var q = ["p":"\(page)", "ps":"\(pageSize)", "resolved":"false", "s":"CREATION_DATE", "asc":"false"]
|
||||
if let projectKey { q["componentKeys"] = projectKey }
|
||||
if let type { q["types"] = type }
|
||||
return try await get("api/issues/search", query: q)
|
||||
}
|
||||
|
||||
private func postForm<T: Decodable>(_ path: String, fields: [String: String]) async throws -> T {
|
||||
let data = try await postFormData(path, fields: fields)
|
||||
do { return try JSONDecoder().decode(T.self, from: data) }
|
||||
catch { throw APIError.decoding(error.localizedDescription) }
|
||||
}
|
||||
|
||||
private func postForm(_ path: String, fields: [String: String]) async throws {
|
||||
_ = try await postFormData(path, fields: fields)
|
||||
}
|
||||
|
||||
private func postFormData(_ path: String, fields: [String: String]) async throws -> Data {
|
||||
let base = server.baseURL.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
|
||||
guard let url = URL(string: base + "/" + path) else { throw APIError.invalidURL }
|
||||
var request = URLRequest(url: url)
|
||||
request.httpMethod = "POST"
|
||||
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
|
||||
request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type")
|
||||
request.setValue("application/json", forHTTPHeaderField: "Accept")
|
||||
var components = URLComponents()
|
||||
components.queryItems = fields.sorted(by: { $0.key < $1.key }).map { URLQueryItem(name: $0.key, value: $0.value) }
|
||||
request.httpBody = components.percentEncodedQuery?.data(using: .utf8)
|
||||
let (data, response) = try await session.data(for: request)
|
||||
guard let http = response as? HTTPURLResponse else { throw APIError.invalidResponse }
|
||||
if (200...299).contains(http.statusCode) { return data }
|
||||
if http.statusCode == 401 || http.statusCode == 403 { throw APIError.authentication }
|
||||
if let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
||||
let errors = object["errors"] as? [[String: Any]],
|
||||
let message = errors.first?["msg"] as? String { throw APIError.serverMessage(message) }
|
||||
throw APIError.http(http.statusCode)
|
||||
}
|
||||
|
||||
private func get<T: Decodable>(_ path: String, query: [String:String] = [:]) async throws -> T {
|
||||
let base = server.baseURL.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
|
||||
guard var components = URLComponents(string: base + "/" + path),
|
||||
let scheme = components.scheme?.lowercased(),
|
||||
scheme == "https" || (scheme == "http" && Self.isLocalDevelopmentHost(components.host)) else {
|
||||
throw APIError.insecureURL
|
||||
}
|
||||
if !query.isEmpty { components.queryItems = query.sorted(by: { $0.key < $1.key }).map { URLQueryItem(name: $0.key, value: $0.value) } }
|
||||
guard let url = components.url else { throw APIError.invalidURL }
|
||||
|
||||
var request = URLRequest(url: url)
|
||||
request.httpMethod = "GET"
|
||||
request.cachePolicy = .reloadIgnoringLocalCacheData
|
||||
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
|
||||
request.setValue("application/json", forHTTPHeaderField: "Accept")
|
||||
|
||||
var lastError: Error?
|
||||
for attempt in 0..<3 {
|
||||
do {
|
||||
let (data, response) = try await session.data(for: request)
|
||||
guard let http = response as? HTTPURLResponse else { throw APIError.invalidResponse }
|
||||
if (200...299).contains(http.statusCode) {
|
||||
do { return try JSONDecoder().decode(T.self, from: data) }
|
||||
catch { throw APIError.decoding(error.localizedDescription) }
|
||||
}
|
||||
if http.statusCode == 401 || http.statusCode == 403 { throw APIError.authentication }
|
||||
guard (http.statusCode == 429 || (500...599).contains(http.statusCode)), attempt < 2 else {
|
||||
throw APIError.http(http.statusCode)
|
||||
}
|
||||
let delay = UInt64(500_000_000 * (attempt + 1))
|
||||
try await Task.sleep(nanoseconds: delay)
|
||||
} catch is CancellationError {
|
||||
throw CancellationError()
|
||||
} catch let error as APIError {
|
||||
switch error {
|
||||
case .authentication, .decoding, .invalidResponse, .invalidURL, .insecureURL: throw error
|
||||
default: lastError = error
|
||||
}
|
||||
} catch {
|
||||
lastError = error
|
||||
guard attempt < 2 else { break }
|
||||
try await Task.sleep(nanoseconds: UInt64(500_000_000 * (attempt + 1)))
|
||||
}
|
||||
}
|
||||
throw lastError ?? APIError.invalidResponse
|
||||
}
|
||||
|
||||
private static func isLocalDevelopmentHost(_ host: String?) -> Bool {
|
||||
guard let host = host?.lowercased() else { return false }
|
||||
return host == "localhost" || host == "127.0.0.1" || host == "::1"
|
||||
}
|
||||
}
|
||||
|
||||
enum APIError: LocalizedError {
|
||||
case invalidURL, insecureURL, invalidResponse, authentication, http(Int), decoding(String), notConfigured, serverMessage(String)
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .invalidURL: return "The server URL is invalid."
|
||||
case .insecureURL: return "Use an HTTPS SonarQube URL. HTTP is allowed only for localhost development."
|
||||
case .invalidResponse: return "The server returned an invalid response."
|
||||
case .authentication: return "SonarQube rejected the saved token. Edit the server and enter a valid token."
|
||||
case .http(let code): return "SonarQube returned HTTP \(code)."
|
||||
case .decoding(let text): return "Could not read the SonarQube response: \(text)"
|
||||
case .notConfigured: return "Configure a SonarQube server in Settings."
|
||||
case .serverMessage(let message): return message
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,436 @@
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
@MainActor
|
||||
final class AppState: ObservableObject {
|
||||
@Published var servers: [SonarServer] = [] { didSet { saveServers() } }
|
||||
@Published var selectedServerID: UUID? { didSet { saveSelectedServer() } }
|
||||
@Published var projects: [ProjectSnapshot] = []
|
||||
@Published var issues: [Issue] = []
|
||||
@Published var issueTotal = 0
|
||||
@Published var selectedProjectKey: String?
|
||||
@Published var isLoading = false
|
||||
@Published var errorMessage: String?
|
||||
@Published var lastRefresh: Date?
|
||||
@Published var serverVersion: String?
|
||||
private let projectOrderKey = "projectOrder"
|
||||
private let localServersKey = "SonarDesk.servers.v2"
|
||||
private let localPreferencesKey = "SonarDesk.preferences.v2"
|
||||
private let driveStore = ICloudDriveStore.shared
|
||||
private var isLoadingPersistentData = false
|
||||
|
||||
private struct PreferencesFile: Codable {
|
||||
var selectedServerID: UUID?
|
||||
var projectOrder: [String]
|
||||
var projectAliases: [String: String]?
|
||||
}
|
||||
|
||||
var selectedServer: SonarServer? { servers.first { $0.id == selectedServerID } }
|
||||
|
||||
init() {
|
||||
migrateLegacyPreferencesToICloudDriveIfNeeded()
|
||||
loadDrivePreferences()
|
||||
}
|
||||
|
||||
func client() throws -> SonarQubeClient {
|
||||
guard let server = selectedServer, let token = KeychainService.token(serverID: server.id), !token.isEmpty else { throw APIError.notConfigured }
|
||||
return SonarQubeClient(server: server, token: token)
|
||||
}
|
||||
|
||||
func testServerConfiguration(name: String, url: String, token: String) async throws -> String {
|
||||
let normalized = try normalizedServerURL(url)
|
||||
let cleanToken = token.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !cleanToken.isEmpty else { throw KeychainError.emptyToken }
|
||||
let temporary = SonarServer(name: cleanServerName(name), baseURL: normalized)
|
||||
let status = try await SonarQubeClient(server: temporary, token: cleanToken).serverStatus()
|
||||
return status.version ?? "Connected"
|
||||
}
|
||||
|
||||
func testServer(id: UUID, replacementToken: String? = nil) async throws -> String {
|
||||
guard let server = servers.first(where: { $0.id == id }) else { throw APIError.notConfigured }
|
||||
let token = replacementToken?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let effectiveToken = (token?.isEmpty == false ? token : KeychainService.token(serverID: id))
|
||||
guard let effectiveToken, !effectiveToken.isEmpty else { throw APIError.notConfigured }
|
||||
let status = try await SonarQubeClient(server: server, token: effectiveToken).serverStatus()
|
||||
return status.version ?? "Connected"
|
||||
}
|
||||
|
||||
func refresh() async {
|
||||
isLoading = true; errorMessage = nil
|
||||
defer { isLoading = false }
|
||||
do {
|
||||
let client = try client()
|
||||
async let status = client.serverStatus()
|
||||
async let response = client.projects()
|
||||
let (serverStatus, projectResponse) = try await (status, response)
|
||||
serverVersion = serverStatus.version
|
||||
var snapshots = projectResponse.components.map { ProjectSnapshot(project: $0) }
|
||||
await withTaskGroup(of: (Int, [String:String], String).self) { group in
|
||||
for (index, project) in projectResponse.components.enumerated() {
|
||||
group.addTask {
|
||||
async let m = try? client.measures(projectKey: project.key)
|
||||
async let g = try? client.qualityGate(projectKey: project.key)
|
||||
let (measures, gate) = await (m, g)
|
||||
let dict = Dictionary(uniqueKeysWithValues: (measures?.component.measures ?? []).compactMap { x in x.value.map { (x.metric, $0) } })
|
||||
return (index, dict, gate?.projectStatus.status ?? "UNKNOWN")
|
||||
}
|
||||
}
|
||||
for await result in group { snapshots[result.0].measures = result.1; snapshots[result.0].qualityGate = result.2 }
|
||||
}
|
||||
projects = applySavedProjectOrder(to: snapshots)
|
||||
lastRefresh = Date()
|
||||
} catch { errorMessage = error.localizedDescription }
|
||||
}
|
||||
|
||||
func createSonarQubeProject(name: String, key: String, visibility: String, tokenName: String) async throws -> CreatedProjectCredentials {
|
||||
guard selectedServer != nil else { throw APIError.notConfigured }
|
||||
let cleanName = name.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let cleanKey = key.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !cleanName.isEmpty, !cleanKey.isEmpty else { throw ProjectCreationError.missingNameOrKey }
|
||||
let client = try client()
|
||||
try await client.createProject(name: cleanName, key: cleanKey, visibility: visibility)
|
||||
await refresh()
|
||||
return try await generateScanCredentials(projectKey: cleanKey, projectName: cleanName, tokenName: tokenName)
|
||||
}
|
||||
|
||||
func generateScanCredentials(projectKey: String, projectName: String? = nil, tokenName: String = "") async throws -> CreatedProjectCredentials {
|
||||
guard let server = selectedServer else { throw APIError.notConfigured }
|
||||
let cleanKey = projectKey.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let resolvedName = projectName ?? projects.first(where: { $0.project.key == cleanKey })?.project.name ?? cleanKey
|
||||
let suppliedName = tokenName.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let effectiveTokenName = suppliedName.isEmpty ? "SonarDesk - \(resolvedName) - \(Int(Date().timeIntervalSince1970))" : suppliedName
|
||||
let generated = try await client().generateProjectAnalysisToken(projectKey: cleanKey, tokenName: effectiveTokenName)
|
||||
try KeychainService.saveProjectToken(generated.token, serverID: server.id, projectKey: cleanKey)
|
||||
return CreatedProjectCredentials(projectKey: cleanKey, projectName: resolvedName, tokenName: effectiveTokenName, token: generated.token, serverURL: server.baseURL)
|
||||
}
|
||||
|
||||
func storedProjectToken(projectKey: String) -> String? {
|
||||
guard let server = selectedServer else { return nil }
|
||||
return KeychainService.projectToken(serverID: server.id, projectKey: projectKey)
|
||||
}
|
||||
|
||||
func loadIssues(projectKey: String? = nil, filter: IssueFilter = .all, page: Int = 1, pageSize: Int = 25) async {
|
||||
isLoading = true; errorMessage = nil
|
||||
defer { isLoading = false }
|
||||
do {
|
||||
let response = try await client().issues(projectKey: projectKey, type: filter.apiType, page: page, pageSize: pageSize)
|
||||
issues = response.issues
|
||||
issueTotal = response.total ?? response.issues.count
|
||||
} catch { errorMessage = error.localizedDescription }
|
||||
}
|
||||
|
||||
func addServer(name: String, url: String, token: String) throws {
|
||||
let normalized = try normalizedServerURL(url)
|
||||
guard !servers.contains(where: { canonicalServerURL($0.baseURL) == canonicalServerURL(normalized) }) else {
|
||||
throw ServerConfigurationError.duplicateServer
|
||||
}
|
||||
let server = SonarServer(name: cleanServerName(name), baseURL: normalized)
|
||||
try KeychainService.saveToken(token, serverID: server.id)
|
||||
servers.append(server)
|
||||
selectedServerID = server.id
|
||||
}
|
||||
|
||||
func updateServer(id: UUID, name: String, url: String, token: String?) throws {
|
||||
guard let index = servers.firstIndex(where: { $0.id == id }) else { return }
|
||||
let normalized = try normalizedServerURL(url)
|
||||
guard !servers.contains(where: { $0.id != id && canonicalServerURL($0.baseURL) == canonicalServerURL(normalized) }) else {
|
||||
throw ServerConfigurationError.duplicateServer
|
||||
}
|
||||
|
||||
servers[index].name = cleanServerName(name)
|
||||
servers[index].baseURL = normalized
|
||||
if let token, !token.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||
try KeychainService.saveToken(token, serverID: id)
|
||||
}
|
||||
saveServers()
|
||||
if selectedServerID == id {
|
||||
projects = []; issues = []; issueTotal = 0; serverVersion = nil; errorMessage = nil
|
||||
Task { await refresh() }
|
||||
}
|
||||
}
|
||||
|
||||
func selectServer(_ id: UUID?) {
|
||||
guard selectedServerID != id else { return }
|
||||
selectedServerID = id
|
||||
projects = []
|
||||
issues = []
|
||||
issueTotal = 0
|
||||
serverVersion = nil
|
||||
errorMessage = nil
|
||||
if id != nil { Task { await refresh() } }
|
||||
}
|
||||
|
||||
func removeServer(_ server: SonarServer) {
|
||||
KeychainService.deleteToken(serverID: server.id); servers.removeAll { $0.id == server.id }
|
||||
if selectedServerID == server.id { selectedServerID = servers.first?.id }
|
||||
}
|
||||
|
||||
|
||||
func projectDisplayName(for project: Project) -> String {
|
||||
let key = projectAliasStorageKey(projectKey: project.key)
|
||||
let alias = localPreferences()?.projectAliases?[key]?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return alias?.isEmpty == false ? alias! : project.name
|
||||
}
|
||||
|
||||
func setProjectAlias(_ alias: String, for projectKey: String) {
|
||||
let clean = alias.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
var aliases = localPreferences()?.projectAliases ?? [:]
|
||||
let key = projectAliasStorageKey(projectKey: projectKey)
|
||||
if clean.isEmpty { aliases.removeValue(forKey: key) } else { aliases[key] = clean }
|
||||
savePreferences(projectAliases: aliases)
|
||||
objectWillChange.send()
|
||||
}
|
||||
|
||||
func resetProjectAlias(for projectKey: String) {
|
||||
setProjectAlias("", for: projectKey)
|
||||
}
|
||||
|
||||
func projectAlias(for projectKey: String) -> String? {
|
||||
let alias = localPreferences()?.projectAliases?[projectAliasStorageKey(projectKey: projectKey)]
|
||||
return alias?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false ? alias : nil
|
||||
}
|
||||
|
||||
private func projectAliasStorageKey(projectKey: String) -> String {
|
||||
"\(selectedServerID?.uuidString ?? "unknown")|\(projectKey)"
|
||||
}
|
||||
|
||||
func moveProjects(from source: IndexSet, to destination: Int) {
|
||||
projects.move(fromOffsets: source, toOffset: destination)
|
||||
saveProjectOrder()
|
||||
}
|
||||
|
||||
func moveProject(_ key: String, direction: Int) {
|
||||
guard let index = projects.firstIndex(where: { $0.project.key == key }) else { return }
|
||||
let target = index + direction
|
||||
guard projects.indices.contains(target) else { return }
|
||||
projects.swapAt(index, target)
|
||||
saveProjectOrder()
|
||||
}
|
||||
|
||||
func moveProject(_ sourceKey: String, before targetKey: String) {
|
||||
guard sourceKey != targetKey,
|
||||
let sourceIndex = projects.firstIndex(where: { $0.project.key == sourceKey }),
|
||||
let targetIndex = projects.firstIndex(where: { $0.project.key == targetKey }) else { return }
|
||||
let item = projects.remove(at: sourceIndex)
|
||||
let adjustedTarget = sourceIndex < targetIndex ? targetIndex - 1 : targetIndex
|
||||
projects.insert(item, at: max(0, min(adjustedTarget, projects.count)))
|
||||
saveProjectOrder()
|
||||
}
|
||||
|
||||
func allIssues(projectKey: String) async throws -> [Issue] {
|
||||
let api = try client()
|
||||
let pageSize = 500
|
||||
var page = 1
|
||||
var result: [Issue] = []
|
||||
while true {
|
||||
let response = try await api.issues(projectKey: projectKey, page: page, pageSize: pageSize)
|
||||
result.append(contentsOf: response.issues)
|
||||
let total = response.total ?? result.count
|
||||
if result.count >= total || response.issues.isEmpty { break }
|
||||
page += 1
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
private func applySavedProjectOrder(to snapshots: [ProjectSnapshot]) -> [ProjectSnapshot] {
|
||||
let alphabetical = snapshots.sorted { $0.project.name.localizedCaseInsensitiveCompare($1.project.name) == .orderedAscending }
|
||||
let keys = (try? driveStore.read(PreferencesFile.self, fileName: "preferences.json"))?.projectOrder ?? []
|
||||
guard !keys.isEmpty else { return alphabetical }
|
||||
let positions = Dictionary(uniqueKeysWithValues: keys.enumerated().map { ($0.element, $0.offset) })
|
||||
return alphabetical.sorted { lhs, rhs in
|
||||
let li = positions[lhs.project.key] ?? Int.max
|
||||
let ri = positions[rhs.project.key] ?? Int.max
|
||||
if li == ri { return lhs.project.name.localizedCaseInsensitiveCompare(rhs.project.name) == .orderedAscending }
|
||||
return li < ri
|
||||
}
|
||||
}
|
||||
|
||||
private func saveProjectOrder() {
|
||||
savePreferences(projectOrder: projects.map { $0.project.key })
|
||||
}
|
||||
|
||||
private func saveServers() {
|
||||
guard !isLoadingPersistentData else { return }
|
||||
do {
|
||||
let encoder = JSONEncoder()
|
||||
encoder.dateEncodingStrategy = .iso8601
|
||||
UserDefaults.standard.set(try encoder.encode(servers), forKey: localServersKey)
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
return
|
||||
}
|
||||
|
||||
// iCloud is a mirror, not the source of truth required for app startup.
|
||||
// A temporarily unavailable iCloud container must never make SonarDesk
|
||||
// appear to have forgotten its configured SonarQube server.
|
||||
try? driveStore.write(servers, fileName: "servers.json")
|
||||
}
|
||||
|
||||
private func saveSelectedServer() {
|
||||
guard !isLoadingPersistentData else { return }
|
||||
savePreferences()
|
||||
}
|
||||
|
||||
private func savePreferences(projectOrder: [String]? = nil, projectAliases: [String: String]? = nil) {
|
||||
let existing = localPreferences()
|
||||
let preferences = PreferencesFile(
|
||||
selectedServerID: selectedServerID,
|
||||
projectOrder: projectOrder ?? existing?.projectOrder ?? [],
|
||||
projectAliases: projectAliases ?? existing?.projectAliases ?? [:]
|
||||
)
|
||||
|
||||
do {
|
||||
let encoder = JSONEncoder()
|
||||
UserDefaults.standard.set(try encoder.encode(preferences), forKey: localPreferencesKey)
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
return
|
||||
}
|
||||
|
||||
try? driveStore.write(preferences, fileName: "preferences.json")
|
||||
}
|
||||
|
||||
private func loadDrivePreferences() {
|
||||
isLoadingPersistentData = true
|
||||
defer { isLoadingPersistentData = false }
|
||||
|
||||
// Local data is deliberately authoritative. iCloud is used to seed a new
|
||||
// installation and as a mirror, but its availability is not required to launch.
|
||||
var decoded = localServers()
|
||||
if decoded.isEmpty {
|
||||
decoded = (try? driveStore.read([SonarServer].self, fileName: "servers.json")) ?? []
|
||||
}
|
||||
|
||||
var seen = Set<String>()
|
||||
for index in decoded.indices {
|
||||
decoded[index].baseURL = decoded[index].baseURL.replacingOccurrences(
|
||||
of: "sonacube.absdev.net", with: "sonarcube.absdev.net", options: [.caseInsensitive]
|
||||
)
|
||||
}
|
||||
servers = decoded.filter { server in
|
||||
let key = server.baseURL.trimmingCharacters(in: CharacterSet(charactersIn: "/")).lowercased()
|
||||
return seen.insert(key).inserted
|
||||
}
|
||||
|
||||
let preferences = localPreferences()
|
||||
?? (try? driveStore.read(PreferencesFile.self, fileName: "preferences.json"))
|
||||
selectedServerID = preferences?.selectedServerID
|
||||
if selectedServerID == nil || !servers.contains(where: { $0.id == selectedServerID }) {
|
||||
selectedServerID = servers.first?.id
|
||||
}
|
||||
|
||||
// Persist the recovered/normalised state locally first, then mirror to iCloud.
|
||||
do {
|
||||
let encoder = JSONEncoder()
|
||||
encoder.dateEncodingStrategy = .iso8601
|
||||
UserDefaults.standard.set(try encoder.encode(servers), forKey: localServersKey)
|
||||
let repaired = PreferencesFile(selectedServerID: selectedServerID,
|
||||
projectOrder: preferences?.projectOrder ?? [],
|
||||
projectAliases: preferences?.projectAliases ?? [:])
|
||||
UserDefaults.standard.set(try encoder.encode(repaired), forKey: localPreferencesKey)
|
||||
try? driveStore.write(servers, fileName: "servers.json")
|
||||
try? driveStore.write(repaired, fileName: "preferences.json")
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
private func migrateLegacyPreferencesToICloudDriveIfNeeded() {
|
||||
// If the v2 local store already exists there is nothing to migrate.
|
||||
if UserDefaults.standard.data(forKey: localServersKey) != nil { return }
|
||||
|
||||
let cloudStore = NSUbiquitousKeyValueStore.default
|
||||
cloudStore.synchronize()
|
||||
let defaults = UserDefaults.standard
|
||||
|
||||
var legacyServers: [SonarServer] = []
|
||||
if let data = cloudStore.data(forKey: "servers") ?? defaults.data(forKey: "servers"),
|
||||
let decoded = try? JSONDecoder().decode([SonarServer].self, from: data) {
|
||||
legacyServers = decoded
|
||||
} else if let driveServers = try? driveStore.read([SonarServer].self, fileName: "servers.json") {
|
||||
legacyServers = driveServers
|
||||
}
|
||||
|
||||
let selectedRaw = cloudStore.string(forKey: "selectedServer") ?? defaults.string(forKey: "selectedServer")
|
||||
let selected = selectedRaw.flatMap(UUID.init(uuidString:))
|
||||
let drivePreferences = try? driveStore.read(PreferencesFile.self, fileName: "preferences.json")
|
||||
let order = (cloudStore.array(forKey: projectOrderKey) as? [String])
|
||||
?? defaults.stringArray(forKey: projectOrderKey)
|
||||
?? drivePreferences?.projectOrder
|
||||
?? []
|
||||
let effectiveSelected = selected ?? drivePreferences?.selectedServerID
|
||||
|
||||
do {
|
||||
let encoder = JSONEncoder()
|
||||
encoder.dateEncodingStrategy = .iso8601
|
||||
UserDefaults.standard.set(try encoder.encode(legacyServers), forKey: localServersKey)
|
||||
UserDefaults.standard.set(
|
||||
try encoder.encode(PreferencesFile(selectedServerID: effectiveSelected, projectOrder: order, projectAliases: drivePreferences?.projectAliases ?? [:])),
|
||||
forKey: localPreferencesKey
|
||||
)
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
private func localServers() -> [SonarServer] {
|
||||
guard let data = UserDefaults.standard.data(forKey: localServersKey) else { return [] }
|
||||
let decoder = JSONDecoder()
|
||||
decoder.dateDecodingStrategy = .iso8601
|
||||
return (try? decoder.decode([SonarServer].self, from: data)) ?? []
|
||||
}
|
||||
|
||||
private func localPreferences() -> PreferencesFile? {
|
||||
guard let data = UserDefaults.standard.data(forKey: localPreferencesKey) else { return nil }
|
||||
return try? JSONDecoder().decode(PreferencesFile.self, from: data)
|
||||
}
|
||||
|
||||
|
||||
private func cleanServerName(_ name: String) -> String {
|
||||
let clean = name.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return clean.isEmpty ? "SonarQube" : clean
|
||||
}
|
||||
|
||||
private func canonicalServerURL(_ value: String) -> String {
|
||||
value.trimmingCharacters(in: CharacterSet(charactersIn: "/")).lowercased()
|
||||
}
|
||||
|
||||
private func normalizedServerURL(_ value: String) throws -> String {
|
||||
let clean = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard var components = URLComponents(string: clean),
|
||||
let scheme = components.scheme?.lowercased(),
|
||||
let host = components.host?.lowercased(), !host.isEmpty else { throw APIError.invalidURL }
|
||||
let localHost = ["localhost", "127.0.0.1", "::1"].contains(host)
|
||||
guard scheme == "https" || (scheme == "http" && localHost) else { throw APIError.insecureURL }
|
||||
guard components.user == nil, components.password == nil, components.query == nil, components.fragment == nil else {
|
||||
throw ServerConfigurationError.invalidServerURL
|
||||
}
|
||||
components.scheme = scheme
|
||||
components.host = host
|
||||
var path = components.path
|
||||
while path.count > 1 && path.hasSuffix("/") { path.removeLast() }
|
||||
components.path = path == "/" ? "" : path
|
||||
guard let result = components.url?.absoluteString else { throw APIError.invalidURL }
|
||||
return result
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
enum ServerConfigurationError: LocalizedError {
|
||||
case duplicateServer
|
||||
case invalidServerURL
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .duplicateServer: return "That SonarQube server is already configured."
|
||||
case .invalidServerURL: return "Enter only the SonarQube server base URL, without credentials, a query, or a fragment."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
enum ProjectCreationError: LocalizedError {
|
||||
case missingNameOrKey
|
||||
var errorDescription: String? { "Enter both a project name and project key." }
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import Foundation
|
||||
|
||||
enum WorkspaceKind: String, Codable, CaseIterable, Sendable {
|
||||
case personal
|
||||
case team
|
||||
}
|
||||
|
||||
enum WorkspaceRole: String, Codable, CaseIterable, Sendable {
|
||||
case owner
|
||||
case admin
|
||||
case developer
|
||||
case viewer
|
||||
}
|
||||
|
||||
struct SonarWorkspace: Codable, Identifiable, Hashable, Sendable {
|
||||
var id: UUID
|
||||
var name: String
|
||||
var kind: WorkspaceKind
|
||||
var role: WorkspaceRole
|
||||
var serverIDs: [UUID]
|
||||
var createdAt: Date
|
||||
var updatedAt: Date
|
||||
|
||||
init(id: UUID = UUID(), name: String, kind: WorkspaceKind, role: WorkspaceRole,
|
||||
serverIDs: [UUID] = [], createdAt: Date = Date(), updatedAt: Date = Date()) {
|
||||
self.id = id
|
||||
self.name = name
|
||||
self.kind = kind
|
||||
self.role = role
|
||||
self.serverIDs = serverIDs
|
||||
self.createdAt = createdAt
|
||||
self.updatedAt = updatedAt
|
||||
}
|
||||
|
||||
static let personal = SonarWorkspace(
|
||||
id: UUID(uuidString: "00000000-0000-0000-0000-000000000001")!,
|
||||
name: "Personal",
|
||||
kind: .personal,
|
||||
role: .owner
|
||||
)
|
||||
}
|
||||
|
||||
struct WorkspaceMember: Codable, Identifiable, Hashable, Sendable {
|
||||
var id: UUID
|
||||
var workspaceID: UUID
|
||||
var displayName: String
|
||||
var role: WorkspaceRole
|
||||
var joinedAt: Date
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import Foundation
|
||||
import Combine
|
||||
|
||||
@MainActor
|
||||
final class WorkspaceStore: ObservableObject {
|
||||
@Published private(set) var workspaces: [SonarWorkspace] = [.personal]
|
||||
@Published var selectedWorkspaceID: UUID = SonarWorkspace.personal.id
|
||||
|
||||
private let driveStore = ICloudDriveStore.shared
|
||||
private let fileName = "workspaces.json"
|
||||
|
||||
var selectedWorkspace: SonarWorkspace {
|
||||
workspaces.first(where: { $0.id == selectedWorkspaceID }) ?? .personal
|
||||
}
|
||||
|
||||
init() {
|
||||
load()
|
||||
}
|
||||
|
||||
func load() {
|
||||
do {
|
||||
guard let saved = try driveStore.read([SonarWorkspace].self, fileName: fileName), !saved.isEmpty else {
|
||||
persist()
|
||||
return
|
||||
}
|
||||
workspaces = saved
|
||||
if !workspaces.contains(where: { $0.id == selectedWorkspaceID }) {
|
||||
selectedWorkspaceID = workspaces.first?.id ?? SonarWorkspace.personal.id
|
||||
}
|
||||
} catch {
|
||||
// Keep Personal usable if iCloud is temporarily unavailable.
|
||||
workspaces = [.personal]
|
||||
}
|
||||
}
|
||||
|
||||
func createTeam(name: String) {
|
||||
let clean = name.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !clean.isEmpty else { return }
|
||||
let team = SonarWorkspace(name: clean, kind: .team, role: .owner)
|
||||
workspaces.append(team)
|
||||
selectedWorkspaceID = team.id
|
||||
persist()
|
||||
}
|
||||
|
||||
func select(_ workspace: SonarWorkspace) {
|
||||
selectedWorkspaceID = workspace.id
|
||||
}
|
||||
|
||||
private func persist() {
|
||||
try? driveStore.write(workspaces, fileName: fileName)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>com.apple.developer.icloud-container-identifiers</key>
|
||||
<array><string>iCloud.net.absdev.SonarDesk</string></array>
|
||||
<key>com.apple.developer.ubiquity-container-identifiers</key>
|
||||
<array><string>iCloud.net.absdev.SonarDesk</string></array>
|
||||
<key>com.apple.developer.icloud-services</key>
|
||||
<array><string>CloudDocuments</string><string>CloudKit</string></array>
|
||||
<key>com.apple.developer.ubiquity-kvstore-identifier</key>
|
||||
<string>$(TeamIdentifierPrefix)net.absdev.SonarDesk</string>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,15 @@
|
||||
import SwiftUI
|
||||
|
||||
@main
|
||||
struct SonarDeskIOSApp: App {
|
||||
@StateObject private var appState = AppState()
|
||||
@StateObject private var workspaceStore = WorkspaceStore()
|
||||
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
IOSRootView()
|
||||
.environmentObject(appState)
|
||||
.environmentObject(workspaceStore)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import SwiftUI
|
||||
|
||||
struct IOSDashboardView: View {
|
||||
@EnvironmentObject private var appState: AppState
|
||||
|
||||
private var failedProjects: [ProjectSnapshot] {
|
||||
appState.projects.filter { $0.qualityGate.uppercased() != "OK" }
|
||||
}
|
||||
|
||||
private var bugs: Int { total("bugs") }
|
||||
private var vulnerabilities: Int { total("vulnerabilities") }
|
||||
private var codeSmells: Int { total("code_smells") }
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
ScrollView {
|
||||
LazyVGrid(columns: [GridItem(.adaptive(minimum: 155), spacing: 12)], spacing: 12) {
|
||||
metricCard("Projects", value: appState.projects.count, icon: "square.stack.3d.up")
|
||||
metricCard("Failed", value: failedProjects.count, icon: "xmark.octagon.fill", isFailure: !failedProjects.isEmpty)
|
||||
metricCard("Bugs", value: bugs, icon: "ladybug")
|
||||
metricCard("Vulnerabilities", value: vulnerabilities, icon: "lock.trianglebadge.exclamationmark")
|
||||
metricCard("Code Smells", value: codeSmells, icon: "exclamationmark.bubble")
|
||||
}
|
||||
.padding()
|
||||
|
||||
if !failedProjects.isEmpty {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
Text("Needs attention")
|
||||
.font(.headline)
|
||||
ForEach(failedProjects) { snapshot in
|
||||
NavigationLink {
|
||||
IOSProjectDetailView(snapshot: snapshot)
|
||||
} label: {
|
||||
HStack {
|
||||
Image(systemName: "xmark.octagon.fill")
|
||||
.foregroundStyle(.red)
|
||||
VStack(alignment: .leading) {
|
||||
Text(snapshot.project.name).fontWeight(.semibold)
|
||||
Text(snapshot.qualityGate).font(.caption)
|
||||
}
|
||||
Spacer()
|
||||
Image(systemName: "chevron.right")
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.padding()
|
||||
.background(.red.opacity(0.10), in: RoundedRectangle(cornerRadius: 14))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal)
|
||||
}
|
||||
}
|
||||
.navigationTitle("Dashboard")
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Button { Task { await appState.refresh() } } label: {
|
||||
if appState.isLoading { ProgressView() } else { Image(systemName: "arrow.clockwise") }
|
||||
}
|
||||
.disabled(appState.isLoading || appState.selectedServer == nil)
|
||||
}
|
||||
}
|
||||
.overlay {
|
||||
if appState.selectedServer == nil {
|
||||
ContentUnavailableView("No SonarQube Server", systemImage: "server.rack", description: Text("Add or sync a server to start using SonarDesk."))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func total(_ key: String) -> Int {
|
||||
appState.projects.reduce(0) { $0 + (Int($1.measures[key] ?? "0") ?? 0) }
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func metricCard(_ title: String, value: Int, icon: String, isFailure: Bool = false) -> some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Image(systemName: icon).font(.title2).foregroundStyle(isFailure ? .red : .primary)
|
||||
Text("\(value)").font(.system(.title, design: .rounded, weight: .bold))
|
||||
Text(title).font(.subheadline).foregroundStyle(.secondary)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding()
|
||||
.background(isFailure ? .red.opacity(0.10) : Color.secondary.opacity(0.08), in: RoundedRectangle(cornerRadius: 16))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import SwiftUI
|
||||
|
||||
struct IOSProjectDetailView: View {
|
||||
let snapshot: ProjectSnapshot
|
||||
|
||||
private let metrics: [(String, String, String)] = [
|
||||
("bugs", "Bugs", "ladybug"),
|
||||
("vulnerabilities", "Vulnerabilities", "lock.trianglebadge.exclamationmark"),
|
||||
("code_smells", "Code Smells", "exclamationmark.bubble"),
|
||||
("coverage", "Coverage", "chart.bar"),
|
||||
("duplicated_lines_density", "Duplications", "doc.on.doc")
|
||||
]
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
Section("Quality Gate") {
|
||||
HStack {
|
||||
Label(snapshot.qualityGate.uppercased() == "OK" ? "Passed" : "Failed",
|
||||
systemImage: snapshot.qualityGate.uppercased() == "OK" ? "checkmark.circle.fill" : "xmark.octagon.fill")
|
||||
.foregroundStyle(snapshot.qualityGate.uppercased() == "OK" ? .green : .red)
|
||||
Spacer()
|
||||
Text(snapshot.qualityGate)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
Section("Metrics") {
|
||||
ForEach(metrics, id: \.0) { metric in
|
||||
HStack {
|
||||
Label(metric.1, systemImage: metric.2)
|
||||
Spacer()
|
||||
Text(displayValue(metric.0))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Section("Project") {
|
||||
LabeledContent("Key", value: snapshot.project.key)
|
||||
if let date = snapshot.project.lastAnalysisDate {
|
||||
LabeledContent("Last analysis", value: date)
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle(snapshot.project.name)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
}
|
||||
|
||||
private func displayValue(_ metric: String) -> String {
|
||||
guard let value = snapshot.measures[metric] else { return "—" }
|
||||
if metric == "coverage" || metric == "duplicated_lines_density" { return "\(value)%" }
|
||||
return value
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import SwiftUI
|
||||
|
||||
struct IOSProjectsView: View {
|
||||
@EnvironmentObject private var appState: AppState
|
||||
@State private var searchText = ""
|
||||
|
||||
private var filtered: [ProjectSnapshot] {
|
||||
guard !searchText.isEmpty else { return appState.projects }
|
||||
return appState.projects.filter {
|
||||
$0.project.name.localizedCaseInsensitiveContains(searchText) ||
|
||||
$0.project.key.localizedCaseInsensitiveContains(searchText)
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
List(filtered) { snapshot in
|
||||
NavigationLink {
|
||||
IOSProjectDetailView(snapshot: snapshot)
|
||||
} label: {
|
||||
HStack(spacing: 12) {
|
||||
Image(systemName: snapshot.qualityGate.uppercased() == "OK" ? "checkmark.circle.fill" : "xmark.octagon.fill")
|
||||
.foregroundStyle(snapshot.qualityGate.uppercased() == "OK" ? .green : .red)
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text(snapshot.project.name).fontWeight(.semibold)
|
||||
Text(snapshot.project.key).font(.caption).foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("Projects")
|
||||
.searchable(text: $searchText, prompt: "Search projects")
|
||||
.refreshable { await appState.refresh() }
|
||||
.overlay {
|
||||
if appState.projects.isEmpty && !appState.isLoading {
|
||||
ContentUnavailableView("No Projects", systemImage: "square.stack.3d.up", description: Text(appState.errorMessage ?? "No SonarQube projects are available."))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import SwiftUI
|
||||
|
||||
struct IOSRootView: View {
|
||||
@EnvironmentObject private var appState: AppState
|
||||
@EnvironmentObject private var workspaceStore: WorkspaceStore
|
||||
@State private var selection: IOSSection? = .dashboard
|
||||
|
||||
enum IOSSection: Hashable {
|
||||
case dashboard
|
||||
case projects
|
||||
case settings
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationSplitView {
|
||||
List(selection: $selection) {
|
||||
Section {
|
||||
Label("Dashboard", systemImage: "gauge.with.dots.needle.50percent")
|
||||
.tag(IOSSection.dashboard)
|
||||
Label("Projects", systemImage: "square.stack.3d.up")
|
||||
.tag(IOSSection.projects)
|
||||
}
|
||||
|
||||
Section("Workspace") {
|
||||
Picker("Workspace", selection: $workspaceStore.selectedWorkspaceID) {
|
||||
ForEach(workspaceStore.workspaces) { workspace in
|
||||
Text(workspace.name).tag(workspace.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Section {
|
||||
Label("Settings", systemImage: "gearshape")
|
||||
.tag(IOSSection.settings)
|
||||
}
|
||||
}
|
||||
.navigationTitle("SonarDesk")
|
||||
} detail: {
|
||||
switch selection ?? .dashboard {
|
||||
case .dashboard:
|
||||
IOSDashboardView()
|
||||
case .projects:
|
||||
IOSProjectsView()
|
||||
case .settings:
|
||||
IOSSettingsView()
|
||||
}
|
||||
}
|
||||
.task {
|
||||
if appState.selectedServer != nil && appState.projects.isEmpty {
|
||||
await appState.refresh()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
import SwiftUI
|
||||
|
||||
struct IOSSettingsView: View {
|
||||
@EnvironmentObject private var appState: AppState
|
||||
@EnvironmentObject private var workspaceStore: WorkspaceStore
|
||||
@State private var editor: ServerEditorItem?
|
||||
@State private var serverToDelete: SonarServer?
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Form {
|
||||
Section("Workspace") {
|
||||
LabeledContent("Name", value: workspaceStore.selectedWorkspace.name)
|
||||
LabeledContent("Type", value: workspaceStore.selectedWorkspace.kind == .team ? "Team" : "Personal")
|
||||
LabeledContent("Role", value: workspaceStore.selectedWorkspace.role.rawValue.capitalized)
|
||||
}
|
||||
|
||||
Section {
|
||||
if appState.servers.isEmpty {
|
||||
ContentUnavailableView("No SonarQube Servers", systemImage: "server.rack", description: Text("Add a server to load your SonarQube projects."))
|
||||
} else {
|
||||
ForEach(appState.servers) { server in
|
||||
Button {
|
||||
appState.selectServer(server.id)
|
||||
} label: {
|
||||
HStack(spacing: 12) {
|
||||
Image(systemName: appState.selectedServerID == server.id ? "checkmark.circle.fill" : "circle")
|
||||
.foregroundStyle(appState.selectedServerID == server.id ? Color.accentColor : Color.secondary)
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text(server.name).foregroundStyle(.primary)
|
||||
Text(server.baseURL).font(.caption).foregroundStyle(.secondary).lineLimit(1)
|
||||
}
|
||||
Spacer()
|
||||
Button { editor = ServerEditorItem(server: server) } label: { Image(systemName: "pencil") }
|
||||
.buttonStyle(.borderless)
|
||||
}
|
||||
}
|
||||
.swipeActions(edge: .trailing) {
|
||||
Button(role: .destructive) { serverToDelete = server } label: { Label("Delete", systemImage: "trash") }
|
||||
Button { editor = ServerEditorItem(server: server) } label: { Label("Edit", systemImage: "pencil") }.tint(.blue)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Button { editor = ServerEditorItem(server: nil) } label: {
|
||||
Label("Add SonarQube Server", systemImage: "plus.circle.fill")
|
||||
}
|
||||
} header: { Text("SonarQube Servers") } footer: {
|
||||
Text("Access tokens are stored only in this device's Keychain and are not written to iCloud.")
|
||||
}
|
||||
|
||||
if let server = appState.selectedServer {
|
||||
Section("Active Server") {
|
||||
LabeledContent("Server", value: server.name)
|
||||
LabeledContent("Address", value: server.baseURL)
|
||||
if let version = appState.serverVersion { LabeledContent("Version", value: version) }
|
||||
Button("Refresh Projects") { Task { await appState.refresh() } }
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("Settings")
|
||||
.sheet(item: $editor) { item in
|
||||
IOSServerEditorView(server: item.server)
|
||||
.environmentObject(appState)
|
||||
}
|
||||
.confirmationDialog("Delete this server?", isPresented: Binding(get: { serverToDelete != nil }, set: { if !$0 { serverToDelete = nil } }), titleVisibility: .visible) {
|
||||
if let server = serverToDelete {
|
||||
Button("Delete \(server.name)", role: .destructive) { appState.removeServer(server); serverToDelete = nil }
|
||||
}
|
||||
Button("Cancel", role: .cancel) { serverToDelete = nil }
|
||||
} message: { Text("The server configuration and its token will be removed from this device.") }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct ServerEditorItem: Identifiable {
|
||||
let id = UUID()
|
||||
let server: SonarServer?
|
||||
}
|
||||
|
||||
private struct IOSServerEditorView: View {
|
||||
@EnvironmentObject private var appState: AppState
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
let server: SonarServer?
|
||||
|
||||
@State private var name: String
|
||||
@State private var url: String
|
||||
@State private var token = ""
|
||||
@State private var isTesting = false
|
||||
@State private var isSaving = false
|
||||
@State private var testResult: String?
|
||||
@State private var errorMessage: String?
|
||||
|
||||
init(server: SonarServer?) {
|
||||
self.server = server
|
||||
_name = State(initialValue: server?.name ?? "")
|
||||
_url = State(initialValue: server?.baseURL ?? "https://")
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Form {
|
||||
Section("Server") {
|
||||
TextField("Name", text: $name).textContentType(.organizationName)
|
||||
TextField("https://sonarqube.example.com", text: $url)
|
||||
.textInputAutocapitalization(.never).autocorrectionDisabled()
|
||||
.keyboardType(.URL).textContentType(.URL)
|
||||
}
|
||||
Section("Authentication") {
|
||||
SecureField(server == nil ? "SonarQube API token" : "New token (leave blank to keep current)", text: $token)
|
||||
.textInputAutocapitalization(.never).autocorrectionDisabled()
|
||||
Text(server == nil ? "Create a SonarQube user token and paste it here." : "For security the existing token cannot be displayed. Leave this blank unless you want to replace it.")
|
||||
.font(.footnote).foregroundStyle(.secondary)
|
||||
}
|
||||
Section {
|
||||
Button { Task { await testConnection() } } label: {
|
||||
HStack { if isTesting { ProgressView() }; Label("Test Connection", systemImage: "network") }
|
||||
}.disabled(isTesting || url.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || (server == nil && token.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty))
|
||||
if let testResult { Label(testResult, systemImage: "checkmark.circle.fill").foregroundStyle(.green) }
|
||||
if let errorMessage { Text(errorMessage).foregroundStyle(.red).font(.footnote) }
|
||||
}
|
||||
}
|
||||
.navigationTitle(server == nil ? "Add Server" : "Edit Server")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) { Button("Cancel") { dismiss() } }
|
||||
ToolbarItem(placement: .confirmationAction) { Button("Save") { save() }.disabled(isSaving || url.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || (server == nil && token.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor private func testConnection() async {
|
||||
isTesting = true; errorMessage = nil; testResult = nil
|
||||
defer { isTesting = false }
|
||||
do {
|
||||
let result: String
|
||||
if let server, token.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||
result = try await appState.testServer(id: server.id)
|
||||
} else {
|
||||
result = try await appState.testServerConfiguration(name: name, url: url, token: token)
|
||||
}
|
||||
testResult = "Connected — \(result)"
|
||||
} catch { errorMessage = error.localizedDescription }
|
||||
}
|
||||
|
||||
private func save() {
|
||||
isSaving = true; errorMessage = nil
|
||||
do {
|
||||
if let server { try appState.updateServer(id: server.id, name: name, url: url, token: token.isEmpty ? nil : token) }
|
||||
else { try appState.addServer(name: name, url: url, token: token) }
|
||||
dismiss()
|
||||
} catch { errorMessage = error.localizedDescription; isSaving = false }
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@
|
||||
|
||||
/* Begin PBXFileReference section */
|
||||
A7 /* SonarDesk.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = SonarDesk.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
B7 /* SonarDesk iOS.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "SonarDesk iOS.app"; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
/* Begin PBXFileSystemSynchronizedRootGroup section */
|
||||
@@ -16,9 +17,21 @@
|
||||
path = SonarDesk;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
B6 /* SonarDesk-iOS */ = {
|
||||
isa = PBXFileSystemSynchronizedRootGroup;
|
||||
path = "SonarDesk-iOS";
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
/* End PBXFileSystemSynchronizedRootGroup section */
|
||||
|
||||
/* Begin PBXFrameworksBuildPhase section */
|
||||
B10 /* Frameworks */ = {
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
A10 /* Frameworks */ = {
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
@@ -33,6 +46,7 @@
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
A6 /* SonarDesk */,
|
||||
B6 /* SonarDesk-iOS */,
|
||||
A4 /* Products */,
|
||||
);
|
||||
sourceTree = "<group>";
|
||||
@@ -41,6 +55,7 @@
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
A7 /* SonarDesk.app */,
|
||||
B7 /* SonarDesk iOS.app */,
|
||||
);
|
||||
name = Products;
|
||||
sourceTree = "<group>";
|
||||
@@ -70,6 +85,28 @@
|
||||
productReference = A7 /* SonarDesk.app */;
|
||||
productType = "com.apple.product-type.application";
|
||||
};
|
||||
B5 /* SonarDesk-iOS */ = {
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = B8 /* Build configuration list for PBXNativeTarget "SonarDesk-iOS" */;
|
||||
buildPhases = (
|
||||
B9 /* Sources */,
|
||||
B10 /* Frameworks */,
|
||||
B11 /* Resources */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
dependencies = (
|
||||
);
|
||||
fileSystemSynchronizedGroups = (
|
||||
B6 /* SonarDesk-iOS */,
|
||||
);
|
||||
name = "SonarDesk-iOS";
|
||||
packageProductDependencies = (
|
||||
);
|
||||
productName = "SonarDesk iOS";
|
||||
productReference = B7 /* SonarDesk iOS.app */;
|
||||
productType = "com.apple.product-type.application";
|
||||
};
|
||||
/* End PBXNativeTarget section */
|
||||
|
||||
/* Begin PBXProject section */
|
||||
@@ -94,6 +131,7 @@
|
||||
projectRoot = "";
|
||||
targets = (
|
||||
A5 /* SonarDesk */,
|
||||
B5 /* SonarDesk-iOS */,
|
||||
);
|
||||
};
|
||||
/* End PBXProject section */
|
||||
@@ -106,6 +144,13 @@
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
B11 /* Resources */ = {
|
||||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXResourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXSourcesBuildPhase section */
|
||||
@@ -116,6 +161,13 @@
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
B9 /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXSourcesBuildPhase section */
|
||||
|
||||
/* Begin XCBuildConfiguration section */
|
||||
@@ -418,6 +470,11 @@
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
|
||||
B20 /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { CODE_SIGN_ENTITLEMENTS = "SonarDesk-iOS/SonarDesk-iOS.entitlements"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 7; DEVELOPMENT_TEAM = 39R6U3437D; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_CFBundleDisplayName = SonarDesk; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.developer-tools"; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES; IPHONEOS_DEPLOYMENT_TARGET = 17.0; MARKETING_VERSION = 2.3.0; PRODUCT_BUNDLE_IDENTIFIER = net.absdev.SonarDesk.iOS; PRODUCT_NAME = "SonarDesk"; SDKROOT = iphoneos; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; SUPPORTS_MACCATALYST = NO; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_STRICT_CONCURRENCY = targeted; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; }; name = Debug; };
|
||||
B14 /* Develop */ = { isa = XCBuildConfiguration; buildSettings = { CODE_SIGN_ENTITLEMENTS = "SonarDesk-iOS/SonarDesk-iOS.entitlements"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 7; DEVELOPMENT_TEAM = 39R6U3437D; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_CFBundleDisplayName = SonarDesk; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.developer-tools"; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES; IPHONEOS_DEPLOYMENT_TARGET = 17.0; MARKETING_VERSION = 2.3.0; PRODUCT_BUNDLE_IDENTIFIER = net.absdev.SonarDesk.iOS; PRODUCT_NAME = "SonarDesk"; SDKROOT = iphoneos; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; SUPPORTS_MACCATALYST = NO; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_STRICT_CONCURRENCY = targeted; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; }; name = Develop; };
|
||||
B18 /* Stage */ = { isa = XCBuildConfiguration; buildSettings = { CODE_SIGN_ENTITLEMENTS = "SonarDesk-iOS/SonarDesk-iOS.entitlements"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 7; DEVELOPMENT_TEAM = 39R6U3437D; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_CFBundleDisplayName = SonarDesk; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.developer-tools"; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES; IPHONEOS_DEPLOYMENT_TARGET = 17.0; MARKETING_VERSION = 2.3.0; PRODUCT_BUNDLE_IDENTIFIER = net.absdev.SonarDesk.iOS; PRODUCT_NAME = "SonarDesk"; SDKROOT = iphoneos; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; SUPPORTS_MACCATALYST = NO; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_STRICT_CONCURRENCY = targeted; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; }; name = Stage; };
|
||||
B15 /* Release */ = { isa = XCBuildConfiguration; buildSettings = { CODE_SIGN_ENTITLEMENTS = "SonarDesk-iOS/SonarDesk-iOS.entitlements"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 7; DEVELOPMENT_TEAM = 39R6U3437D; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_CFBundleDisplayName = SonarDesk; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.developer-tools"; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES; IPHONEOS_DEPLOYMENT_TARGET = 17.0; MARKETING_VERSION = 2.3.0; PRODUCT_BUNDLE_IDENTIFIER = net.absdev.SonarDesk.iOS; PRODUCT_NAME = "SonarDesk"; SDKROOT = iphoneos; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; SUPPORTS_MACCATALYST = NO; SWIFT_COMPILATION_MODE = wholemodule; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_OPTIMIZATION_LEVEL = "-O"; SWIFT_STRICT_CONCURRENCY = targeted; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; VALIDATE_PRODUCT = YES; }; name = Release; };
|
||||
/* End XCBuildConfiguration section */
|
||||
|
||||
/* Begin XCConfigurationList section */
|
||||
@@ -443,6 +500,13 @@
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
|
||||
B8 /* Build configuration list for PBXNativeTarget "SonarDesk-iOS" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = ( B20 /* Debug */, B14 /* Develop */, B18 /* Stage */, B15 /* Release */, );
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
/* End XCConfigurationList section */
|
||||
};
|
||||
rootObject = A1 /* Project object */;
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
# SonarDesk shared workspace foundation
|
||||
|
||||
This is the first compatibility-safe step toward macOS + iOS + Team.
|
||||
|
||||
## Storage boundaries
|
||||
|
||||
- iCloud Drive currently remains the persistence mechanism for existing macOS data.
|
||||
- Workspace metadata is stored in `Data/workspaces.json` so current releases can adopt the model without a destructive migration.
|
||||
- SonarQube credentials remain in Keychain and are **not** written to iCloud Drive.
|
||||
- `ThisDeviceOnly` remains the default credential policy. An iPhone therefore authenticates independently.
|
||||
|
||||
## Next migration
|
||||
|
||||
Move workspace/team records to CloudKit private/shared databases:
|
||||
|
||||
- Personal workspace -> private database
|
||||
- Team workspace -> CKShare/shared database
|
||||
- Team membership and roles -> shared records
|
||||
- Reports -> iCloud Drive assets/CloudKit asset metadata
|
||||
|
||||
The existing JSON workspace model is intentionally Codable so it can be migrated to CloudKit records without changing UI-facing domain types.
|
||||
|
||||
## iOS target
|
||||
|
||||
The iOS target should share:
|
||||
|
||||
- Models/Models.swift
|
||||
- Services/SonarQubeClient.swift
|
||||
- Services/KeychainService.swift
|
||||
- Shared/Workspaces/*
|
||||
|
||||
macOS-only UI and exporters stay out of the iOS target. The first iOS screens are Dashboard, Projects and Project Detail.
|
||||
@@ -0,0 +1,49 @@
|
||||
import Foundation
|
||||
|
||||
enum WorkspaceKind: String, Codable, CaseIterable, Sendable {
|
||||
case personal
|
||||
case team
|
||||
}
|
||||
|
||||
enum WorkspaceRole: String, Codable, CaseIterable, Sendable {
|
||||
case owner
|
||||
case admin
|
||||
case developer
|
||||
case viewer
|
||||
}
|
||||
|
||||
struct SonarWorkspace: Codable, Identifiable, Hashable, Sendable {
|
||||
var id: UUID
|
||||
var name: String
|
||||
var kind: WorkspaceKind
|
||||
var role: WorkspaceRole
|
||||
var serverIDs: [UUID]
|
||||
var createdAt: Date
|
||||
var updatedAt: Date
|
||||
|
||||
init(id: UUID = UUID(), name: String, kind: WorkspaceKind, role: WorkspaceRole,
|
||||
serverIDs: [UUID] = [], createdAt: Date = Date(), updatedAt: Date = Date()) {
|
||||
self.id = id
|
||||
self.name = name
|
||||
self.kind = kind
|
||||
self.role = role
|
||||
self.serverIDs = serverIDs
|
||||
self.createdAt = createdAt
|
||||
self.updatedAt = updatedAt
|
||||
}
|
||||
|
||||
static let personal = SonarWorkspace(
|
||||
id: UUID(uuidString: "00000000-0000-0000-0000-000000000001")!,
|
||||
name: "Personal",
|
||||
kind: .personal,
|
||||
role: .owner
|
||||
)
|
||||
}
|
||||
|
||||
struct WorkspaceMember: Codable, Identifiable, Hashable, Sendable {
|
||||
var id: UUID
|
||||
var workspaceID: UUID
|
||||
var displayName: String
|
||||
var role: WorkspaceRole
|
||||
var joinedAt: Date
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import Foundation
|
||||
import Combine
|
||||
|
||||
@MainActor
|
||||
final class WorkspaceStore: ObservableObject {
|
||||
@Published private(set) var workspaces: [SonarWorkspace] = [.personal]
|
||||
@Published var selectedWorkspaceID: UUID = SonarWorkspace.personal.id
|
||||
|
||||
private let driveStore = ICloudDriveStore.shared
|
||||
private let fileName = "workspaces.json"
|
||||
|
||||
var selectedWorkspace: SonarWorkspace {
|
||||
workspaces.first(where: { $0.id == selectedWorkspaceID }) ?? .personal
|
||||
}
|
||||
|
||||
init() {
|
||||
load()
|
||||
}
|
||||
|
||||
func load() {
|
||||
do {
|
||||
guard let saved = try driveStore.read([SonarWorkspace].self, fileName: fileName), !saved.isEmpty else {
|
||||
persist()
|
||||
return
|
||||
}
|
||||
workspaces = saved
|
||||
if !workspaces.contains(where: { $0.id == selectedWorkspaceID }) {
|
||||
selectedWorkspaceID = workspaces.first?.id ?? SonarWorkspace.personal.id
|
||||
}
|
||||
} catch {
|
||||
// Keep Personal usable if iCloud is temporarily unavailable.
|
||||
workspaces = [.personal]
|
||||
}
|
||||
}
|
||||
|
||||
func createTeam(name: String) {
|
||||
let clean = name.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !clean.isEmpty else { return }
|
||||
let team = SonarWorkspace(name: clean, kind: .team, role: .owner)
|
||||
workspaces.append(team)
|
||||
selectedWorkspaceID = team.id
|
||||
persist()
|
||||
}
|
||||
|
||||
func select(_ workspace: SonarWorkspace) {
|
||||
selectedWorkspaceID = workspace.id
|
||||
}
|
||||
|
||||
private func persist() {
|
||||
try? driveStore.write(workspaces, fileName: fileName)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
# SonarDesk iOS + Team — Phase 1
|
||||
|
||||
Added the non-destructive shared workspace domain foundation.
|
||||
|
||||
## Added
|
||||
- `WorkspaceKind`: personal/team
|
||||
- `WorkspaceRole`: owner/admin/developer/viewer
|
||||
- `SonarWorkspace`
|
||||
- `WorkspaceMember`
|
||||
- `WorkspaceStore`
|
||||
- iCloud Drive persistence for workspace metadata
|
||||
- Explicit credential boundary: SonarQube tokens stay device-local in Keychain
|
||||
|
||||
## Next in Xcode
|
||||
1. Add a SonarDesk iOS app target (`net.absdev.SonarDesk.iOS`).
|
||||
2. Enable the existing iCloud container `iCloud.net.absdev.SonarDesk` plus CloudKit.
|
||||
3. Share API/models/workspace files with both targets.
|
||||
4. Add an iOS-specific app entry point and `NavigationSplitView`.
|
||||
5. Implement Dashboard, Projects and Project Detail.
|
||||
6. Introduce CloudKit private/shared workspace repository and migrate the workspace JSON records.
|
||||
|
||||
No existing macOS source was modified in Phase 1; the new synchronized-folder files are automatically visible to the current Xcode project.
|
||||
@@ -0,0 +1,34 @@
|
||||
# SonarDesk iOS + Team — Phase 2
|
||||
|
||||
Phase 2 adds the first native iPhone/iPad client source while leaving the existing macOS target and licensing implementation untouched.
|
||||
|
||||
## Added
|
||||
- Native iOS app entry point (`SonarDeskIOSApp`)
|
||||
- Adaptive `NavigationSplitView` root interface
|
||||
- iOS Dashboard
|
||||
- Failed Quality Gate projects highlighted in red
|
||||
- Project browser with search and pull-to-refresh
|
||||
- Project detail metrics
|
||||
- Workspace selector using the Phase 1 Personal/Team model
|
||||
- iOS settings showing workspace/server and the device-local credential boundary
|
||||
- Separate iOS entitlements file with the existing iCloud container and CloudKit capability
|
||||
|
||||
## Shared files for the iOS target
|
||||
Add these existing source groups to iOS target membership:
|
||||
- `SonarDesk/Models/Models.swift`
|
||||
- `SonarDesk/Services/SonarQubeClient.swift`
|
||||
- `SonarDesk/Services/KeychainService.swift`
|
||||
- `SonarDesk/Services/ICloudDriveStore.swift`
|
||||
- `SonarDesk/Stores/AppState.swift`
|
||||
- `SonarDesk/Shared/Workspaces/WorkspaceModels.swift`
|
||||
- `SonarDesk/Shared/Workspaces/WorkspaceStore.swift`
|
||||
|
||||
Do not add the macOS `SonarDesk/App/SonarDeskApp.swift` to the iOS target. Use `SonarDesk-iOS/SonarDeskIOSApp.swift` as the iOS app entry point.
|
||||
|
||||
## Xcode target
|
||||
Create an iOS App target named `SonarDesk-iOS` with bundle identifier `net.absdev.SonarDesk.iOS`, iPhone + iPad destinations, and the deployment target you want to support. Assign `SonarDesk-iOS/SonarDesk-iOS.entitlements` to that target.
|
||||
|
||||
The existing macOS entitlements, licensing files, bundle identifier, and licence verification code are unchanged.
|
||||
|
||||
## Phase 3
|
||||
Replace workspace JSON mirroring with a CloudKit repository, implement CKShare-backed team workspaces/invitations, member roles, and server metadata synchronization. Credentials remain in each member's Keychain.
|
||||
@@ -0,0 +1,19 @@
|
||||
# SonarDesk Phase 3 — Buildable iOS/iPadOS target
|
||||
|
||||
This phase fixes the missing Xcode target from Phase 2.
|
||||
|
||||
## Added
|
||||
- `SonarDesk-iOS` native application target in `SonarDesk.xcodeproj`
|
||||
- iPhone + iPad destinations (`TARGETED_DEVICE_FAMILY = 1,2`)
|
||||
- iOS 17 deployment target
|
||||
- bundle identifier `net.absdev.SonarDesk.iOS`
|
||||
- iOS-specific iCloud/CloudKit entitlements
|
||||
- self-contained iOS shared source copies so AppKit-only macOS files are not compiled into iOS
|
||||
- Dashboard, Projects, Project Detail, Settings and workspace selector
|
||||
|
||||
## Important
|
||||
The macOS `SonarDesk` target is retained separately. Its licensing source/configuration was not intentionally changed by this phase.
|
||||
|
||||
Open `SonarDesk.xcodeproj`, choose the `SonarDesk-iOS` scheme/target and an iPhone/iPad simulator or device.
|
||||
|
||||
If Xcode reports a provisioning error for `net.absdev.SonarDesk.iOS` or the iCloud container, enable the App ID/capabilities for your Apple Developer team in Signing & Capabilities. This cannot be provisioned from the Linux build environment used to prepare this archive.
|
||||
@@ -0,0 +1,3 @@
|
||||
# Phase 3.2 — iOS SonarQube Server Configuration
|
||||
|
||||
Adds full on-device server management to the iPhone/iPad target: add, edit, delete, select, test connection, multiple servers, and secure Keychain token storage. Existing tokens are never displayed. HTTPS is required except localhost loopback development URLs.
|
||||
@@ -25,3 +25,4 @@ _ide_helper.php
|
||||
Homestead.json
|
||||
Homestead.yaml
|
||||
Thumbs.db
|
||||
/.scannerwork/
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\CmsPage;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
final class CmsPageController extends Controller
|
||||
{
|
||||
public function index() { return view('admin.cms.index', ['pages' => CmsPage::latest()->paginate(25)]); }
|
||||
public function create() { return view('admin.cms.form', ['page' => new CmsPage()]); }
|
||||
public function store(Request $request) { $page = CmsPage::create($this->validated($request)); return redirect()->route('admin.cms.edit', $page)->with('status', 'Page created.'); }
|
||||
public function edit(CmsPage $page) { return view('admin.cms.form', compact('page')); }
|
||||
public function update(Request $request, CmsPage $page) { $page->update($this->validated($request, $page)); return back()->with('status', 'Page updated.'); }
|
||||
public function destroy(CmsPage $page) { $page->delete(); return redirect()->route('admin.cms.index')->with('status', 'Page deleted.'); }
|
||||
|
||||
private function validated(Request $request, ?CmsPage $page = null): array
|
||||
{
|
||||
$request->merge(['slug' => Str::slug($request->input('slug') ?: $request->input('title', ''))]);
|
||||
$data = $request->validate([
|
||||
'title' => ['required','string','max:160'],
|
||||
'slug' => ['required','string','max:180', Rule::unique('cms_pages','slug')->ignore($page?->id)],
|
||||
'excerpt' => ['nullable','string','max:1000'],
|
||||
'body' => ['required','string'],
|
||||
'meta_title' => ['nullable','string','max:160'],
|
||||
'meta_description' => ['nullable','string','max:320'],
|
||||
'meta_keywords' => ['nullable','string','max:500'],
|
||||
'canonical_url' => ['nullable','url:http,https','max:2048'],
|
||||
'og_title' => ['nullable','string','max:160'],
|
||||
'og_description' => ['nullable','string','max:320'],
|
||||
'og_image' => ['nullable','url:http,https','max:2048'],
|
||||
'sitemap_priority' => ['required','numeric','min:0','max:1'],
|
||||
'sitemap_change_frequency' => ['required', Rule::in(['always','hourly','daily','weekly','monthly','yearly','never'])],
|
||||
]);
|
||||
$data['robots_index'] = $request->boolean('robots_index');
|
||||
$data['robots_follow'] = $request->boolean('robots_follow');
|
||||
$data['include_in_sitemap'] = $request->boolean('include_in_sitemap');
|
||||
$data['is_published'] = $request->boolean('is_published');
|
||||
$data['published_at'] = $data['is_published'] ? ($page?->published_at ?? now()) : null;
|
||||
return $data;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\SiteSetting;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
|
||||
final class SeoSettingsController extends Controller
|
||||
{
|
||||
private const KEYS = [
|
||||
'seo_site_name', 'seo_title_suffix', 'seo_default_title', 'seo_default_description',
|
||||
'seo_default_keywords', 'seo_canonical_base_url', 'seo_default_og_image', 'seo_twitter_site',
|
||||
'seo_organization_name', 'seo_organization_logo', 'seo_google_site_verification',
|
||||
];
|
||||
|
||||
public function edit(): View
|
||||
{
|
||||
return view('admin.seo.edit', [
|
||||
'settings' => collect(self::KEYS)->mapWithKeys(fn (string $key) => [$key => SiteSetting::valueFor($key, '')]),
|
||||
'robotsEnabled' => SiteSetting::booleanFor('seo_robots_enabled', true),
|
||||
]);
|
||||
}
|
||||
|
||||
public function update(Request $request): RedirectResponse
|
||||
{
|
||||
$data = $request->validate([
|
||||
'seo_site_name' => ['required', 'string', 'max:80'],
|
||||
'seo_title_suffix' => ['nullable', 'string', 'max:80'],
|
||||
'seo_default_title' => ['required', 'string', 'max:160'],
|
||||
'seo_default_description' => ['required', 'string', 'max:320'],
|
||||
'seo_default_keywords' => ['nullable', 'string', 'max:500'],
|
||||
'seo_canonical_base_url' => ['nullable', 'url:http,https', 'max:2048'],
|
||||
'seo_default_og_image' => ['nullable', 'url:http,https', 'max:2048'],
|
||||
'seo_twitter_site' => ['nullable', 'string', 'max:40', 'regex:/^@?[A-Za-z0-9_]{1,15}$/'],
|
||||
'seo_organization_name' => ['nullable', 'string', 'max:120'],
|
||||
'seo_organization_logo' => ['nullable', 'url:http,https', 'max:2048'],
|
||||
'seo_google_site_verification' => ['nullable', 'string', 'max:255'],
|
||||
]);
|
||||
|
||||
foreach (self::KEYS as $key) {
|
||||
SiteSetting::put($key, isset($data[$key]) ? trim((string) $data[$key]) : null);
|
||||
}
|
||||
SiteSetting::put('seo_robots_enabled', $request->boolean('seo_robots_enabled') ? '1' : '0');
|
||||
|
||||
return back()->with('status', 'SEO settings updated.');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\CmsPage;
|
||||
use App\Models\SiteSetting;
|
||||
use Illuminate\Http\Response;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
final class SeoController extends Controller
|
||||
{
|
||||
public function sitemap(): Response
|
||||
{
|
||||
$static = collect([
|
||||
['route' => 'home', 'priority' => '1.0', 'frequency' => 'weekly'],
|
||||
['route' => 'features', 'priority' => '0.9', 'frequency' => 'monthly'],
|
||||
['route' => 'buy', 'priority' => '0.9', 'frequency' => 'monthly'],
|
||||
['route' => 'release', 'priority' => '0.8', 'frequency' => 'weekly'],
|
||||
['route' => 'support', 'priority' => '0.6', 'frequency' => 'monthly'],
|
||||
['route' => 'privacy', 'priority' => '0.3', 'frequency' => 'yearly'],
|
||||
['route' => 'terms', 'priority' => '0.3', 'frequency' => 'yearly'],
|
||||
['route' => 'refund', 'priority' => '0.3', 'frequency' => 'yearly'],
|
||||
['route' => 'cookies', 'priority' => '0.3', 'frequency' => 'yearly'],
|
||||
])->filter(fn (array $item) => Route::has($item['route']))->values();
|
||||
|
||||
$pages = CmsPage::query()
|
||||
->where('is_published', true)
|
||||
->where('include_in_sitemap', true)
|
||||
->where('robots_index', true)
|
||||
->where(fn ($query) => $query->whereNull('published_at')->orWhere('published_at', '<=', now()))
|
||||
->orderBy('slug')
|
||||
->get();
|
||||
|
||||
return response()
|
||||
->view('seo.sitemap', compact('static', 'pages'))
|
||||
->header('Content-Type', 'application/xml; charset=UTF-8')
|
||||
->header('Cache-Control', 'public, max-age=3600');
|
||||
}
|
||||
|
||||
public function robots(): Response
|
||||
{
|
||||
$indexable = app()->environment('production') && SiteSetting::booleanFor('seo_robots_enabled', true);
|
||||
|
||||
$body = $indexable
|
||||
? "User-agent: *\nAllow: /\nDisallow: /admin/\nDisallow: /portal/\nDisallow: /login\nDisallow: /register\n\nSitemap: ".route('sitemap')."\n"
|
||||
: "User-agent: *\nDisallow: /\n";
|
||||
|
||||
return response($body, 200, [
|
||||
'Content-Type' => 'text/plain; charset=UTF-8',
|
||||
'Cache-Control' => 'public, max-age=3600',
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
final class CmsPage extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'title', 'slug', 'excerpt', 'body', 'meta_title', 'meta_description', 'meta_keywords',
|
||||
'canonical_url', 'og_title', 'og_description', 'og_image', 'robots_index', 'robots_follow',
|
||||
'include_in_sitemap', 'sitemap_priority', 'sitemap_change_frequency', 'is_published', 'published_at',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'is_published' => 'boolean', 'published_at' => 'datetime', 'robots_index' => 'boolean',
|
||||
'robots_follow' => 'boolean', 'include_in_sitemap' => 'boolean', 'sitemap_priority' => 'decimal:1',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
|
||||
final class SiteSetting extends Model
|
||||
{
|
||||
protected $table = 'sonardesk_site_settings';
|
||||
protected $primaryKey = 'key';
|
||||
public $incrementing = false;
|
||||
protected $keyType = 'string';
|
||||
protected $fillable = ['key', 'value'];
|
||||
|
||||
public static function valueFor(string $key, ?string $default = null): ?string
|
||||
{
|
||||
return Cache::remember('sonardesk.setting.'.$key, 300, fn () => static::query()->find($key)?->value ?? $default);
|
||||
}
|
||||
|
||||
public static function booleanFor(string $key, bool $default = false): bool
|
||||
{
|
||||
$value = static::valueFor($key);
|
||||
return $value === null ? $default : filter_var($value, FILTER_VALIDATE_BOOLEAN);
|
||||
}
|
||||
|
||||
public static function put(string $key, ?string $value): void
|
||||
{
|
||||
static::query()->updateOrCreate(['key' => $key], ['value' => $value]);
|
||||
Cache::forget('sonardesk.setting.'.$key);
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\License;
|
||||
use Modules\Licensing\Models\License;
|
||||
use App\Models\User;
|
||||
use App\Services\Accounts\PurchasedAccountProvisioner;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\License;
|
||||
use App\Models\LicenseActivation;
|
||||
use Modules\Licensing\Models\License;
|
||||
use Modules\Licensing\Models\LicenseActivation;
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
|
||||
@@ -27,7 +27,17 @@ final class CmsPageController extends Controller
|
||||
'body' => ['required','string'],
|
||||
'meta_title' => ['nullable','string','max:160'],
|
||||
'meta_description' => ['nullable','string','max:320'],
|
||||
'meta_keywords' => ['nullable','string','max:500'],
|
||||
'canonical_url' => ['nullable','url:http,https','max:2048'],
|
||||
'og_title' => ['nullable','string','max:160'],
|
||||
'og_description' => ['nullable','string','max:320'],
|
||||
'og_image' => ['nullable','url:http,https','max:2048'],
|
||||
'sitemap_priority' => ['required','numeric','min:0','max:1'],
|
||||
'sitemap_change_frequency' => ['required', Rule::in(['always','hourly','daily','weekly','monthly','yearly','never'])],
|
||||
]);
|
||||
$data['robots_index'] = $request->boolean('robots_index');
|
||||
$data['robots_follow'] = $request->boolean('robots_follow');
|
||||
$data['include_in_sitemap'] = $request->boolean('include_in_sitemap');
|
||||
$data['is_published'] = $request->boolean('is_published');
|
||||
$data['published_at'] = $data['is_published'] ? ($page?->published_at ?? now()) : null;
|
||||
return $data;
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\SiteSetting;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
|
||||
final class SeoSettingsController extends Controller
|
||||
{
|
||||
private const KEYS = [
|
||||
'seo_site_name', 'seo_title_suffix', 'seo_default_title', 'seo_default_description',
|
||||
'seo_default_keywords', 'seo_canonical_base_url', 'seo_default_og_image', 'seo_twitter_site',
|
||||
'seo_organization_name', 'seo_organization_logo', 'seo_google_site_verification',
|
||||
];
|
||||
|
||||
public function edit(): View
|
||||
{
|
||||
return view('admin.seo.edit', [
|
||||
'settings' => collect(self::KEYS)->mapWithKeys(fn (string $key) => [$key => SiteSetting::valueFor($key, '')]),
|
||||
'robotsEnabled' => SiteSetting::booleanFor('seo_robots_enabled', true),
|
||||
]);
|
||||
}
|
||||
|
||||
public function update(Request $request): RedirectResponse
|
||||
{
|
||||
$data = $request->validate([
|
||||
'seo_site_name' => ['required', 'string', 'max:80'],
|
||||
'seo_title_suffix' => ['nullable', 'string', 'max:80'],
|
||||
'seo_default_title' => ['required', 'string', 'max:160'],
|
||||
'seo_default_description' => ['required', 'string', 'max:320'],
|
||||
'seo_default_keywords' => ['nullable', 'string', 'max:500'],
|
||||
'seo_canonical_base_url' => ['nullable', 'url:http,https', 'max:2048'],
|
||||
'seo_default_og_image' => ['nullable', 'url:http,https', 'max:2048'],
|
||||
'seo_twitter_site' => ['nullable', 'string', 'max:40', 'regex:/^@?[A-Za-z0-9_]{1,15}$/'],
|
||||
'seo_organization_name' => ['nullable', 'string', 'max:120'],
|
||||
'seo_organization_logo' => ['nullable', 'url:http,https', 'max:2048'],
|
||||
'seo_google_site_verification' => ['nullable', 'string', 'max:255'],
|
||||
]);
|
||||
|
||||
foreach (self::KEYS as $key) {
|
||||
SiteSetting::put($key, isset($data[$key]) ? trim((string) $data[$key]) : null);
|
||||
}
|
||||
SiteSetting::put('seo_robots_enabled', $request->boolean('seo_robots_enabled') ? '1' : '0');
|
||||
|
||||
return back()->with('status', 'SEO settings updated.');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Modules\Licensing\Models\License;
|
||||
use Modules\Licensing\Models\LicenseActivation;
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Password;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
final class UserController extends Controller
|
||||
{
|
||||
public function index(Request $request)
|
||||
{
|
||||
$query = User::query()->latest();
|
||||
|
||||
if ($search = trim((string) $request->query('q'))) {
|
||||
$query->where(fn ($q) => $q
|
||||
->where('name', 'like', "%{$search}%")
|
||||
->orWhere('email', 'like', "%{$search}%"));
|
||||
}
|
||||
|
||||
if ($request->query('status') === 'admin') {
|
||||
$query->where('is_admin', true);
|
||||
} elseif ($request->query('status') === 'suspended') {
|
||||
$query->whereNotNull('disabled_at');
|
||||
} elseif ($request->query('status') === 'unverified') {
|
||||
$query->whereNull('email_verified_at');
|
||||
}
|
||||
|
||||
$users = $query->paginate(50)->withQueryString();
|
||||
$emails = $users->getCollection()->pluck('email')->map(fn ($email) => strtolower($email))->all();
|
||||
$licenseCounts = License::query()
|
||||
->selectRaw('LOWER(customer_email) as email_key, COUNT(*) as aggregate')
|
||||
->whereIn(DB::raw('LOWER(customer_email)'), $emails)
|
||||
->groupBy(DB::raw('LOWER(customer_email)'))
|
||||
->pluck('aggregate', 'email_key');
|
||||
|
||||
return view('admin.users.index', compact('users', 'licenseCounts'));
|
||||
}
|
||||
|
||||
public function show(User $user)
|
||||
{
|
||||
$licenses = License::with(['product', 'activations'])
|
||||
->whereRaw('LOWER(customer_email) = ?', [strtolower($user->email)])
|
||||
->latest()
|
||||
->get();
|
||||
|
||||
return view('admin.users.show', compact('user', 'licenses'));
|
||||
}
|
||||
|
||||
public function update(Request $request, User $user)
|
||||
{
|
||||
$data = $request->validate([
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'email' => ['required', 'email', 'max:255', Rule::unique('users', 'email')->ignore($user->id)],
|
||||
'is_admin' => ['nullable', 'boolean'],
|
||||
]);
|
||||
|
||||
$oldEmail = strtolower($user->email);
|
||||
$newEmail = strtolower(trim($data['email']));
|
||||
$isSelf = $request->user()->is($user);
|
||||
|
||||
DB::transaction(function () use ($user, $data, $oldEmail, $newEmail, $isSelf): void {
|
||||
$user->name = trim($data['name']);
|
||||
$user->email = $newEmail;
|
||||
if (! $isSelf) {
|
||||
$user->is_admin = (bool) ($data['is_admin'] ?? false);
|
||||
}
|
||||
$user->save();
|
||||
|
||||
if ($oldEmail !== $newEmail) {
|
||||
License::whereRaw('LOWER(customer_email) = ?', [$oldEmail])
|
||||
->update(['customer_email' => $newEmail]);
|
||||
}
|
||||
});
|
||||
|
||||
return redirect()->route('admin.users.show', $user)->with('status', 'User updated.');
|
||||
}
|
||||
|
||||
public function verify(User $user)
|
||||
{
|
||||
if (! $user->hasVerifiedEmail()) {
|
||||
$user->markEmailAsVerified();
|
||||
}
|
||||
|
||||
return back()->with('status', 'Email marked as verified.');
|
||||
}
|
||||
|
||||
public function resendVerification(User $user)
|
||||
{
|
||||
if (! $user->hasVerifiedEmail()) {
|
||||
$user->sendEmailVerificationNotification();
|
||||
}
|
||||
|
||||
return back()->with('status', 'Verification email sent.');
|
||||
}
|
||||
|
||||
public function sendPasswordReset(User $user)
|
||||
{
|
||||
$status = Password::sendResetLink(['email' => $user->email]);
|
||||
|
||||
return $status === Password::RESET_LINK_SENT
|
||||
? back()->with('status', 'Password reset link sent.')
|
||||
: back()->withErrors(['email' => __($status)]);
|
||||
}
|
||||
|
||||
public function suspend(Request $request, User $user)
|
||||
{
|
||||
abort_if($request->user()->is($user), 422, 'You cannot suspend your own administrator account.');
|
||||
$user->forceFill(['disabled_at' => now(), 'remember_token' => null])->save();
|
||||
|
||||
return back()->with('status', 'User suspended.');
|
||||
}
|
||||
|
||||
public function restore(User $user)
|
||||
{
|
||||
$user->forceFill(['disabled_at' => null])->save();
|
||||
|
||||
return back()->with('status', 'User restored.');
|
||||
}
|
||||
|
||||
public function destroy(Request $request, User $user)
|
||||
{
|
||||
abort_if($request->user()->is($user), 422, 'You cannot delete your own administrator account.');
|
||||
$user->delete();
|
||||
|
||||
return redirect()->route('admin.users.index')->with('status', 'User deleted. Licences were retained.');
|
||||
}
|
||||
|
||||
public function updateLicense(Request $request, User $user, License $license)
|
||||
{
|
||||
$this->ensureOwnsLicense($user, $license);
|
||||
$data = $request->validate(['activation_limit' => ['required', 'integer', 'min:1', 'max:1000']]);
|
||||
$license->update(['activation_limit' => $data['activation_limit']]);
|
||||
|
||||
return back()->with('status', 'Activation limit updated.');
|
||||
}
|
||||
|
||||
public function deactivate(User $user, License $license, LicenseActivation $activation)
|
||||
{
|
||||
$this->ensureOwnsLicense($user, $license);
|
||||
abort_unless((string) $activation->license_id === (string) $license->id, 404);
|
||||
$activation->update(['deactivated_at' => now()]);
|
||||
|
||||
return back()->with('status', 'Device activation released.');
|
||||
}
|
||||
|
||||
private function ensureOwnsLicense(User $user, License $license): void
|
||||
{
|
||||
abort_unless(strtolower((string) $license->customer_email) === strtolower($user->email), 404);
|
||||
}
|
||||
}
|
||||
@@ -16,7 +16,7 @@ final class AuthenticatedSessionController extends Controller
|
||||
public function store(Request $request)
|
||||
{
|
||||
$credentials = $request->validate(['email' => ['required', 'email'], 'password' => ['required', 'string']]);
|
||||
if (! Auth::attempt(['email' => strtolower($credentials['email']), 'password' => $credentials['password']], $request->boolean('remember'))) {
|
||||
if (! Auth::attempt(['email' => strtolower($credentials['email']), 'password' => $credentials['password'], 'disabled_at' => null], $request->boolean('remember'))) {
|
||||
return back()->withErrors(['email' => 'The supplied credentials are invalid.'])->onlyInput('email');
|
||||
}
|
||||
$request->session()->regenerate();
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\CmsPage;
|
||||
use App\Models\SiteSetting;
|
||||
use Illuminate\Http\Response;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
final class SeoController extends Controller
|
||||
{
|
||||
public function sitemap(): Response
|
||||
{
|
||||
$static = collect([
|
||||
['route' => 'home', 'priority' => '1.0', 'frequency' => 'weekly'],
|
||||
['route' => 'features', 'priority' => '0.9', 'frequency' => 'monthly'],
|
||||
['route' => 'buy', 'priority' => '0.9', 'frequency' => 'monthly'],
|
||||
['route' => 'release', 'priority' => '0.8', 'frequency' => 'weekly'],
|
||||
['route' => 'support', 'priority' => '0.6', 'frequency' => 'monthly'],
|
||||
['route' => 'privacy', 'priority' => '0.3', 'frequency' => 'yearly'],
|
||||
['route' => 'terms', 'priority' => '0.3', 'frequency' => 'yearly'],
|
||||
['route' => 'refund', 'priority' => '0.3', 'frequency' => 'yearly'],
|
||||
['route' => 'cookies', 'priority' => '0.3', 'frequency' => 'yearly'],
|
||||
])->filter(fn (array $item) => Route::has($item['route']))->values();
|
||||
|
||||
$pages = CmsPage::query()
|
||||
->where('is_published', true)
|
||||
->where('include_in_sitemap', true)
|
||||
->where('robots_index', true)
|
||||
->where(fn ($query) => $query->whereNull('published_at')->orWhere('published_at', '<=', now()))
|
||||
->orderBy('slug')
|
||||
->get();
|
||||
|
||||
return response()
|
||||
->view('seo.sitemap', compact('static', 'pages'))
|
||||
->header('Content-Type', 'application/xml; charset=UTF-8')
|
||||
->header('Cache-Control', 'public, max-age=3600');
|
||||
}
|
||||
|
||||
public function robots(): Response
|
||||
{
|
||||
$indexable = app()->environment('production') && SiteSetting::booleanFor('seo_robots_enabled', true);
|
||||
|
||||
$body = $indexable
|
||||
? "User-agent: *\nAllow: /\nDisallow: /admin/\nDisallow: /portal/\nDisallow: /login\nDisallow: /register\n\nSitemap: ".route('sitemap')."\n"
|
||||
: "User-agent: *\nDisallow: /\n";
|
||||
|
||||
return response($body, 200, [
|
||||
'Content-Type' => 'text/plain; charset=UTF-8',
|
||||
'Cache-Control' => 'public, max-age=3600',
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -6,10 +6,17 @@ use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
final class CmsPage extends Model
|
||||
{
|
||||
protected $fillable = ['title', 'slug', 'excerpt', 'body', 'meta_title', 'meta_description', 'is_published', 'published_at'];
|
||||
protected $fillable = [
|
||||
'title', 'slug', 'excerpt', 'body', 'meta_title', 'meta_description', 'meta_keywords',
|
||||
'canonical_url', 'og_title', 'og_description', 'og_image', 'robots_index', 'robots_follow',
|
||||
'include_in_sitemap', 'sitemap_priority', 'sitemap_change_frequency', 'is_published', 'published_at',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return ['is_published' => 'boolean', 'published_at' => 'datetime'];
|
||||
return [
|
||||
'is_published' => 'boolean', 'published_at' => 'datetime', 'robots_index' => 'boolean',
|
||||
'robots_follow' => 'boolean', 'include_in_sitemap' => 'boolean', 'sitemap_priority' => 'decimal:1',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,12 @@ final class SiteSetting extends Model
|
||||
return Cache::remember('sonardesk.setting.'.$key, 300, fn () => static::query()->find($key)?->value ?? $default);
|
||||
}
|
||||
|
||||
public static function booleanFor(string $key, bool $default = false): bool
|
||||
{
|
||||
$value = static::valueFor($key);
|
||||
return $value === null ? $default : filter_var($value, FILTER_VALIDATE_BOOLEAN);
|
||||
}
|
||||
|
||||
public static function put(string $key, ?string $value): void
|
||||
{
|
||||
static::query()->updateOrCreate(['key' => $key], ['value' => $value]);
|
||||
|
||||
@@ -11,12 +11,19 @@ class User extends Authenticatable implements MustVerifyEmail
|
||||
{
|
||||
use HasFactory, Notifiable;
|
||||
|
||||
protected $fillable = ['name', 'email', 'password', 'is_admin'];
|
||||
protected $fillable = [
|
||||
'name',
|
||||
'email',
|
||||
'email_verified_at',
|
||||
'password',
|
||||
'is_admin',
|
||||
'disabled_at',
|
||||
];
|
||||
|
||||
protected $hidden = ['password', 'remember_token'];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return ['email_verified_at' => 'datetime', 'password' => 'hashed', 'is_admin' => 'boolean'];
|
||||
return ['email_verified_at' => 'datetime', 'password' => 'hashed', 'is_admin' => 'boolean', 'disabled_at' => 'datetime'];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
namespace App\Providers;
|
||||
|
||||
use Illuminate\Support\Facades\Blade;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
|
||||
class AppServiceProvider extends ServiceProvider
|
||||
{
|
||||
public function boot(): void
|
||||
{
|
||||
Blade::anonymousComponentPath(
|
||||
resource_path('views/components/layouts'),
|
||||
'layouts'
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Providers;
|
||||
|
||||
use Illuminate\Cache\RateLimiting\Limit;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\RateLimiter;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
|
||||
final class SonarDeskLicensingServiceProvider extends ServiceProvider
|
||||
{
|
||||
public function boot(): void
|
||||
{
|
||||
RateLimiter::for('license-activation', function (Request $request): array {
|
||||
return [
|
||||
Limit::perMinute(12)->by($request->ip()),
|
||||
Limit::perMinute(6)->by(hash('sha256', (string) $request->input('installationID'))),
|
||||
];
|
||||
});
|
||||
|
||||
RateLimiter::for('login', fn (Request $request) => Limit::perMinute(5)
|
||||
->by(strtolower((string) $request->input('email')).'|'.$request->ip()));
|
||||
|
||||
RateLimiter::for('registration', fn (Request $request) => Limit::perHour(5)->by($request->ip()));
|
||||
}
|
||||
}
|
||||
@@ -28,7 +28,6 @@ final class PurchasedAccountProvisioner
|
||||
* Paddle can deliver several related webhook events concurrently, so
|
||||
* this lookup alone cannot guarantee that another request will not
|
||||
* create the user between this SELECT and our INSERT.
|
||||
* enure this is pushed
|
||||
*/
|
||||
$user = $this->findByEmail($email);
|
||||
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
<?php
|
||||
|
||||
use App\Providers\SonarDeskLicensingServiceProvider;
|
||||
|
||||
return [
|
||||
SonarDeskLicensingServiceProvider::class,
|
||||
App\Providers\AppServiceProvider::class,
|
||||
];
|
||||
|
||||
@@ -3,13 +3,31 @@
|
||||
"name": "laravel/laravel",
|
||||
"type": "project",
|
||||
"description": "The skeleton application for the Laravel framework.",
|
||||
"keywords": ["laravel", "framework"],
|
||||
"keywords": [
|
||||
"laravel",
|
||||
"framework"
|
||||
],
|
||||
"license": "MIT",
|
||||
"repositories": [
|
||||
{
|
||||
"type": "vcs",
|
||||
"url": "git@github.com:MPBA-Ltd/laravel_modules.git"
|
||||
},
|
||||
{
|
||||
"type": "vcs",
|
||||
"url": "git@github.com:MPBA-Ltd/laravel_settings.git"
|
||||
},
|
||||
{
|
||||
"type": "vcs",
|
||||
"url": "git@github.com:MPBA-Ltd/laravel-charts.git"
|
||||
}
|
||||
],
|
||||
"require": {
|
||||
"php": "^8.3",
|
||||
"deployer/deployer": "^8.0",
|
||||
"laravel/framework": "^13.17",
|
||||
"laravel/tinker": "^3.0",
|
||||
"mpba/laravel-modules": "dev-main",
|
||||
"predis/predis": "^3.6"
|
||||
},
|
||||
"require-dev": {
|
||||
@@ -25,7 +43,8 @@
|
||||
"psr-4": {
|
||||
"App\\": "app/",
|
||||
"Database\\Factories\\": "database/factories/",
|
||||
"Database\\Seeders\\": "database/seeders/"
|
||||
"Database\\Seeders\\": "database/seeders/",
|
||||
"Modules\\": "modules/"
|
||||
}
|
||||
},
|
||||
"autoload-dev": {
|
||||
|
||||
Generated
+110
-13
@@ -4,7 +4,7 @@
|
||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||
"This file is @generated automatically"
|
||||
],
|
||||
"content-hash": "1f5b5a47e5910fdf5b2c47e1bc45e1b0",
|
||||
"content-hash": "d92f21f84e06e667b70468859bfaffd0",
|
||||
"packages": [
|
||||
{
|
||||
"name": "brick/math",
|
||||
@@ -1542,16 +1542,16 @@
|
||||
},
|
||||
{
|
||||
"name": "league/commonmark",
|
||||
"version": "2.10.1",
|
||||
"version": "2.10.3",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/thephpleague/commonmark.git",
|
||||
"reference": "9d489ab67a02960fd8ffe624d93f751daf95439e"
|
||||
"reference": "6efbd9c472b91db0a3350fcd601c8332c2382e1f"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/thephpleague/commonmark/zipball/9d489ab67a02960fd8ffe624d93f751daf95439e",
|
||||
"reference": "9d489ab67a02960fd8ffe624d93f751daf95439e",
|
||||
"url": "https://api.github.com/repos/thephpleague/commonmark/zipball/6efbd9c472b91db0a3350fcd601c8332c2382e1f",
|
||||
"reference": "6efbd9c472b91db0a3350fcd601c8332c2382e1f",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
@@ -1645,7 +1645,7 @@
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2026-09-07T13:44:26+00:00"
|
||||
"time": "2026-09-21T13:07:34+00:00"
|
||||
},
|
||||
{
|
||||
"name": "league/config",
|
||||
@@ -2256,6 +2256,101 @@
|
||||
],
|
||||
"time": "2026-09-02T12:39:56+00:00"
|
||||
},
|
||||
{
|
||||
"name": "mpba/laravel-modules",
|
||||
"version": "dev-main",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/MPBA-Ltd/laravel_modules.git",
|
||||
"reference": "55d802560f6404d6b9fd71a89561ed46d32124ff"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/MPBA-Ltd/laravel_modules/zipball/55d802560f6404d6b9fd71a89561ed46d32124ff",
|
||||
"reference": "55d802560f6404d6b9fd71a89561ed46d32124ff",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"ext-json": "*",
|
||||
"php": ">=7.3"
|
||||
},
|
||||
"require-dev": {
|
||||
"friendsofphp/php-cs-fixer": "^2.16",
|
||||
"laravel/framework": "^8.0",
|
||||
"mockery/mockery": "~1.0",
|
||||
"orchestra/testbench": "^6.0",
|
||||
"phpstan/phpstan": "^0.12.14",
|
||||
"phpunit/phpunit": "^8.5",
|
||||
"spatie/phpunit-snapshot-assertions": "^2.1.0"
|
||||
},
|
||||
"default-branch": true,
|
||||
"type": "library",
|
||||
"extra": {
|
||||
"laravel": {
|
||||
"providers": [
|
||||
"mpba\\Modules\\LaravelModulesServiceProvider"
|
||||
],
|
||||
"aliases": {
|
||||
"Module": "mpba\\Modules\\Facades\\Module"
|
||||
}
|
||||
},
|
||||
"branch-alias": {
|
||||
"dev-master": "8.0-dev"
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"mpba\\Modules\\": "src"
|
||||
},
|
||||
"files": [
|
||||
"src/helpers.php"
|
||||
]
|
||||
},
|
||||
"autoload-dev": {
|
||||
"psr-4": {
|
||||
"mpba\\Modules\\Tests\\": "tests",
|
||||
"Modules\\Recipe\\": "tests/stubs/valid/Recipe"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"update-snapshots": [
|
||||
"./vendor/bin/phpunit --no-coverage -d --update-snapshots"
|
||||
],
|
||||
"test": [
|
||||
"vendor/bin/phpunit"
|
||||
],
|
||||
"test-coverage": [
|
||||
"vendor/bin/phpunit --debug --coverage-html coverage"
|
||||
],
|
||||
"pcf": [
|
||||
"vendor/bin/php-cs-fixer fix --verbose"
|
||||
]
|
||||
},
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Mark Gregory",
|
||||
"email": "mark.gregory@mpba.co.uk",
|
||||
"homepage": "https://mpba.co.uk",
|
||||
"role": "Developer"
|
||||
}
|
||||
],
|
||||
"description": "Laravel Module management",
|
||||
"keywords": [
|
||||
"laravel",
|
||||
"module",
|
||||
"modules",
|
||||
"mpba",
|
||||
"rad"
|
||||
],
|
||||
"support": {
|
||||
"source": "https://github.com/MPBA-Ltd/laravel_modules/tree/main",
|
||||
"issues": "https://github.com/MPBA-Ltd/laravel_modules/issues"
|
||||
},
|
||||
"time": "2026-09-30T13:50:27+00:00"
|
||||
},
|
||||
{
|
||||
"name": "nesbot/carbon",
|
||||
"version": "3.13.2",
|
||||
@@ -4875,16 +4970,16 @@
|
||||
},
|
||||
{
|
||||
"name": "symfony/polyfill-php80",
|
||||
"version": "v1.37.0",
|
||||
"version": "v1.43.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/symfony/polyfill-php80.git",
|
||||
"reference": "dfb55726c3a76ea3b6459fcfda1ec2d80a682411"
|
||||
"reference": "9c6a5d6b01ca51d486c813e9a9c0ed55f208bb74"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/dfb55726c3a76ea3b6459fcfda1ec2d80a682411",
|
||||
"reference": "dfb55726c3a76ea3b6459fcfda1ec2d80a682411",
|
||||
"url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/9c6a5d6b01ca51d486c813e9a9c0ed55f208bb74",
|
||||
"reference": "9c6a5d6b01ca51d486c813e9a9c0ed55f208bb74",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
@@ -4935,7 +5030,7 @@
|
||||
"shim"
|
||||
],
|
||||
"support": {
|
||||
"source": "https://github.com/symfony/polyfill-php80/tree/v1.37.0"
|
||||
"source": "https://github.com/symfony/polyfill-php80/tree/v1.43.0"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
@@ -4955,7 +5050,7 @@
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2026-04-10T16:19:22+00:00"
|
||||
"time": "2026-09-25T15:50:05+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/polyfill-php82",
|
||||
@@ -8709,7 +8804,9 @@
|
||||
],
|
||||
"aliases": [],
|
||||
"minimum-stability": "stable",
|
||||
"stability-flags": [],
|
||||
"stability-flags": {
|
||||
"mpba/laravel-modules": 20
|
||||
},
|
||||
"prefer-stable": true,
|
||||
"prefer-lowest": false,
|
||||
"platform": {
|
||||
|
||||
@@ -28,21 +28,6 @@ return [
|
||||
'notify_admin_users' => filter_var(env('SONARDESK_SUPPORT_NOTIFY_ADMINS', true), FILTER_VALIDATE_BOOL),
|
||||
],
|
||||
|
||||
'paddle' => [
|
||||
'api_key' => env('PADDLE_API_KEY'),
|
||||
'client_side_token' => env('PADDLE_CLIENT_SIDE_TOKEN'),
|
||||
'environment' => env('PADDLE_ENVIRONMENT', env('APP_ENV') === 'production' ? 'production' : 'sandbox'),
|
||||
'api_url' => env('PADDLE_API_URL', env('APP_ENV') === 'production' ? 'https://api.paddle.com' : 'https://sandbox-api.paddle.com'),
|
||||
'webhook_secret' => env('PADDLE_WEBHOOK_SECRET'),
|
||||
'timestamp_tolerance' => (int) env('PADDLE_WEBHOOK_TOLERANCE', 300),
|
||||
|
||||
// SonarDesk is one Paddle product with four recurring prices.
|
||||
'product_id' => env('PADDLE_PRODUCT_ID'),
|
||||
'personal_monthly_price_id' => env('PADDLE_PERSONAL_MONTHLY_PRICE_ID'),
|
||||
'personal_annual_price_id' => env('PADDLE_PERSONAL_ANNUAL_PRICE_ID'),
|
||||
'professional_monthly_price_id' => env('PADDLE_PROFESSIONAL_MONTHLY_PRICE_ID'),
|
||||
'professional_annual_price_id' => env('PADDLE_PROFESSIONAL_ANNUAL_PRICE_ID'),
|
||||
],
|
||||
|
||||
'lemon_squeezy' => [
|
||||
'webhook_secret' => env('LEMON_SQUEEZY_WEBHOOK_SECRET'),
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
* @extends Factory<\App\Models\User>
|
||||
*/
|
||||
class UserFactory extends Factory
|
||||
{
|
||||
protected static ?string $password;
|
||||
|
||||
public function definition(): array
|
||||
{
|
||||
return [
|
||||
'name' => fake()->name(),
|
||||
'email' => fake()->unique()->safeEmail(),
|
||||
'email_verified_at' => now(),
|
||||
'password' => static::$password ??= Hash::make('password'),
|
||||
'remember_token' => Str::random(10),
|
||||
'is_admin' => false,
|
||||
];
|
||||
}
|
||||
|
||||
public function unverified(): static
|
||||
{
|
||||
return $this->state(fn (array $attributes) => [
|
||||
'email_verified_at' => null,
|
||||
]);
|
||||
}
|
||||
}
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration {
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('cms_pages', function (Blueprint $table): void {
|
||||
$table->string('meta_keywords', 500)->nullable()->after('meta_description');
|
||||
$table->string('canonical_url', 2048)->nullable()->after('meta_keywords');
|
||||
$table->string('og_title')->nullable()->after('canonical_url');
|
||||
$table->string('og_description', 320)->nullable()->after('og_title');
|
||||
$table->string('og_image', 2048)->nullable()->after('og_description');
|
||||
$table->boolean('robots_index')->default(true)->after('og_image');
|
||||
$table->boolean('robots_follow')->default(true)->after('robots_index');
|
||||
$table->boolean('include_in_sitemap')->default(true)->after('robots_follow')->index();
|
||||
$table->decimal('sitemap_priority', 2, 1)->default(0.5)->after('include_in_sitemap');
|
||||
$table->string('sitemap_change_frequency', 20)->default('monthly')->after('sitemap_priority');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('cms_pages', function (Blueprint $table): void {
|
||||
$table->dropIndex(['include_in_sitemap']);
|
||||
$table->dropColumn([
|
||||
'meta_keywords', 'canonical_url', 'og_title', 'og_description', 'og_image',
|
||||
'robots_index', 'robots_follow', 'include_in_sitemap', 'sitemap_priority',
|
||||
'sitemap_change_frequency',
|
||||
]);
|
||||
});
|
||||
}
|
||||
};
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
if (Schema::hasTable('users') && ! Schema::hasColumn('users', 'disabled_at')) {
|
||||
Schema::table('users', fn (Blueprint $table) => $table->timestamp('disabled_at')->nullable()->index()->after('is_admin'));
|
||||
}
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
if (Schema::hasTable('users') && Schema::hasColumn('users', 'disabled_at')) {
|
||||
Schema::table('users', fn (Blueprint $table) => $table->dropColumn('disabled_at'));
|
||||
}
|
||||
}
|
||||
};
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('failed_jobs', function (Blueprint $table): void {
|
||||
$table->id();
|
||||
$table->string('uuid')->unique();
|
||||
$table->text('connection');
|
||||
$table->text('queue');
|
||||
$table->longText('payload');
|
||||
$table->longText('exception');
|
||||
$table->timestamp('failed_at')->useCurrent();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('failed_jobs');
|
||||
}
|
||||
};
|
||||
@@ -19,13 +19,13 @@ add('writable_dirs', ['storage', 'bootstrap/cache']);
|
||||
|
||||
// Host Definitions
|
||||
host('main')
|
||||
->set('hostname', 'sonardesk.absdev.net')
|
||||
->set('hostname', '217.154.47.197')
|
||||
->set('remote_user', 'mag')
|
||||
->set('branch', 'main')
|
||||
->set('deploy_path', '/var/www/sonardesk/main');
|
||||
->set('deploy_path', '/var/www/sonardesk/prod');
|
||||
|
||||
host('stage')
|
||||
->set('hostname', 'sonardesk.stage.absdev.net')
|
||||
->set('hostname', '192.168.1.2')
|
||||
->set('remote_user', 'mag')
|
||||
->set('branch', 'stage')
|
||||
->set('deploy_path', '/var/www/sonardesk/stage');
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
'name' => 'Licensing',
|
||||
'features' =>
|
||||
[
|
||||
],
|
||||
];
|
||||
+2
-2
@@ -1,8 +1,8 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
namespace Modules\Licensing\Console;
|
||||
|
||||
use App\Services\Licensing\LicenseDeliveryService;
|
||||
use Modules\Licensing\Services\LicenseDeliveryService;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
final class SonarDeskLicenseDeliver extends Command
|
||||
+3
-3
@@ -1,9 +1,9 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
namespace Modules\Licensing\Console;
|
||||
|
||||
use App\Models\LicenseProduct;
|
||||
use App\Services\Licensing\LicenseIssuer;
|
||||
use Modules\Licensing\Models\LicenseProduct;
|
||||
use Modules\Licensing\Services\LicenseIssuer;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class SonarDeskLicenseIssue extends Command
|
||||
+2
-2
@@ -1,8 +1,8 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
namespace Modules\Licensing\Console;
|
||||
|
||||
use App\Models\License;
|
||||
use Modules\Licensing\Models\License;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class SonarDeskLicenseRevoke extends Command
|
||||
+2
-2
@@ -1,8 +1,8 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
namespace Modules\Licensing\Console;
|
||||
|
||||
use App\Models\LicenseProduct;
|
||||
use Modules\Licensing\Models\LicenseProduct;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class SonarDeskProductCreate extends Command
|
||||
+92
@@ -0,0 +1,92 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('license_products', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('code')->unique();
|
||||
$table->string('name');
|
||||
$table->string('edition')->default('Professional');
|
||||
$table->unsignedInteger('activation_limit')->default(1);
|
||||
$table->unsignedInteger('max_major_version')->nullable();
|
||||
$table->string('payment_provider')->nullable();
|
||||
$table->string('external_product_id')->nullable();
|
||||
$table->string('external_price_id')->nullable();
|
||||
$table->boolean('active')->default(true);
|
||||
$table->timestamps();
|
||||
$table->unique(['payment_provider', 'external_price_id']);
|
||||
});
|
||||
|
||||
Schema::create('licenses', function (Blueprint $table) {
|
||||
$table->uuid('id')->primary();
|
||||
$table->foreignId('license_product_id')->constrained('license_products');
|
||||
$table->string('license_key_hash', 64)->unique();
|
||||
$table->string('license_key_last4', 4);
|
||||
$table->string('customer_name')->nullable();
|
||||
$table->string('customer_email')->nullable()->index();
|
||||
$table->string('edition');
|
||||
$table->unsignedInteger('activation_limit');
|
||||
$table->unsignedInteger('max_major_version')->nullable();
|
||||
$table->timestamp('expires_at')->nullable();
|
||||
$table->timestamp('revoked_at')->nullable();
|
||||
$table->string('revoke_reason')->nullable();
|
||||
$table->string('payment_provider')->nullable();
|
||||
$table->string('external_order_id')->nullable();
|
||||
$table->string('external_customer_id')->nullable();
|
||||
$table->timestamps();
|
||||
$table->unique(['payment_provider', 'external_order_id']);
|
||||
});
|
||||
|
||||
Schema::create('license_delivery_outbox', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->uuid('license_id')->unique();
|
||||
$table->text('encrypted_license_key');
|
||||
$table->string('recipient_email')->nullable();
|
||||
$table->timestamp('delivered_at')->nullable();
|
||||
$table->timestamps();
|
||||
$table->foreign('license_id')->references('id')->on('licenses')->cascadeOnDelete();
|
||||
});
|
||||
|
||||
Schema::create('license_activations', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->uuid('license_id');
|
||||
$table->uuid('installation_id');
|
||||
$table->string('platform', 32)->nullable();
|
||||
$table->string('app_version', 32)->nullable();
|
||||
$table->timestamp('activated_at');
|
||||
$table->timestamp('last_seen_at')->nullable();
|
||||
$table->timestamp('deactivated_at')->nullable();
|
||||
$table->foreign('license_id')->references('id')->on('licenses')->cascadeOnDelete();
|
||||
$table->unique(['license_id', 'installation_id']);
|
||||
$table->index(['license_id', 'deactivated_at']);
|
||||
});
|
||||
|
||||
Schema::create('license_webhook_events', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('provider');
|
||||
$table->string('external_event_id');
|
||||
$table->string('event_type');
|
||||
$table->string('payload_sha256', 64);
|
||||
$table->json('payload');
|
||||
$table->timestamp('processed_at')->nullable();
|
||||
$table->text('processing_error')->nullable();
|
||||
$table->timestamps();
|
||||
$table->unique(['provider', 'external_event_id']);
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('license_webhook_events');
|
||||
Schema::dropIfExists('license_activations');
|
||||
Schema::dropIfExists('license_delivery_outbox');
|
||||
Schema::dropIfExists('licenses');
|
||||
Schema::dropIfExists('license_products');
|
||||
}
|
||||
};
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
if (Schema::hasTable('users') && ! Schema::hasColumn('users', 'is_admin')) {
|
||||
Schema::table('users', fn (Blueprint $table) => $table->boolean('is_admin')->default(false)->index());
|
||||
}
|
||||
|
||||
if (Schema::hasTable('license_activations') && ! Schema::hasColumn('license_activations', 'installation_name')) {
|
||||
Schema::table('license_activations', fn (Blueprint $table) => $table->string('installation_name')->nullable()->after('installation_id'));
|
||||
}
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
if (Schema::hasTable('license_activations') && Schema::hasColumn('license_activations', 'installation_name')) {
|
||||
Schema::table('license_activations', fn (Blueprint $table) => $table->dropColumn('installation_name'));
|
||||
}
|
||||
|
||||
if (Schema::hasTable('users') && Schema::hasColumn('users', 'is_admin')) {
|
||||
Schema::table('users', fn (Blueprint $table) => $table->dropColumn('is_admin'));
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Licensing\Database\Seeders;
|
||||
|
||||
use Illuminate\Database\Seeder;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class LicensingDatabaseSeeder extends Seeder
|
||||
{
|
||||
/**
|
||||
* Run the database seeds.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function run()
|
||||
{
|
||||
Model::unguard();
|
||||
|
||||
// $this->call("OthersTableSeeder");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
<?php
|
||||
use Modules\Licensing\Support\ModuleInfo;
|
||||
|
||||
if (! function_exists('licensing_feature_enabled')) {
|
||||
/**
|
||||
* Check if a feature is enabled in the config.
|
||||
*/
|
||||
function licensing_feature_enabled(string $key): bool
|
||||
{
|
||||
return (bool) config(ModuleInfo::nameLower().".features.{$key}", false);
|
||||
}
|
||||
}
|
||||
+6
-6
@@ -1,13 +1,13 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
namespace Modules\Licensing\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\License;
|
||||
use App\Models\LicenseActivation;
|
||||
use App\Services\Licensing\LicenseKey;
|
||||
use App\Services\Licensing\LicenseSigner;
|
||||
use App\Services\Licensing\LicenseTokenVerifier;
|
||||
use Modules\Licensing\Models\License;
|
||||
use Modules\Licensing\Models\LicenseActivation;
|
||||
use Modules\Licensing\Services\LicenseKey;
|
||||
use Modules\Licensing\Services\LicenseSigner;
|
||||
use Modules\Licensing\Services\LicenseTokenVerifier;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
+5
-5
@@ -1,13 +1,13 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
namespace Modules\Licensing\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\LicenseWebhookEvent;
|
||||
use App\Services\Licensing\LicenseDeliveryService;
|
||||
use Modules\Licensing\Models\LicenseWebhookEvent;
|
||||
use Modules\Licensing\Services\LicenseDeliveryService;
|
||||
use App\Services\Payments\LemonSqueezyWebhookVerifier;
|
||||
use App\Services\Payments\PaddleWebhookVerifier;
|
||||
use App\Services\Payments\PurchaseProvisioner;
|
||||
use Modules\Paddle\Services\PaddleWebhookVerifier;
|
||||
use Modules\Paddle\Services\PurchaseProvisioner;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
@@ -0,0 +1,79 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Licensing\Http\Controllers;
|
||||
|
||||
use Illuminate\Contracts\Support\Renderable;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Routing\Controller;
|
||||
|
||||
class LicensingController extends Controller
|
||||
{
|
||||
/**
|
||||
* Display a listing of the resource.
|
||||
* @return Renderable
|
||||
*/
|
||||
public function index()
|
||||
{
|
||||
return view('licensing::index');
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the form for creating a new resource.
|
||||
* @return Renderable
|
||||
*/
|
||||
public function create()
|
||||
{
|
||||
return view('licensing::create');
|
||||
}
|
||||
|
||||
/**
|
||||
* Store a newly created resource in storage.
|
||||
* @param Request $request
|
||||
* @return Renderable
|
||||
*/
|
||||
public function store(Request $request)
|
||||
{
|
||||
//
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the specified resource.
|
||||
* @param int $id
|
||||
* @return Renderable
|
||||
*/
|
||||
public function show($id)
|
||||
{
|
||||
return view('licensing::show');
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the form for editing the specified resource.
|
||||
* @param int $id
|
||||
* @return Renderable
|
||||
*/
|
||||
public function edit($id)
|
||||
{
|
||||
return view('licensing::edit');
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the specified resource in storage.
|
||||
* @param Request $request
|
||||
* @param int $id
|
||||
* @return Renderable
|
||||
*/
|
||||
public function update(Request $request, $id)
|
||||
{
|
||||
//
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the specified resource from storage.
|
||||
* @param int $id
|
||||
* @return Renderable
|
||||
*/
|
||||
public function destroy($id)
|
||||
{
|
||||
//
|
||||
}
|
||||
}
|
||||
+7
-7
@@ -1,13 +1,13 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Portal;
|
||||
namespace Modules\Licensing\Http\Controllers\Portal;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\License;
|
||||
use App\Models\LicenseDeliveryOutbox;
|
||||
use App\Models\LicenseProduct;
|
||||
use App\Services\Payments\PaddleClient;
|
||||
use App\Services\Licensing\LicenseDeliveryService;
|
||||
use Modules\Licensing\Models\License;
|
||||
use Modules\Licensing\Models\LicenseDeliveryOutbox;
|
||||
use Modules\Licensing\Models\LicenseProduct;
|
||||
use Modules\Paddle\Services\PaddleClient;
|
||||
use Modules\Licensing\Services\LicenseDeliveryService;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use RuntimeException;
|
||||
@@ -131,7 +131,7 @@ final class PortalController extends Controller
|
||||
|
||||
private function professionalPriceFor(License $license): string
|
||||
{
|
||||
$paddle = config('sonardesk.paddle');
|
||||
$paddle = config('paddle');
|
||||
$current = (string) $license->external_price_id;
|
||||
|
||||
if ($current === (string) ($paddle['personal_annual_price_id'] ?? '')) {
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
namespace Modules\Licensing\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Concerns\HasUuids;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
namespace Modules\Licensing\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
namespace Modules\Licensing\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
namespace Modules\Licensing\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
namespace Modules\Licensing\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Licensing\Providers;
|
||||
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
use Illuminate\Cache\RateLimiting\Limit;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\RateLimiter;
|
||||
use Illuminate\Database\Eloquent\Factory;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Modules\Licensing\Support\ModuleInfo;
|
||||
use Config;
|
||||
|
||||
class LicensingServiceProvider extends ServiceProvider
|
||||
{
|
||||
/**
|
||||
* @var string $moduleName
|
||||
*/
|
||||
protected string $moduleName = 'Licensing';
|
||||
|
||||
/**
|
||||
* @var string $moduleNameLower
|
||||
*/
|
||||
protected string $moduleNameLower = 'licensing';
|
||||
|
||||
|
||||
/**
|
||||
* Get the services provided by the provider.
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
public function provides():array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Boot the application events.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function boot() : void
|
||||
{
|
||||
RateLimiter::for('login', fn (Request $request) => Limit::perMinute(5)
|
||||
->by(strtolower((string) $request->input('email')).'|'.$request->ip()));
|
||||
|
||||
RateLimiter::for('registration', fn (Request $request) =>
|
||||
Limit::perHour(5)->by($request->ip())
|
||||
);
|
||||
|
||||
RateLimiter::for('license-activation', function (Request $request): array {
|
||||
return [
|
||||
Limit::perMinute(12)->by($request->ip()),
|
||||
Limit::perMinute(6)->by(hash('sha256', (string) $request->input('installationID'))),
|
||||
];
|
||||
});
|
||||
$this->registerTranslations();
|
||||
$this->registerConfig();
|
||||
$this->registerViews();
|
||||
$this->registerHelpers();
|
||||
$this->registerCommands();
|
||||
$this->loadMigrationsFrom(module_path(ModuleInfo::name(), 'Database/Migrations'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the service provider.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function register(): void
|
||||
{
|
||||
$this->app->register(RouteServiceProvider::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the helpers.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
protected function registerHelpers(): void
|
||||
{
|
||||
foreach (glob(__DIR__.'/../Helpers/*.php') as $filename) {
|
||||
require_once $filename;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Dynamically Register Commands.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function registerCommands(): void
|
||||
{
|
||||
if (!$this->app->runningInConsole()) {
|
||||
return;
|
||||
}
|
||||
$commandPath = __DIR__ . '/../Console';
|
||||
$commands = collect(File::allFiles($commandPath))
|
||||
->map(function ($file) {
|
||||
$class = 'Modules\\' . $this->moduleName . '\\Console\\' . $file->getFilenameWithoutExtension();
|
||||
return $class;
|
||||
})
|
||||
->filter(function ($class) {
|
||||
return class_exists($class);
|
||||
})
|
||||
->values()
|
||||
->all();
|
||||
$this->commands($commands);
|
||||
}
|
||||
|
||||
/**
|
||||
* Register config.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
protected function registerConfig(): void
|
||||
{
|
||||
$this->publishes([
|
||||
module_path(ModuleInfo::name(), 'Config/config.php') => config_path( ModuleInfo::nameLower(). '.php'),
|
||||
], 'config');
|
||||
$this->mergeConfigFrom(
|
||||
module_path(ModuleInfo::name(), 'Config/config.php'), ModuleInfo::nameLower()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Register views.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function registerViews(): void
|
||||
{
|
||||
$viewPath = resource_path('views/modules/' . ModuleInfo::nameLower());
|
||||
$sourcePath = module_path(ModuleInfo::name(), 'Resources/views');
|
||||
$this->publishes([
|
||||
$sourcePath => $viewPath
|
||||
], ['views', ModuleInfo::nameLower() . '-module-views']);
|
||||
$this->loadViewsFrom(array_merge($this->getPublishableViewPaths(), [$sourcePath]), ModuleInfo::nameLower());
|
||||
}
|
||||
|
||||
/**
|
||||
* Register translations.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function registerTranslations(): void
|
||||
{
|
||||
$langPath = resource_path('lang/modules/' . ModuleInfo::nameLower());
|
||||
|
||||
if (is_dir($langPath)) {
|
||||
$this->loadTranslationsFrom($langPath, ModuleInfo::nameLower());
|
||||
} else {
|
||||
$this->loadTranslationsFrom(module_path(ModuleInfo::name(), 'Resources/lang'), ModuleInfo::nameLower());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Regsiter View Paths
|
||||
* @return array
|
||||
*/
|
||||
private function getPublishableViewPaths(): array
|
||||
{
|
||||
$paths = [];
|
||||
foreach (Config::get('view.paths') as $path) {
|
||||
if (is_dir($path . '/modules/' . ModuleInfo::nameLower())) {
|
||||
$paths[] = $path . '/modules/' . ModuleInfo::nameLower();
|
||||
}
|
||||
}
|
||||
return $paths;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Licensing\Providers;
|
||||
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use Illuminate\Foundation\Support\Providers\RouteServiceProvider as ServiceProvider;
|
||||
|
||||
class RouteServiceProvider extends ServiceProvider
|
||||
{
|
||||
/**
|
||||
* The module namespace to assume when generating URLs to actions.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $moduleNamespace = 'Modules\Licensing\Http\Controllers';
|
||||
|
||||
/**
|
||||
* Called before routes are registered.
|
||||
*
|
||||
* Register any model bindings or pattern based filters.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function boot()
|
||||
{
|
||||
parent::boot();
|
||||
}
|
||||
|
||||
/**
|
||||
* Define the routes for the application.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function map()
|
||||
{
|
||||
$this->mapApiRoutes();
|
||||
|
||||
$this->mapWebRoutes();
|
||||
}
|
||||
|
||||
/**
|
||||
* Define the "web" routes for the application.
|
||||
*
|
||||
* These routes all receive session state, CSRF protection, etc.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
protected function mapWebRoutes()
|
||||
{
|
||||
Route::middleware('web')
|
||||
->namespace($this->moduleNamespace)
|
||||
->group(module_path('Licensing', '/Routes/web.php'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Define the "api" routes for the application.
|
||||
*
|
||||
* These routes are typically stateless.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
protected function mapApiRoutes()
|
||||
{
|
||||
Route::prefix('api')
|
||||
->middleware('api')
|
||||
->namespace($this->moduleNamespace)
|
||||
->group(module_path('Licensing', '/Routes/api.php'));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
@extends('licensing::layouts.master')
|
||||
|
||||
@section('content')
|
||||
<h1>Hello World</h1>
|
||||
|
||||
<p>
|
||||
This view is loaded from module: {!! config('licensing.name') !!}
|
||||
</p>
|
||||
@endsection
|
||||
@@ -0,0 +1,19 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Module Licensing</title>
|
||||
|
||||
{{-- Laravel Mix - CSS File --}}
|
||||
{{-- <link rel="stylesheet" href="{{ mix('css/licensing.css') }}"> --}}
|
||||
|
||||
</head>
|
||||
<body>
|
||||
@yield('content')
|
||||
|
||||
{{-- Laravel Mix - JS File --}}
|
||||
{{-- <script src="{{ mix('js/licensing.js') }}"></script> --}}
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,12 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use Modules\Licensing\Http\Controllers\Api\LicenseActivationController;
|
||||
use Modules\Licensing\Http\Controllers\Api\PaymentWebhookController;
|
||||
|
||||
Route::prefix('v1')->group(function (): void {
|
||||
Route::post('/activate', [LicenseActivationController::class, 'activate'])->middleware('throttle:license-activation');
|
||||
Route::post('/validate', [LicenseActivationController::class, 'validateInstallation'])->middleware('throttle:license-activation');
|
||||
Route::post('/deactivate', [LicenseActivationController::class, 'deactivate'])->middleware('throttle:license-activation');
|
||||
Route::post('/webhooks/{provider}', PaymentWebhookController::class)->whereIn('provider', ['paddle', 'lemon-squeezy']);
|
||||
});
|
||||
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use Modules\Licensing\Http\Controllers\Portal\PortalController;
|
||||
|
||||
Route::middleware(['auth', 'verified'])->prefix('portal')->name('portal.')->group(function (): void {
|
||||
Route::get('/', [PortalController::class, 'index'])->name('index');
|
||||
Route::get('/licences/{license}', [PortalController::class, 'show'])->name('show');
|
||||
Route::post('/licences/{license}/upgrade', [PortalController::class, 'upgrade'])->middleware('throttle:5,1')->name('upgrade');
|
||||
Route::delete('/licences/{license}/subscription', [PortalController::class, 'cancel'])->middleware('throttle:5,1')->name('cancel');
|
||||
Route::post('/licences/{license}/resend', [PortalController::class, 'resend'])->middleware('throttle:3,10')->name('resend');
|
||||
Route::delete('/licences/{license}/activations/{activation}', [PortalController::class, 'deactivate'])->name('deactivate');
|
||||
});
|
||||
+2
-2
@@ -1,8 +1,8 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Licensing;
|
||||
namespace Modules\Licensing\Services;
|
||||
|
||||
use App\Models\LicenseDeliveryOutbox;
|
||||
use Modules\Licensing\Models\LicenseDeliveryOutbox;
|
||||
use Illuminate\Support\Facades\Crypt;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
|
||||
+4
-4
@@ -1,10 +1,10 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Licensing;
|
||||
namespace Modules\Licensing\Services;
|
||||
|
||||
use App\Models\License;
|
||||
use App\Models\LicenseDeliveryOutbox;
|
||||
use App\Models\LicenseProduct;
|
||||
use Modules\Licensing\Models\License;
|
||||
use Modules\Licensing\Models\LicenseDeliveryOutbox;
|
||||
use Modules\Licensing\Models\LicenseProduct;
|
||||
use Illuminate\Support\Facades\Crypt;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Licensing;
|
||||
namespace Modules\Licensing\Services;
|
||||
|
||||
final class LicenseKey
|
||||
{
|
||||
+2
-2
@@ -1,8 +1,8 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Licensing;
|
||||
namespace Modules\Licensing\Services;
|
||||
|
||||
use App\Models\License;
|
||||
use Modules\Licensing\Models\License;
|
||||
use Carbon\CarbonInterface;
|
||||
use RuntimeException;
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Licensing;
|
||||
namespace Modules\Licensing\Services;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user