@meith/backup 0.34.0
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.md +21 -0
- package/package.json +27 -0
- package/src/archive.ts +132 -0
- package/src/bundle.ts +129 -0
- package/src/capability.ts +10 -0
- package/src/create.ts +316 -0
- package/src/destination.ts +449 -0
- package/src/index.ts +105 -0
- package/src/postgres-client.ts +141 -0
- package/src/restore.ts +332 -0
- package/src/retention.ts +45 -0
- package/src/runs.ts +60 -0
- package/src/schedule.ts +75 -0
- package/src/uploads.ts +75 -0
- package/src/webdav.ts +247 -0
package/src/webdav.ts
ADDED
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
import { createReadStream, createWriteStream } from 'node:fs'
|
|
2
|
+
import http from 'node:http'
|
|
3
|
+
import https from 'node:https'
|
|
4
|
+
import { Readable } from 'node:stream'
|
|
5
|
+
import { pipeline } from 'node:stream/promises'
|
|
6
|
+
|
|
7
|
+
import { ConfigurationError, ValidationError } from '@meith/core'
|
|
8
|
+
|
|
9
|
+
import { isBundleName } from './bundle'
|
|
10
|
+
import type {
|
|
11
|
+
BackupDestination,
|
|
12
|
+
RemoteBundle,
|
|
13
|
+
RemoteBundleBody,
|
|
14
|
+
WebDavDestinationConfig,
|
|
15
|
+
} from './destination'
|
|
16
|
+
import { type RetentionPolicy, retentionCandidates } from './retention'
|
|
17
|
+
|
|
18
|
+
export interface WebDavResponse {
|
|
19
|
+
readonly status: number
|
|
20
|
+
readonly headers: http.IncomingHttpHeaders
|
|
21
|
+
readonly body: Readable
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export type WebDavRequester = (input: {
|
|
25
|
+
readonly method: string
|
|
26
|
+
readonly url: URL
|
|
27
|
+
readonly headers: Readonly<Record<string, string>>
|
|
28
|
+
readonly body?: Readable | string | undefined
|
|
29
|
+
}) => Promise<WebDavResponse>
|
|
30
|
+
|
|
31
|
+
export const WEBDAV_IDLE_TIMEOUT_MS = 120_000
|
|
32
|
+
|
|
33
|
+
const PROPFIND_BODY =
|
|
34
|
+
'<?xml version="1.0" encoding="utf-8"?>' +
|
|
35
|
+
'<d:propfind xmlns:d="DAV:"><d:prop><d:getcontentlength/><d:resourcetype/></d:prop></d:propfind>'
|
|
36
|
+
|
|
37
|
+
export function nodeRequester(): WebDavRequester {
|
|
38
|
+
return ({ method, url, headers, body }) =>
|
|
39
|
+
new Promise((resolve, reject) => {
|
|
40
|
+
const transport = url.protocol === 'https:' ? https : http
|
|
41
|
+
const request = transport.request(
|
|
42
|
+
url,
|
|
43
|
+
{ method, headers, timeout: WEBDAV_IDLE_TIMEOUT_MS },
|
|
44
|
+
(response) => {
|
|
45
|
+
resolve({
|
|
46
|
+
status: response.statusCode ?? 0,
|
|
47
|
+
headers: response.headers,
|
|
48
|
+
body: response,
|
|
49
|
+
})
|
|
50
|
+
},
|
|
51
|
+
)
|
|
52
|
+
request.on('timeout', () => {
|
|
53
|
+
request.destroy(
|
|
54
|
+
new Error(
|
|
55
|
+
`${url.origin} sent nothing for ${WEBDAV_IDLE_TIMEOUT_MS / 1000} seconds; giving up.`,
|
|
56
|
+
),
|
|
57
|
+
)
|
|
58
|
+
})
|
|
59
|
+
request.on('error', reject)
|
|
60
|
+
if (body === undefined) request.end()
|
|
61
|
+
else if (typeof body === 'string') request.end(body)
|
|
62
|
+
else body.pipe(request)
|
|
63
|
+
})
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async function drain(body: Readable): Promise<string> {
|
|
67
|
+
const chunks: Buffer[] = []
|
|
68
|
+
for await (const chunk of body) chunks.push(Buffer.from(chunk as Uint8Array))
|
|
69
|
+
return Buffer.concat(chunks).toString('utf8')
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function unescapeXml(text: string): string {
|
|
73
|
+
return text
|
|
74
|
+
.replace(/</g, '<')
|
|
75
|
+
.replace(/>/g, '>')
|
|
76
|
+
.replace(/"/g, '"')
|
|
77
|
+
.replace(/'/g, "'")
|
|
78
|
+
.replace(/&#(\d+);/g, (_, code: string) => String.fromCodePoint(Number(code)))
|
|
79
|
+
.replace(/&/g, '&')
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function parsePropfind(xml: string): readonly { href: string; size: number | null }[] {
|
|
83
|
+
const entries: { href: string; size: number | null }[] = []
|
|
84
|
+
for (const block of xml.matchAll(/<(?:\w+:)?response\b[\s\S]*?<\/(?:\w+:)?response>/g)) {
|
|
85
|
+
const text = block[0]
|
|
86
|
+
const href = /<(?:\w+:)?href[^>]*>([\s\S]*?)<\/(?:\w+:)?href>/.exec(text)?.[1]
|
|
87
|
+
if (href === undefined) continue
|
|
88
|
+
const length = /<(?:\w+:)?getcontentlength[^>]*>\s*(\d+)\s*<\/(?:\w+:)?getcontentlength>/.exec(
|
|
89
|
+
text,
|
|
90
|
+
)?.[1]
|
|
91
|
+
entries.push({
|
|
92
|
+
href: unescapeXml(href.trim()),
|
|
93
|
+
size: length === undefined ? null : Number(length),
|
|
94
|
+
})
|
|
95
|
+
}
|
|
96
|
+
return entries
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export class WebDavBackupDestination implements BackupDestination {
|
|
100
|
+
private readonly base: URL
|
|
101
|
+
|
|
102
|
+
private readonly requester: WebDavRequester
|
|
103
|
+
|
|
104
|
+
constructor(
|
|
105
|
+
private readonly config: WebDavDestinationConfig,
|
|
106
|
+
requester?: WebDavRequester,
|
|
107
|
+
) {
|
|
108
|
+
this.base = new URL(config.url)
|
|
109
|
+
this.requester = requester ?? nodeRequester()
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
get description(): string {
|
|
113
|
+
return `the WebDAV folder at ${this.base.origin}${this.base.pathname}`
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
private url(name: string): URL {
|
|
117
|
+
if (!isBundleName(name)) {
|
|
118
|
+
throw new ValidationError(`Not a backup bundle name: ${JSON.stringify(name)}`)
|
|
119
|
+
}
|
|
120
|
+
return new URL(name, this.base)
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
private headers(extra: Readonly<Record<string, string>> = {}): Readonly<Record<string, string>> {
|
|
124
|
+
const headers: Record<string, string> = { ...extra }
|
|
125
|
+
if (this.config.username !== '') {
|
|
126
|
+
const credential = `${this.config.username}:${this.config.password}`
|
|
127
|
+
headers.Authorization = `Basic ${Buffer.from(credential).toString('base64')}`
|
|
128
|
+
}
|
|
129
|
+
return headers
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
private failure(action: string, response: WebDavResponse): ConfigurationError {
|
|
133
|
+
const { status } = response
|
|
134
|
+
const location = response.headers.location
|
|
135
|
+
return new ConfigurationError(
|
|
136
|
+
`${this.description} answered ${status} to ${action}.` +
|
|
137
|
+
(status === 401 || status === 403
|
|
138
|
+
? ' Check the WebDAV username and password, and that the account may write there.'
|
|
139
|
+
: status === 404 || status === 409
|
|
140
|
+
? ' Check that the folder exists: the destination creates bundles, not the folder.'
|
|
141
|
+
: status >= 300 && status < 400 && typeof location === 'string'
|
|
142
|
+
? ` It redirects to ${location}: use that address as the WebDAV folder, ` +
|
|
143
|
+
'since credentials are not followed across a redirect.'
|
|
144
|
+
: ''),
|
|
145
|
+
)
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
async putFile(name: string, filePath: string, size: number): Promise<void> {
|
|
149
|
+
const response = await this.requester({
|
|
150
|
+
method: 'PUT',
|
|
151
|
+
url: this.url(name),
|
|
152
|
+
headers: this.headers({
|
|
153
|
+
'Content-Type': 'application/gzip',
|
|
154
|
+
'Content-Length': String(size),
|
|
155
|
+
}),
|
|
156
|
+
body: createReadStream(filePath),
|
|
157
|
+
})
|
|
158
|
+
response.body.resume()
|
|
159
|
+
if (response.status < 200 || response.status >= 300) {
|
|
160
|
+
throw this.failure(`uploading ${name}`, response)
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
async list(): Promise<readonly RemoteBundle[]> {
|
|
165
|
+
const response = await this.requester({
|
|
166
|
+
method: 'PROPFIND',
|
|
167
|
+
url: this.base,
|
|
168
|
+
headers: this.headers({
|
|
169
|
+
Depth: '1',
|
|
170
|
+
'Content-Type': 'application/xml; charset=utf-8',
|
|
171
|
+
'Content-Length': String(Buffer.byteLength(PROPFIND_BODY)),
|
|
172
|
+
}),
|
|
173
|
+
body: PROPFIND_BODY,
|
|
174
|
+
})
|
|
175
|
+
const xml = await drain(response.body)
|
|
176
|
+
if (response.status !== 207 && response.status !== 200) {
|
|
177
|
+
throw this.failure('listing the folder', response)
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const bundles: RemoteBundle[] = []
|
|
181
|
+
for (const entry of parsePropfind(xml)) {
|
|
182
|
+
const segment = entry.href.replace(/\/+$/, '').split('/').pop() ?? ''
|
|
183
|
+
let name: string
|
|
184
|
+
try {
|
|
185
|
+
name = decodeURIComponent(segment)
|
|
186
|
+
} catch {
|
|
187
|
+
continue
|
|
188
|
+
}
|
|
189
|
+
if (isBundleName(name)) bundles.push({ name, size: entry.size ?? 0 })
|
|
190
|
+
}
|
|
191
|
+
return bundles.sort((a, b) => a.name.localeCompare(b.name))
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
async open(name: string): Promise<RemoteBundleBody | null> {
|
|
195
|
+
const response = await this.requester({
|
|
196
|
+
method: 'GET',
|
|
197
|
+
url: this.url(name),
|
|
198
|
+
headers: this.headers(),
|
|
199
|
+
})
|
|
200
|
+
if (response.status === 404) {
|
|
201
|
+
response.body.resume()
|
|
202
|
+
return null
|
|
203
|
+
}
|
|
204
|
+
if (response.status < 200 || response.status >= 300) {
|
|
205
|
+
response.body.resume()
|
|
206
|
+
throw this.failure(`downloading ${name}`, response)
|
|
207
|
+
}
|
|
208
|
+
const length = response.headers['content-length']
|
|
209
|
+
const size = typeof length === 'string' && /^\d+$/.test(length) ? Number(length) : null
|
|
210
|
+
return { body: Readable.toWeb(response.body) as ReadableStream<Uint8Array>, size }
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
async getToFile(name: string, outPath: string): Promise<void> {
|
|
214
|
+
const opened = await this.open(name)
|
|
215
|
+
if (opened === null) {
|
|
216
|
+
throw new ValidationError(
|
|
217
|
+
`${this.description} has no bundle named ${name}. meith backup:list names what it holds.`,
|
|
218
|
+
)
|
|
219
|
+
}
|
|
220
|
+
await pipeline(
|
|
221
|
+
Readable.fromWeb(opened.body as import('node:stream/web').ReadableStream<Uint8Array>),
|
|
222
|
+
createWriteStream(outPath, { mode: 0o600 }),
|
|
223
|
+
)
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
async delete(name: string): Promise<void> {
|
|
227
|
+
const response = await this.requester({
|
|
228
|
+
method: 'DELETE',
|
|
229
|
+
url: this.url(name),
|
|
230
|
+
headers: this.headers(),
|
|
231
|
+
})
|
|
232
|
+
response.body.resume()
|
|
233
|
+
if (response.status !== 404 && (response.status < 200 || response.status >= 300)) {
|
|
234
|
+
throw this.failure(`deleting ${name}`, response)
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
async prune(policy: RetentionPolicy, now: Date = new Date()): Promise<readonly string[]> {
|
|
239
|
+
const stale = retentionCandidates(
|
|
240
|
+
(await this.list()).map((bundle) => bundle.name),
|
|
241
|
+
policy,
|
|
242
|
+
now,
|
|
243
|
+
)
|
|
244
|
+
for (const name of stale) await this.delete(name)
|
|
245
|
+
return stale
|
|
246
|
+
}
|
|
247
|
+
}
|