krz/rune

an ios client for njalla

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

main: Rune/API/Models.swift · raw

  1import Foundation
  2
  3struct EmptyResult: Codable {}
  4
  5struct WalletBalance: Codable, Equatable {
  6    let balance: Int
  7}
  8
  9struct DomainListResponse: Decodable {
 10    let domains: [Domain]
 11}
 12
 13struct RecordListResponse: Codable {
 14    let records: [DNSRecord]
 15}
 16
 17struct TokenListResponse: Codable {
 18    let tokens: [APIToken]
 19}
 20
 21struct ForwardListResponse: Decodable {
 22    let forwards: [EmailForward]
 23
 24    enum CodingKeys: String, CodingKey {
 25        case forwards
 26        case mailforwards
 27    }
 28
 29    init(from decoder: Decoder) throws {
 30        let container = try decoder.container(keyedBy: CodingKeys.self)
 31        let forwardsValue = try? container.decodeIfPresent([EmailForward].self, forKey: .forwards)
 32        let mailForwardsValue = try? container.decodeIfPresent([EmailForward].self, forKey: .mailforwards)
 33        forwards = forwardsValue ?? mailForwardsValue ?? []
 34    }
 35}
 36
 37struct GlueListResponse: Decodable {
 38    let glue: [GlueRecord]
 39
 40    enum CodingKeys: String, CodingKey {
 41        case glue
 42    }
 43
 44    init(from decoder: Decoder) throws {
 45        let container = try decoder.container(keyedBy: CodingKeys.self)
 46        glue = (try container.decodeIfPresent([GlueRecord].self, forKey: .glue)) ?? []
 47    }
 48}
 49
 50struct WalletTransactionListResponse: Decodable {
 51    let transactions: [WalletTransaction]
 52
 53    enum CodingKeys: String, CodingKey {
 54        case transactions
 55    }
 56
 57    init(from decoder: Decoder) throws {
 58        let container = try decoder.container(keyedBy: CodingKeys.self)
 59        transactions = (try container.decodeIfPresent([WalletTransaction].self, forKey: .transactions)) ?? []
 60    }
 61}
 62
 63struct Domain: Decodable, Identifiable, Hashable {
 64    var id: String { name }
 65
 66    let name: String
 67    let status: String?
 68    let expiry: String?
 69    let renewPrice: Int?
 70    let autorenew: Bool?
 71    let mailforwarding: Bool?
 72    let dnssec: Bool?
 73    let lock: Bool?
 74    let nameservers: [String]?
 75
 76    enum CodingKeys: String, CodingKey {
 77        case name
 78        case status
 79        case expiry
 80        case renewPrice = "renew_price"
 81        case renewalPrice = "renewal_price"
 82        case price
 83        case autorenew
 84        case mailforwarding
 85        case dnssec
 86        case dnssecEnabled = "dnssec_enabled"
 87        case lock
 88        case locked
 89        case nameservers
 90    }
 91
 92    init(from decoder: Decoder) throws {
 93        let container = try decoder.container(keyedBy: CodingKeys.self)
 94        name = try container.decode(String.self, forKey: .name)
 95        status = try container.decodeIfPresent(String.self, forKey: .status)
 96        expiry = try container.decodeIfPresent(String.self, forKey: .expiry)
 97        renewPrice =
 98            container.decodeLossyIntIfPresent(forKey: .renewPrice) ??
 99            container.decodeLossyIntIfPresent(forKey: .renewalPrice) ??
100            container.decodeLossyIntIfPresent(forKey: .price)
101        autorenew = try container.decodeIfPresent(Bool.self, forKey: .autorenew)
102        mailforwarding = try container.decodeIfPresent(Bool.self, forKey: .mailforwarding)
103        dnssec = try container.decodeIfPresent(Bool.self, forKey: .dnssec) ??
104            container.decodeIfPresent(Bool.self, forKey: .dnssecEnabled)
105        lock = try container.decodeIfPresent(Bool.self, forKey: .lock) ??
106            container.decodeIfPresent(Bool.self, forKey: .locked)
107        nameservers = try container.decodeIfPresent([String].self, forKey: .nameservers)
108    }
109}
110
111private extension KeyedDecodingContainer where K == Domain.CodingKeys {
112    func decodeLossyIntIfPresent(forKey key: K) -> Int? {
113        if let intValue = try? decodeIfPresent(Int.self, forKey: key) {
114            return intValue
115        }
116
117        if let stringValue = try? decodeIfPresent(String.self, forKey: key) {
118            return Int(stringValue)
119        }
120
121        return nil
122    }
123}
124
125struct DNSRecord: Codable, Identifiable, Hashable {
126    let id: String
127    let domain: String
128    let type: String
129    let name: String
130    let content: String?
131    let ttl: Int?
132    let prio: Int?
133    let weight: Int?
134    let port: Int?
135    let target: String?
136    let sshAlgorithm: Int?
137    let sshType: Int?
138
139    enum CodingKeys: String, CodingKey {
140        case id
141        case domain
142        case type
143        case name
144        case content
145        case ttl
146        case prio
147        case weight
148        case port
149        case target
150        case sshAlgorithm = "ssh_algorithm"
151        case sshType = "ssh_type"
152    }
153
154    init(
155        id: String,
156        domain: String,
157        type: String,
158        name: String,
159        content: String?,
160        ttl: Int?,
161        prio: Int?,
162        weight: Int?,
163        port: Int?,
164        target: String?,
165        sshAlgorithm: Int?,
166        sshType: Int?
167    ) {
168        self.id = id
169        self.domain = domain
170        self.type = type
171        self.name = name
172        self.content = content
173        self.ttl = ttl
174        self.prio = prio
175        self.weight = weight
176        self.port = port
177        self.target = target
178        self.sshAlgorithm = sshAlgorithm
179        self.sshType = sshType
180    }
181
182    init(from decoder: Decoder) throws {
183        let container = try decoder.container(keyedBy: CodingKeys.self)
184
185        id = try container.decodeLossyString(forKey: .id)
186        domain = try container.decodeIfPresent(String.self, forKey: .domain) ?? ""
187        type = try container.decode(String.self, forKey: .type)
188        name = try container.decode(String.self, forKey: .name)
189        content = try container.decodeIfPresent(String.self, forKey: .content)
190        ttl = try container.decodeLossyIntIfPresent(forKey: .ttl)
191        prio = try container.decodeLossyIntIfPresent(forKey: .prio)
192        weight = try container.decodeLossyIntIfPresent(forKey: .weight)
193        port = try container.decodeLossyIntIfPresent(forKey: .port)
194        target = try container.decodeIfPresent(String.self, forKey: .target)
195        sshAlgorithm = try container.decodeLossyIntIfPresent(forKey: .sshAlgorithm)
196        sshType = try container.decodeLossyIntIfPresent(forKey: .sshType)
197    }
198
199    func withDomain(_ domain: String) -> DNSRecord {
200        DNSRecord(
201            id: id,
202            domain: domain,
203            type: type,
204            name: name,
205            content: content,
206            ttl: ttl,
207            prio: prio,
208            weight: weight,
209            port: port,
210            target: target,
211            sshAlgorithm: sshAlgorithm,
212            sshType: sshType
213        )
214    }
215}
216
217struct APIToken: Codable, Identifiable, Hashable {
218    var id: String { key }
219
220    let key: String
221    let comment: String?
222    let from: [String]?
223    let allowedDomains: [String]?
224    let allowedServers: [String]?
225    let allowedMethods: [String]?
226    let allowedPrefixes: [String]?
227    let allowedTypes: [String]?
228
229    enum CodingKeys: String, CodingKey {
230        case key
231        case comment
232        case from
233        case allowedDomains = "allowed_domains"
234        case allowedServers = "allowed_servers"
235        case allowedMethods = "allowed_methods"
236        case allowedPrefixes = "allowed_prefixes"
237        case allowedTypes = "allowed_types"
238    }
239}
240
241struct EmailForward: Codable, Hashable, Identifiable {
242    let domain: String
243    let from: String
244    let to: String
245
246    enum CodingKeys: String, CodingKey {
247        case domain
248        case from
249        case to
250    }
251
252    init(domain: String, from: String, to: String) {
253        self.domain = domain
254        self.from = from
255        self.to = to
256    }
257
258    init(from decoder: Decoder) throws {
259        let container = try decoder.container(keyedBy: CodingKeys.self)
260        let rawFrom = try container.decode(String.self, forKey: .from).trimmingCharacters(in: .whitespacesAndNewlines)
261        let to = try container.decode(String.self, forKey: .to).trimmingCharacters(in: .whitespacesAndNewlines)
262
263        let decodedDomain = try container.decodeIfPresent(String.self, forKey: .domain)?
264            .trimmingCharacters(in: .whitespacesAndNewlines)
265
266        if let atIndex = rawFrom.lastIndex(of: "@") {
267            let localPart = String(rawFrom[..<atIndex])
268            let fromDomain = String(rawFrom[rawFrom.index(after: atIndex)...])
269            self.from = localPart.isEmpty ? rawFrom : localPart
270            if let decodedDomain, !decodedDomain.isEmpty {
271                self.domain = decodedDomain
272            } else {
273                self.domain = fromDomain
274            }
275        } else {
276            self.from = rawFrom
277            self.domain = decodedDomain ?? ""
278        }
279
280        self.to = to
281    }
282
283    var id: String {
284        "\(domain)|\(from)|\(to)"
285    }
286}
287
288struct GlueRecord: Codable, Hashable, Identifiable {
289    let domain: String
290    let name: String
291    let address4: String?
292    let address6: String?
293
294    enum CodingKeys: String, CodingKey {
295        case domain
296        case name
297        case address4
298        case address6
299    }
300
301    init(domain: String, name: String, address4: String?, address6: String?) {
302        self.domain = domain
303        self.name = name
304        self.address4 = address4
305        self.address6 = address6
306    }
307
308    init(from decoder: Decoder) throws {
309        let container = try decoder.container(keyedBy: CodingKeys.self)
310        domain = (try container.decodeIfPresent(String.self, forKey: .domain)) ?? ""
311        name = try container.decode(String.self, forKey: .name)
312        address4 = try container.decodeIfPresent(String.self, forKey: .address4)
313        address6 = try container.decodeIfPresent(String.self, forKey: .address6)
314    }
315
316    var id: String {
317        "\(domain)|\(name)"
318    }
319
320    func withDomain(_ domain: String) -> GlueRecord {
321        GlueRecord(domain: domain, name: name, address4: address4, address6: address6)
322    }
323}
324
325struct WalletTransaction: Codable, Hashable, Identifiable {
326    let id: String
327    let type: String?
328    let status: String?
329    let amount: Int?
330    let date: String?
331    let details: String?
332
333    enum CodingKeys: String, CodingKey {
334        case id
335        case type
336        case status
337        case amount
338        case date
339        case details = "description"
340    }
341
342    init(from decoder: Decoder) throws {
343        let container = try decoder.container(keyedBy: CodingKeys.self)
344        id = (try? container.decodeLossyString(forKey: .id)) ?? UUID().uuidString
345        type = try container.decodeIfPresent(String.self, forKey: .type)
346        status = try container.decodeIfPresent(String.self, forKey: .status)
347        amount = try container.decodeLossyIntIfPresent(forKey: .amount)
348        date = try container.decodeIfPresent(String.self, forKey: .date)
349        details = try container.decodeIfPresent(String.self, forKey: .details)
350    }
351}
352
353struct WalletPayment: Codable, Hashable {
354    let id: String?
355    let amount: Int?
356    let status: String?
357    let address: String?
358    let url: String?
359}
360
361enum DNSRecordType: String, CaseIterable, Identifiable, Codable {
362    case a = "A"
363    case aaaa = "AAAA"
364    case aname = "ANAME"
365    case caa = "CAA"
366    case cname = "CNAME"
367    case ds = "DS"
368    case dynamic = "Dynamic"
369    case https = "HTTPS"
370    case mx = "MX"
371    case naptr = "NAPTR"
372    case ns = "NS"
373    case ptr = "PTR"
374    case srv = "SRV"
375    case sshfp = "SSHFP"
376    case svcb = "SVCB"
377    case tlsa = "TLSA"
378    case txt = "TXT"
379
380    var id: String { rawValue }
381
382    var usesContent: Bool {
383        switch self {
384        case .dynamic, .https, .svcb:
385            return false
386        default:
387            return true
388        }
389    }
390
391    var usesTTL: Bool {
392        usesContent
393    }
394
395    var usesPriority: Bool {
396        switch self {
397        case .https, .mx, .srv, .svcb:
398            return true
399        default:
400            return false
401        }
402    }
403
404    var usesWeight: Bool {
405        self == .srv
406    }
407
408    var usesPort: Bool {
409        self == .srv
410    }
411
412    var usesTarget: Bool {
413        self == .https || self == .svcb
414    }
415
416    var usesSSHFields: Bool {
417        self == .sshfp
418    }
419}
420
421enum TTLPreset: Int, CaseIterable, Identifiable {
422    case zero = 0
423    case oneMinute = 60
424    case fiveMinutes = 300
425    case fifteenMinutes = 900
426    case oneHour = 3600
427    case threeHours = 10800
428    case sixHours = 21600
429    case oneDay = 86400
430
431    var id: Int { rawValue }
432
433    var label: String {
434        switch self {
435        case .zero:
436            return "0s"
437        case .oneMinute:
438            return "1m"
439        case .fiveMinutes:
440            return "5m"
441        case .fifteenMinutes:
442            return "15m"
443        case .oneHour:
444            return "1h"
445        case .threeHours:
446            return "3h"
447        case .sixHours:
448            return "6h"
449        case .oneDay:
450            return "1d"
451        }
452    }
453
454    static func option(for seconds: Int) -> TTLOption {
455        if let preset = TTLPreset(rawValue: seconds) {
456            return TTLOption(seconds: preset.rawValue, label: preset.label)
457        }
458
459        return TTLOption(seconds: seconds, label: "\(seconds)s", isCustom: true)
460    }
461}
462
463struct TTLOption: Hashable, Identifiable {
464    let seconds: Int
465    let label: String
466    let isCustom: Bool
467
468    init(seconds: Int, label: String, isCustom: Bool = false) {
469        self.seconds = seconds
470        self.label = label
471        self.isCustom = isCustom
472    }
473
474    var id: Int { seconds }
475}
476
477struct DNSRecordDraft: Equatable {
478    var type: DNSRecordType = .a
479    var name = ""
480    var content = ""
481    var ttlSeconds = TTLPreset.oneHour.rawValue
482    var prio = ""
483    var weight = ""
484    var port = ""
485    var target = ""
486    var sshAlgorithm = ""
487    var sshType = ""
488
489    init() {}
490
491    init(record: DNSRecord) {
492        type = DNSRecordType(rawValue: record.type) ?? .a
493        name = record.name
494        content = record.content ?? ""
495        ttlSeconds = record.ttl ?? TTLPreset.oneHour.rawValue
496        prio = record.prio.map(String.init) ?? ""
497        weight = record.weight.map(String.init) ?? ""
498        port = record.port.map(String.init) ?? ""
499        target = record.target ?? ""
500        sshAlgorithm = record.sshAlgorithm.map(String.init) ?? ""
501        sshType = record.sshType.map(String.init) ?? ""
502    }
503
504    mutating func resetTypeSpecificFields() {
505        content = ""
506        ttlSeconds = TTLPreset.oneHour.rawValue
507        prio = ""
508        weight = ""
509        port = ""
510        target = ""
511        sshAlgorithm = ""
512        sshType = ""
513    }
514
515    var trimmedName: String {
516        name.trimmingCharacters(in: .whitespacesAndNewlines)
517    }
518
519    var canSubmit: Bool {
520        !trimmedName.isEmpty
521    }
522
523    var ttlOptions: [TTLOption] {
524        let presetOptions = TTLPreset.allCases.map { TTLOption(seconds: $0.rawValue, label: $0.label) }
525
526        guard type.usesTTL, TTLPreset(rawValue: ttlSeconds) == nil else {
527            return presetOptions
528        }
529
530        return presetOptions + [TTLPreset.option(for: ttlSeconds)]
531    }
532
533    func params(domain: String, id: String? = nil) -> [String: Any] {
534        var params: [String: Any] = [
535            "domain": domain,
536            "type": type.rawValue,
537            "name": trimmedName
538        ]
539
540        if let id {
541            params["id"] = id
542        }
543
544        if type.usesContent {
545            params["content"] = content.trimmingCharacters(in: .whitespacesAndNewlines)
546        }
547
548        if type.usesTTL {
549            params["ttl"] = ttlSeconds
550        }
551
552        if type.usesPriority, let value = Int(prio.trimmingCharacters(in: .whitespacesAndNewlines)) {
553            params["prio"] = value
554        }
555
556        if type.usesWeight, let value = Int(weight.trimmingCharacters(in: .whitespacesAndNewlines)) {
557            params["weight"] = value
558        }
559
560        if type.usesPort, let value = Int(port.trimmingCharacters(in: .whitespacesAndNewlines)) {
561            params["port"] = value
562        }
563
564        if type.usesTarget {
565            params["target"] = target.trimmingCharacters(in: .whitespacesAndNewlines)
566        }
567
568        if type.usesSSHFields {
569            if let value = Int(sshAlgorithm.trimmingCharacters(in: .whitespacesAndNewlines)) {
570                params["ssh_algorithm"] = value
571            }
572            if let value = Int(sshType.trimmingCharacters(in: .whitespacesAndNewlines)) {
573                params["ssh_type"] = value
574            }
575        }
576
577        return params
578    }
579}
580
581struct DomainUpdateRequest {
582    var autorenew: Bool?
583    var mailforwarding: Bool?
584    var dnssec: Bool?
585    var lock: Bool?
586    var nameservers: [String]?
587
588    var hasChanges: Bool {
589        autorenew != nil ||
590        mailforwarding != nil ||
591        dnssec != nil ||
592        lock != nil ||
593        nameservers != nil
594    }
595
596    var params: [String: Any] {
597        var params: [String: Any] = [:]
598
599        if let autorenew {
600            params["autorenew"] = autorenew
601        }
602
603        if let mailforwarding {
604            params["mailforwarding"] = mailforwarding
605        }
606
607        if let dnssec {
608            params["dnssec"] = dnssec
609        }
610
611        if let lock {
612            params["lock"] = lock
613        }
614
615        if let nameservers {
616            params["nameservers"] = nameservers
617        }
618
619        return params
620    }
621
622    func isSatisfied(by domain: Domain) -> Bool {
623        if let autorenew, domain.autorenew != autorenew {
624            return false
625        }
626
627        if let mailforwarding, domain.mailforwarding != mailforwarding {
628            return false
629        }
630
631        if let dnssec, domain.dnssec != dnssec {
632            return false
633        }
634
635        if let lock, domain.lock != lock {
636            return false
637        }
638
639        if let nameservers {
640            let normalizedResponse = domain.nameservers ?? []
641            if normalizedResponse != nameservers {
642                return false
643            }
644        }
645
646        return true
647    }
648}
649
650struct TokenCreateRequest {
651    var comment: String
652    var from: [String]
653    var allowedMethods: [String]
654    var allowedDomains: [String]
655
656    var params: [String: Any] {
657        var params: [String: Any] = [:]
658
659        let trimmedComment = comment.trimmingCharacters(in: .whitespacesAndNewlines)
660        if !trimmedComment.isEmpty {
661            params["comment"] = trimmedComment
662        }
663
664        if !from.isEmpty {
665            params["from"] = from
666        }
667
668        if !allowedMethods.isEmpty {
669            params["allowed_methods"] = allowedMethods
670        }
671
672        if !allowedDomains.isEmpty {
673            params["allowed_domains"] = allowedDomains
674        }
675
676        return params
677    }
678}
679
680extension String {
681    func formattedExpiry() -> String {
682        let parser = ISO8601DateFormatter()
683        guard let date = parser.date(from: self) else { return self }
684        let formatter = DateFormatter()
685        formatter.dateStyle = .medium
686        formatter.timeStyle = .none
687        return formatter.string(from: date)
688    }
689}
690
691private extension KeyedDecodingContainer where K == DNSRecord.CodingKeys {
692    func decodeLossyString(forKey key: K) throws -> String {
693        if let stringValue = try decodeIfPresent(String.self, forKey: key) {
694            return stringValue
695        }
696
697        if let intValue = try decodeIfPresent(Int.self, forKey: key) {
698            return String(intValue)
699        }
700
701        throw DecodingError.keyNotFound(
702            key,
703            DecodingError.Context(codingPath: codingPath, debugDescription: "Missing required value for \(key.stringValue).")
704        )
705    }
706
707    func decodeLossyIntIfPresent(forKey key: K) throws -> Int? {
708        if let intValue = try decodeIfPresent(Int.self, forKey: key) {
709            return intValue
710        }
711
712        if let stringValue = try decodeIfPresent(String.self, forKey: key) {
713            return Int(stringValue)
714        }
715
716        return nil
717    }
718}
719
720private extension KeyedDecodingContainer where K == WalletTransaction.CodingKeys {
721    func decodeLossyString(forKey key: K) throws -> String {
722        if let stringValue = try decodeIfPresent(String.self, forKey: key) {
723            return stringValue
724        }
725
726        if let intValue = try decodeIfPresent(Int.self, forKey: key) {
727            return String(intValue)
728        }
729
730        throw DecodingError.keyNotFound(
731            key,
732            DecodingError.Context(codingPath: codingPath, debugDescription: "Missing required value for \(key.stringValue).")
733        )
734    }
735
736    func decodeLossyIntIfPresent(forKey key: K) throws -> Int? {
737        if let intValue = try decodeIfPresent(Int.self, forKey: key) {
738            return intValue
739        }
740
741        if let stringValue = try decodeIfPresent(String.self, forKey: key) {
742            return Int(stringValue)
743        }
744
745        return nil
746    }
747}