File size: 3,410 Bytes
4977db2
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
import Foundation

/// Append-only audit log for user-visible mutations. Entries intentionally do
/// not contain prompt titles, contents, tags, complete source identifiers, or
/// selected file paths.
public final class ActivityLogger: @unchecked Sendable {
    public let logURL: URL

    private let lock = NSLock()

    public init(logURL: URL) throws {
        self.logURL = logURL

        let fileManager = FileManager.default
        try fileManager.createDirectory(
            at: logURL.deletingLastPathComponent(),
            withIntermediateDirectories: true
        )

        if !fileManager.fileExists(atPath: logURL.path) {
            guard fileManager.createFile(atPath: logURL.path, contents: nil) else {
                throw ActivityLoggerError.couldNotCreateLog(logURL)
            }
        }

        // The log is local operational metadata and should not be readable by
        // other accounts on the Mac.
        try fileManager.setAttributes(
            [.posixPermissions: 0o600],
            ofItemAtPath: logURL.path
        )
    }

    public func logImport(
        statistics: ImportStatistics,
        sourceFamilies: [String]
    ) throws {
        try append(ActivityLogEntry(
            event: .importCompleted,
            promptID: nil,
            sourceFamily: nil,
            sourceFamilies: Array(Set(sourceFamilies)).sorted(),
            statistics: statistics
        ))
    }

    public func logImport(_ report: ImportReport) throws {
        // `sourceName` is deliberately omitted: even a last path component can
        // disclose personal project or export names.
        try append(ActivityLogEntry(
            event: .importCompleted,
            promptID: nil,
            sourceFamily: report.provider,
            sourceFamilies: [],
            statistics: report.statistics
        ))
    }

    public func logDeletion(promptID: Int64, sourceFamily: String?) throws {
        try append(ActivityLogEntry(
            event: .promptDeleted,
            promptID: promptID,
            sourceFamily: sourceFamily,
            sourceFamilies: [],
            statistics: nil
        ))
    }

    private func append(_ entry: ActivityLogEntry) throws {
        lock.lock()
        defer { lock.unlock() }

        let encoder = JSONEncoder()
        encoder.dateEncodingStrategy = .iso8601
        encoder.outputFormatting = [.sortedKeys, .withoutEscapingSlashes]

        var line = try encoder.encode(entry)
        line.append(0x0A)

        let handle = try FileHandle(forWritingTo: logURL)
        defer { try? handle.close() }
        try handle.seekToEnd()
        try handle.write(contentsOf: line)
        try handle.synchronize()
    }
}

public enum ActivityLoggerError: LocalizedError, Sendable {
    case couldNotCreateLog(URL)

    public var errorDescription: String? {
        switch self {
        case let .couldNotCreateLog(url):
            "Das lokale Aktivitätsprotokoll konnte nicht angelegt werden: \(url.path)"
        }
    }
}

private struct ActivityLogEntry: Encodable {
    enum Event: String, Encodable {
        case importCompleted = "import_completed"
        case promptDeleted = "prompt_deleted"
    }

    let schemaVersion = 1
    let timestamp = Date()
    let event: Event
    let promptID: Int64?
    let sourceFamily: String?
    let sourceFamilies: [String]
    let statistics: ImportStatistics?
}