@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/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(/&lt;/g, '<')
75
+ .replace(/&gt;/g, '>')
76
+ .replace(/&quot;/g, '"')
77
+ .replace(/&apos;/g, "'")
78
+ .replace(/&#(\d+);/g, (_, code: string) => String.fromCodePoint(Number(code)))
79
+ .replace(/&amp;/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
+ }