@summerscar/dsh-mnemon-sync 0.0.0-stage → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +86 -2
- package/client.js +1194 -0
- package/cordis.patch.yml +3 -0
- package/icon.svg +15 -0
- package/index.js +731 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +75 -4
- package/scheduler.js +241 -0
- package/snapshot.js +400 -0
- package/source.js +201 -0
- package/store.js +187 -0
- package/targets.js +533 -0
- package/zip.js +168 -0
package/store.js
ADDED
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mnemon Sync — settings store.
|
|
3
|
+
*
|
|
4
|
+
* Settings live in `<dataRoot>/state/mnemon-sync.json` at mode 0600, never in
|
|
5
|
+
* the DSH profile patch. Two reasons, both learned from how dsh-mnemon keeps
|
|
6
|
+
* its own Git credentials:
|
|
7
|
+
*
|
|
8
|
+
* - The profile patch is plain text that the DSH UI renders and that any
|
|
9
|
+
* profile backup copies wholesale; a WebDAV password or S3 secret key has no
|
|
10
|
+
* business there.
|
|
11
|
+
* - `state/` is deliberately not part of a Mnemon Pack, so a backup never
|
|
12
|
+
* carries the secret that unlocks the backup target.
|
|
13
|
+
*
|
|
14
|
+
* The file is written whole through a temporary sibling, so a crash mid-write
|
|
15
|
+
* leaves the previous settings intact instead of a truncated JSON file.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
|
|
19
|
+
import { join } from 'node:path'
|
|
20
|
+
|
|
21
|
+
const FILE = 'mnemon-sync.json'
|
|
22
|
+
const DIRECTORY = 'state'
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* The intervals the settings page offers, in minutes.
|
|
26
|
+
*
|
|
27
|
+
* Copied from dsh-mnemon's own Git auto-backup so the two backup surfaces read
|
|
28
|
+
* the same: off, hourly, 3/6/12 hours, daily, every 3 days, weekly. A value
|
|
29
|
+
* outside this list is still accepted when a profile sets one by hand — the
|
|
30
|
+
* list only constrains what the UI offers.
|
|
31
|
+
*/
|
|
32
|
+
export const AUTO_SYNC_CHOICES = [0, 60, 180, 360, 720, 1440, 4320, 10080]
|
|
33
|
+
|
|
34
|
+
export const DEFAULT_SETTINGS = Object.freeze({
|
|
35
|
+
enabled: false,
|
|
36
|
+
target: { kind: 'webdav', url: '', username: '', password: '', remotePath: 'mnemon-sync' },
|
|
37
|
+
// Which dsh-mnemon components a backup carries. All three by default: a
|
|
38
|
+
// backup that silently omits memory spaces would be the worst default.
|
|
39
|
+
components: ['memory-spaces', 'documents', 'runtime'],
|
|
40
|
+
// One number, as dsh-mnemon's Git channel stores it: 0 means "only when you
|
|
41
|
+
// press the button", anything else is a whole number of minutes. Keeping the
|
|
42
|
+
// representation identical avoids a second vocabulary for the same idea.
|
|
43
|
+
autoSyncMinutes: 0,
|
|
44
|
+
retention: { keep: 14 },
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
export function settingsPath(dataRoot) {
|
|
48
|
+
return join(dataRoot, DIRECTORY, FILE)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Read settings, falling back to defaults for anything missing or malformed. */
|
|
52
|
+
export function readSettings(dataRoot) {
|
|
53
|
+
const path = settingsPath(dataRoot)
|
|
54
|
+
if (!existsSync(path)) return clone(DEFAULT_SETTINGS)
|
|
55
|
+
try {
|
|
56
|
+
const stored = JSON.parse(readFileSync(path, 'utf8'))
|
|
57
|
+
return normalizeSettings(stored)
|
|
58
|
+
} catch {
|
|
59
|
+
// A hand-edited file with a stray comma must not stop a backup from
|
|
60
|
+
// running with sane defaults; the settings page shows the defaults back.
|
|
61
|
+
return clone(DEFAULT_SETTINGS)
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function writeSettings(dataRoot, value) {
|
|
66
|
+
const normalized = normalizeSettings(value)
|
|
67
|
+
const path = settingsPath(dataRoot)
|
|
68
|
+
mkdirSync(join(dataRoot, DIRECTORY), { recursive: true, mode: 0o700 })
|
|
69
|
+
const temporary = `${path}.${String(process.pid)}.tmp`
|
|
70
|
+
writeFileSync(temporary, `${JSON.stringify(normalized, null, 2)}\n`, { mode: 0o600 })
|
|
71
|
+
try {
|
|
72
|
+
renameSync(temporary, path)
|
|
73
|
+
} catch (error) {
|
|
74
|
+
// Platforms that cannot replace an existing file keep the old content.
|
|
75
|
+
writeFileSync(path, `${JSON.stringify(normalized, null, 2)}\n`, { mode: 0o600 })
|
|
76
|
+
throw error
|
|
77
|
+
}
|
|
78
|
+
try {
|
|
79
|
+
chmodSync(path, 0o600)
|
|
80
|
+
} catch {
|
|
81
|
+
// Best effort on filesystems with no POSIX modes.
|
|
82
|
+
}
|
|
83
|
+
return normalized
|
|
84
|
+
}
|
|
85
|
+
/** Coerce arbitrary input into the exact shape the uploaders and timer expect. */
|
|
86
|
+
export function normalizeSettings(raw) {
|
|
87
|
+
const source = isRecord(raw) ? raw : {}
|
|
88
|
+
return {
|
|
89
|
+
enabled: source.enabled === true,
|
|
90
|
+
target: normalizeTargetSettings(source.target),
|
|
91
|
+
components: normalizeComponents(source.components),
|
|
92
|
+
autoSyncMinutes: normalizeAutoSyncMinutes(source.autoSyncMinutes),
|
|
93
|
+
retention: {
|
|
94
|
+
keep: clampInt(source.retention?.keep, 1, 365, 14),
|
|
95
|
+
},
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Which components a backup carries.
|
|
101
|
+
*
|
|
102
|
+
* An absent value means "everything", which is what a caller that sends only
|
|
103
|
+
* what it changed expects. An explicit empty list is a mistake rather than a
|
|
104
|
+
* choice — a backup of nothing is not a backup — so it falls back too.
|
|
105
|
+
*/
|
|
106
|
+
function normalizeComponents(value) {
|
|
107
|
+
if (value === undefined || value === null) return [...DEFAULT_SETTINGS.components]
|
|
108
|
+
const requested = Array.isArray(value) ? value.map(String) : []
|
|
109
|
+
const known = DEFAULT_SETTINGS.components.filter((component) => requested.includes(component))
|
|
110
|
+
return known.length === 0 ? [...DEFAULT_SETTINGS.components] : known
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* One interval in minutes. Zero disables the timer; anything else is clamped to
|
|
115
|
+
* the same ceiling dsh-mnemon's form uses, so a typo cannot schedule a run a
|
|
116
|
+
* year out.
|
|
117
|
+
*/
|
|
118
|
+
export function normalizeAutoSyncMinutes(value) {
|
|
119
|
+
if (value === null || value === undefined || value === '') return 0
|
|
120
|
+
const minutes = typeof value === 'number' ? value : Number(String(value).trim())
|
|
121
|
+
if (!Number.isInteger(minutes) || minutes < 0 || minutes > 10080) return 0
|
|
122
|
+
return minutes
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function normalizeTargetSettings(raw) {
|
|
126
|
+
const source = isRecord(raw) ? raw : {}
|
|
127
|
+
const kind = source.kind === 's3' ? 's3' : 'webdav'
|
|
128
|
+
if (kind === 'webdav') {
|
|
129
|
+
return {
|
|
130
|
+
kind,
|
|
131
|
+
url: String(source.url ?? ''),
|
|
132
|
+
username: String(source.username ?? ''),
|
|
133
|
+
password: String(source.password ?? ''),
|
|
134
|
+
remotePath: normalizeRemotePath(source.remotePath),
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
return {
|
|
138
|
+
kind,
|
|
139
|
+
endpoint: String(source.endpoint ?? ''),
|
|
140
|
+
bucket: String(source.bucket ?? ''),
|
|
141
|
+
region: String(source.region ?? '') || 'auto',
|
|
142
|
+
accessKeyId: String(source.accessKeyId ?? ''),
|
|
143
|
+
secretAccessKey: String(source.secretAccessKey ?? ''),
|
|
144
|
+
sessionToken: String(source.sessionToken ?? ''),
|
|
145
|
+
remotePath: normalizeRemotePath(source.remotePath),
|
|
146
|
+
forcePathStyle: source.forcePathStyle === true,
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function normalizeRemotePath(value) {
|
|
151
|
+
const text = String(value ?? '').trim().replace(/^\/+/u, '').replace(/\/+$/u, '')
|
|
152
|
+
return text === '' ? 'mnemon-sync' : text
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function clampInt(value, minimum, maximum, fallback) {
|
|
156
|
+
const number = Number(value)
|
|
157
|
+
if (!Number.isFinite(number)) return fallback
|
|
158
|
+
const integer = Math.floor(number)
|
|
159
|
+
if (integer < minimum) return minimum
|
|
160
|
+
if (integer > maximum) return maximum
|
|
161
|
+
return integer
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function isRecord(value) {
|
|
165
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function clone(value) {
|
|
169
|
+
return JSON.parse(JSON.stringify(value))
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* The public view of settings: identical to what is stored, except that a
|
|
174
|
+
* secret is reported by presence rather than by value. The UI needs to know a
|
|
175
|
+
* password is set without being handed it, so a screenshot or a support paste
|
|
176
|
+
* never leaks the target credentials.
|
|
177
|
+
*/
|
|
178
|
+
export function maskSettings(settings) {
|
|
179
|
+
const masked = clone(settings)
|
|
180
|
+
if (masked.target.kind === 'webdav') {
|
|
181
|
+
masked.target.password = masked.target.password === '' ? '' : '••••••••'
|
|
182
|
+
} else {
|
|
183
|
+
masked.target.secretAccessKey = masked.target.secretAccessKey === '' ? '' : '••••••••'
|
|
184
|
+
masked.target.sessionToken = masked.target.sessionToken === '' ? '' : '••••••••'
|
|
185
|
+
}
|
|
186
|
+
return masked
|
|
187
|
+
}
|