@materializecms/server 0.2.0 → 0.4.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/mail.js CHANGED
@@ -1,3 +1,6 @@
1
+ import { Resolver } from 'node:dns/promises';
2
+ import { connect } from 'node:net';
3
+ import { COMMON_DKIM, providerByHost, providerById } from '@materializecms/core/mail';
1
4
  import nodemailer from 'nodemailer';
2
5
  export const EMPTY_MAIL = {
3
6
  host: '',
@@ -34,3 +37,170 @@ export function submissionText(page, data) {
34
37
  const lines = Object.entries(data).map(([k, v]) => `${k}:\n${v}\n`);
35
38
  return `Nuovo messaggio dal modulo del sito${page ? ` (pagina ${page})` : ''}.\n\n${lines.join('\n')}`;
36
39
  }
40
+ // ---------- why sending failed, in words ----------
41
+ /** Whether the server answers on a port at all: the way to tell a blocked port from a wrong setting. */
42
+ export function portOpen(host, port, timeout = 4000) {
43
+ return new Promise((resolve) => {
44
+ const socket = connect({ host, port, timeout });
45
+ const done = (ok) => {
46
+ socket.destroy();
47
+ resolve(ok);
48
+ };
49
+ socket.once('connect', () => done(true));
50
+ socket.once('timeout', () => done(false));
51
+ socket.once('error', () => done(false));
52
+ });
53
+ }
54
+ /**
55
+ * A sending error from nodemailer, explained: wrong server name, a port the hosting blocks (then
56
+ * the ports that do answer), security that does not match the port, credentials refused, a sender
57
+ * the provider has not verified, a limit reached.
58
+ */
59
+ export async function explainMailError(error, m) {
60
+ const e = error;
61
+ const text = [e.message, e.response, ...(e.errors ?? []).flatMap((x) => [x.code, x.message])].join(' ');
62
+ const provider = providerById(m.provider) ?? providerByHost(m.host);
63
+ if (e.code === 'EDNS' || /ENOTFOUND|EAI_AGAIN/.test(text))
64
+ return {
65
+ message: `Il server «${m.host}» non esiste`,
66
+ hint: 'Controlla il nome del server: un errore di battitura basta.',
67
+ };
68
+ if (/wrong version number|ssl3_get_record|packet length too long|unknown protocol/i.test(text))
69
+ return {
70
+ message: 'La sicurezza non corrisponde alla porta',
71
+ hint: 'Di solito: porta 587 con STARTTLS, porta 465 con TLS.',
72
+ };
73
+ if (/certificate|self.signed|CERT_/i.test(text))
74
+ return {
75
+ message: 'Il certificato del server di posta non è valido',
76
+ hint: 'Controlla il nome del server: deve essere esattamente quello indicato dal fornitore.',
77
+ };
78
+ if (e.code === 'ETIMEDOUT' ||
79
+ e.code === 'ECONNECTION' ||
80
+ (e.code === 'ESOCKET' && e.command === 'CONN') ||
81
+ /ECONNREFUSED|ETIMEDOUT|ECONNRESET|EHOSTUNREACH|ENETUNREACH|Greeting never received/i.test(text)) {
82
+ const others = [587, 465, 2525].filter((p) => p !== m.port);
83
+ const open = [];
84
+ for (const p of others)
85
+ if (await portOpen(m.host, p))
86
+ open.push(p);
87
+ if (open.length)
88
+ return {
89
+ message: `Il server non risponde sulla porta ${m.port}, ma risponde sulla ${open.join(' e sulla ')}`,
90
+ hint: `Prova la porta ${open[0]}${open[0] === 465 ? ' con TLS' : ' con STARTTLS'}: la ${m.port} potrebbe essere bloccata dal fornitore del server.`,
91
+ };
92
+ return {
93
+ message: `Il server di posta non risponde (porta ${m.port})`,
94
+ hint: 'Molti fornitori di VPS bloccano le porte della posta in uscita, soprattutto per gli account nuovi: chiedi al loro supporto di sbloccarle, oppure usa un servizio che accetta la porta 2525.',
95
+ };
96
+ }
97
+ if (e.code === 'EAUTH' || e.responseCode === 535 || e.responseCode === 534)
98
+ return {
99
+ message: 'Utente o password rifiutati dal server di posta',
100
+ hint: provider ? `${provider.name}: ${provider.userHint} ${provider.passHint}` : 'Controlla utente e password.',
101
+ };
102
+ if (e.code === 'EENVELOPE' || (e.responseCode && e.responseCode >= 550 && e.responseCode <= 554)) {
103
+ const sender = e.command === 'MAIL FROM' || /sender|from address|not verified|not allowed to send/i.test(text);
104
+ return sender
105
+ ? {
106
+ message: 'Il server di posta non accetta questo mittente',
107
+ hint: `${provider?.note ?? 'Il mittente deve essere un indirizzo o un dominio verificato presso il fornitore.'}`,
108
+ }
109
+ : { message: 'Il destinatario è stato rifiutato', hint: 'Controlla l’indirizzo del destinatario dei moduli.' };
110
+ }
111
+ if (e.responseCode && e.responseCode >= 421 && e.responseCode <= 452)
112
+ return {
113
+ message: 'Il server di posta ha rimandato l’invio',
114
+ hint: 'Spesso è un limite di messaggi raggiunto: riprova più tardi o controlla il piano del fornitore.',
115
+ };
116
+ return { message: e.message || text.trim() || 'Invio non riuscito' };
117
+ }
118
+ const txt = async (resolver, name) => (await resolver.resolveTxt(name).catch(() => [])).map((parts) => parts.join(''));
119
+ /**
120
+ * What mail servers check before trusting a message from this domain. Without SPF and DKIM the
121
+ * messages tend to end in spam; Gmail and Yahoo also ask for DMARC from whoever sends a lot.
122
+ */
123
+ export async function checkSenderDomain(domain, providerId) {
124
+ const resolver = new Resolver({ timeout: 3000, tries: 2 });
125
+ const provider = providerById(providerId);
126
+ const checks = [];
127
+ const spf = (await txt(resolver, domain)).filter((r) => /^v=spf1\b/i.test(r));
128
+ if (!spf.length)
129
+ checks.push({
130
+ id: 'spf',
131
+ state: 'bad',
132
+ title: 'Manca il record SPF',
133
+ detail: `Senza SPF le email da ${domain} finiscono spesso nello spam. ${provider?.spf ? `Aggiungi nel DNS un record TXT su ${domain}: v=spf1 include:${provider.spf[0]} ~all` : 'Il fornitore della posta indica quale record TXT aggiungere nel DNS.'}`,
134
+ });
135
+ else if (spf.length > 1)
136
+ checks.push({
137
+ id: 'spf',
138
+ state: 'bad',
139
+ title: 'Ci sono due record SPF',
140
+ detail: 'I server di posta li scartano entrambi: uniscili in un solo record TXT che inizia con v=spf1.',
141
+ });
142
+ else if (!/\b(include:|a\b|a:|mx\b|mx:|ip4:|ip6:|exists:|redirect=)/i.test(spf[0]))
143
+ checks.push({
144
+ id: 'spf',
145
+ state: 'bad',
146
+ title: 'Il record SPF non autorizza nessun server',
147
+ detail: `${spf[0]}: così il dominio dichiara di non inviare email, e i messaggi vengono rifiutati. Aggiungi il server del tuo fornitore${provider?.spf ? ` (include:${provider.spf[0]})` : ''}.`,
148
+ });
149
+ else if (provider?.spf && !provider.spf.some((h) => spf[0].includes(`include:${h}`)))
150
+ checks.push({
151
+ id: 'spf',
152
+ state: 'warn',
153
+ title: `Il record SPF non autorizza ${provider.name}`,
154
+ detail: `Aggiungi include:${provider.spf[0]} al record attuale, prima di ~all o -all: ${spf[0]}`,
155
+ });
156
+ else
157
+ checks.push({ id: 'spf', state: 'ok', title: 'Record SPF presente', detail: spf[0] });
158
+ const selectors = provider ? provider.dkim : COMMON_DKIM;
159
+ if (provider && !selectors.length)
160
+ checks.push({
161
+ id: 'dkim',
162
+ state: 'info',
163
+ title: 'Firma DKIM: da controllare nel pannello del fornitore',
164
+ detail: `${provider.name} dà a ogni account nomi propri per la firma DKIM: nel suo pannello vedi se il dominio è verificato.`,
165
+ });
166
+ else {
167
+ let found = null;
168
+ // An empty key (p=) is a revoked one: it means "no signature".
169
+ for (const s of selectors)
170
+ if ((await txt(resolver, `${s}._domainkey.${domain}`)).some((r) => /\bp=[A-Za-z0-9+/]/.test(r))) {
171
+ found = s;
172
+ break;
173
+ }
174
+ checks.push(found
175
+ ? {
176
+ id: 'dkim',
177
+ state: 'ok',
178
+ title: 'Firma DKIM presente',
179
+ detail: `Trovata con il nome ${found}._domainkey.${domain}.`,
180
+ }
181
+ : {
182
+ id: 'dkim',
183
+ state: 'warn',
184
+ title: 'Firma DKIM non trovata',
185
+ detail: `Non l’ho trovata con i nomi più comuni${provider ? ` di ${provider.name}` : ''}. Nel pannello del fornitore attiva la firma del dominio: ti dà i record da aggiungere nel DNS.`,
186
+ });
187
+ }
188
+ const dmarc = (await txt(resolver, `_dmarc.${domain}`)).find((r) => /^v=DMARC1\b/i.test(r));
189
+ if (!dmarc)
190
+ checks.push({
191
+ id: 'dmarc',
192
+ state: 'warn',
193
+ title: 'Manca il record DMARC',
194
+ detail: `Gmail e Yahoo lo chiedono a chi invia molte email. Un inizio sicuro, che osserva senza bloccare niente: record TXT su _dmarc.${domain} con v=DMARC1; p=none`,
195
+ });
196
+ else {
197
+ const policy = /\bp=(none|quarantine|reject)/i.exec(dmarc)?.[1]?.toLowerCase();
198
+ checks.push({
199
+ id: 'dmarc',
200
+ state: 'ok',
201
+ title: 'Record DMARC presente',
202
+ detail: policy === 'none' ? `Osserva soltanto (p=none): ${dmarc}` : dmarc,
203
+ });
204
+ }
205
+ return checks;
206
+ }
@@ -0,0 +1,52 @@
1
+ import type { Backups } from './backups.ts';
2
+ import { type RemoteStorage, type S3Target, type WebDavTarget } from './remote-storage.ts';
3
+ /**
4
+ * A copy of the backups outside the server, encrypted before it leaves: every night the newest
5
+ * database copy, and the media and fonts not sent yet (uploaded files never change, so they go
6
+ * once). On the storage the media are only added; database copies beyond `keep` are deleted.
7
+ *
8
+ * Encryption: AES-256-GCM with a random key made by Materialize, the "recovery key", shown once
9
+ * to the administrator. Whoever reads the bucket or the WebDAV folder sees noise. Files are
10
+ * encrypted in blocks of 1 MiB, each authenticated, the last one marked: a file cut short (an
11
+ * upload that stopped) is refused instead of giving back half a database.
12
+ */
13
+ export interface OffsiteSettings {
14
+ kind: 'off' | 's3' | 'webdav';
15
+ s3?: S3Target & {
16
+ prefix?: string;
17
+ };
18
+ webdav?: WebDavTarget;
19
+ /** How many database copies stay on the storage; older ones are deleted. */
20
+ keep: number;
21
+ /** The recovery key: base64url of 32 random bytes, with the "mzk1-" mark. */
22
+ key?: string;
23
+ }
24
+ export interface OffsiteStatus {
25
+ at: string;
26
+ ok: boolean;
27
+ message?: string;
28
+ database?: string;
29
+ media?: number;
30
+ fonts?: number;
31
+ }
32
+ export declare const DEFAULT_OFFSITE: OffsiteSettings;
33
+ /** Encrypted files end with this, so they are told apart from anything else in the folder. */
34
+ export declare const SUFFIX = ".mzb";
35
+ export declare function newRecoveryKey(): string;
36
+ export declare function parseRecoveryKey(text: string): Buffer;
37
+ /** Encrypts `source` into `target` (block by block: large videos never sit in memory). */
38
+ export declare function encryptFile(source: string, target: string, key: Buffer): Promise<void>;
39
+ /** The other way; throws on a wrong key, a changed file or one cut short. */
40
+ export declare function decryptFile(source: string, target: string, key: Buffer): Promise<void>;
41
+ export declare function storageOf(s: OffsiteSettings): RemoteStorage | null;
42
+ /**
43
+ * One run: what is missing goes up, old database copies go away. `fresh` makes a new database
44
+ * copy first (the "Esegui ora" button); otherwise the newest daily copy is sent.
45
+ */
46
+ export declare function runOffsite(s: OffsiteSettings, backups: Backups, mediaDir: string, fresh?: boolean,
47
+ /** For tests: a storage in memory. */
48
+ storage?: RemoteStorage | null): Promise<OffsiteStatus>;
49
+ /** "Prova il collegamento": writes, reads back, lists and deletes a small file. */
50
+ export declare function testOffsite(s: OffsiteSettings): Promise<void>;
51
+ /** Every encrypted file under a folder (or one file), decrypted next to it without the suffix. */
52
+ export declare function decryptTree(source: string, key: Buffer): Promise<string[]>;
package/src/offsite.js ADDED
@@ -0,0 +1,230 @@
1
+ import { createCipheriv, createDecipheriv, randomBytes } from 'node:crypto';
2
+ import { mkdtemp, open, readdir, rm, stat } from 'node:fs/promises';
3
+ import { tmpdir } from 'node:os';
4
+ import { join, relative, resolve } from 'node:path';
5
+ import { createS3Storage, createWebDavStorage, } from './remote-storage.js';
6
+ export const DEFAULT_OFFSITE = { kind: 'off', keep: 30 };
7
+ const KEY_MARK = 'mzk1-';
8
+ const MAGIC = Buffer.from('MZB1');
9
+ const BLOCK = 1024 * 1024;
10
+ /** Encrypted files end with this, so they are told apart from anything else in the folder. */
11
+ export const SUFFIX = '.mzb';
12
+ export function newRecoveryKey() {
13
+ return KEY_MARK + randomBytes(32).toString('base64url');
14
+ }
15
+ export function parseRecoveryKey(text) {
16
+ const clean = text.trim().replace(/\s+/g, '');
17
+ const raw = clean.startsWith(KEY_MARK) ? clean.slice(KEY_MARK.length) : clean;
18
+ const key = Buffer.from(raw, 'base64url');
19
+ if (key.length !== 32)
20
+ throw new Error('La chiave di ripristino non è valida: deve iniziare con mzk1-');
21
+ return key;
22
+ }
23
+ const nonceOf = (prefix, index) => {
24
+ const n = Buffer.alloc(12);
25
+ prefix.copy(n, 0);
26
+ n.writeUInt32BE(index, 8);
27
+ return n;
28
+ };
29
+ /** Encrypts `source` into `target` (block by block: large videos never sit in memory). */
30
+ export async function encryptFile(source, target, key) {
31
+ const input = await open(source, 'r');
32
+ const output = await open(target, 'w');
33
+ try {
34
+ const { size } = await input.stat();
35
+ const blocks = Math.max(1, Math.ceil(size / BLOCK));
36
+ const prefix = randomBytes(8);
37
+ await output.write(Buffer.concat([MAGIC, prefix]));
38
+ const buffer = Buffer.alloc(BLOCK);
39
+ for (let i = 0; i < blocks; i++) {
40
+ const { bytesRead } = await input.read(buffer, 0, BLOCK, i * BLOCK);
41
+ const cipher = createCipheriv('aes-256-gcm', key, nonceOf(prefix, i));
42
+ cipher.setAAD(Buffer.from([i === blocks - 1 ? 1 : 0]));
43
+ const sealed = Buffer.concat([cipher.update(buffer.subarray(0, bytesRead)), cipher.final(), cipher.getAuthTag()]);
44
+ const length = Buffer.alloc(4);
45
+ length.writeUInt32BE(bytesRead);
46
+ await output.write(Buffer.concat([length, sealed]));
47
+ }
48
+ }
49
+ finally {
50
+ await input.close();
51
+ await output.close();
52
+ }
53
+ }
54
+ /** The other way; throws on a wrong key, a changed file or one cut short. */
55
+ export async function decryptFile(source, target, key) {
56
+ const input = await open(source, 'r');
57
+ const output = await open(target, 'w');
58
+ let ok = false;
59
+ try {
60
+ const head = Buffer.alloc(12);
61
+ await input.read(head, 0, 12, 0);
62
+ if (!head.subarray(0, 4).equals(MAGIC))
63
+ throw new Error('Non è un file di backup cifrato da Materialize');
64
+ const prefix = head.subarray(4, 12);
65
+ let position = 12;
66
+ for (let i = 0;; i++) {
67
+ const length = Buffer.alloc(4);
68
+ if ((await input.read(length, 0, 4, position)).bytesRead < 4)
69
+ throw new Error('Il file è incompleto');
70
+ const size = length.readUInt32BE();
71
+ if (size > BLOCK)
72
+ throw new Error('Il file è danneggiato');
73
+ const sealed = Buffer.alloc(size + 16);
74
+ if ((await input.read(sealed, 0, size + 16, position + 4)).bytesRead < size + 16)
75
+ throw new Error('Il file è incompleto');
76
+ position += 4 + size + 16;
77
+ const end = position >= (await input.stat()).size;
78
+ const decipher = createDecipheriv('aes-256-gcm', key, nonceOf(prefix, i));
79
+ decipher.setAAD(Buffer.from([end ? 1 : 0]));
80
+ decipher.setAuthTag(sealed.subarray(size));
81
+ try {
82
+ await output.write(Buffer.concat([decipher.update(sealed.subarray(0, size)), decipher.final()]));
83
+ }
84
+ catch {
85
+ throw new Error(end ? 'Chiave sbagliata, oppure il file è stato modificato' : 'Il file è incompleto o è stato modificato');
86
+ }
87
+ if (end)
88
+ break;
89
+ }
90
+ ok = true;
91
+ }
92
+ finally {
93
+ await input.close();
94
+ await output.close();
95
+ if (!ok)
96
+ await rm(target, { force: true });
97
+ }
98
+ }
99
+ export function storageOf(s) {
100
+ if (s.kind === 's3' && s.s3)
101
+ return createS3Storage(s.s3, s.s3.prefix ?? '');
102
+ if (s.kind === 'webdav' && s.webdav)
103
+ return createWebDavStorage(s.webdav);
104
+ return null;
105
+ }
106
+ const README = `Backup di Materialize
107
+
108
+ Questi file sono cifrati (AES-256-GCM): senza la chiave di ripristino non si leggono.
109
+
110
+ database/ le copie del database, dalla più recente
111
+ media/ le immagini e i file caricati
112
+ fonts/ i caratteri del sito
113
+
114
+ Per rimetterli su un server:
115
+ 1. scarica la cartella (con lo strumento del servizio, per esempio rclone);
116
+ 2. nella cartella del sito: npx materialize decrypt <cartella scaricata> --key <chiave>
117
+ 3. npx materialize restore <cartella decifrata>/database/content-….db
118
+ e copia media/ e fonts/ in .materialize/
119
+
120
+ https://github.com/materializecms/materialize/blob/main/docs/guida/12-aggiornamenti-e-backup.md
121
+ `;
122
+ /** Every file under a folder, as paths relative to it; hidden files and the media cache left out. */
123
+ async function filesUnder(dir) {
124
+ const out = [];
125
+ const walk = async (current) => {
126
+ for (const item of await readdir(current, { withFileTypes: true }).catch(() => [])) {
127
+ if (item.name.startsWith('.'))
128
+ continue;
129
+ const path = join(current, item.name);
130
+ if (item.isDirectory())
131
+ await walk(path);
132
+ else if (item.isFile())
133
+ out.push(relative(dir, path).split('\\').join('/'));
134
+ }
135
+ };
136
+ await walk(dir);
137
+ return out;
138
+ }
139
+ /**
140
+ * One run: what is missing goes up, old database copies go away. `fresh` makes a new database
141
+ * copy first (the "Esegui ora" button); otherwise the newest daily copy is sent.
142
+ */
143
+ export async function runOffsite(s, backups, mediaDir, fresh = false,
144
+ /** For tests: a storage in memory. */
145
+ storage = storageOf(s)) {
146
+ if (!storage || !s.key)
147
+ throw new Error('La copia fuori dal server non è configurata');
148
+ const key = parseRecoveryKey(s.key);
149
+ const work = await mkdtemp(join(tmpdir(), 'materialize-offsite-'));
150
+ try {
151
+ const remote = new Set((await storage.list('')).map((i) => i.key));
152
+ if (!remote.has('LEGGIMI.txt'))
153
+ await storage.putText('LEGGIMI.txt', README);
154
+ const send = async (local, remoteKey) => {
155
+ const sealed = join(work, 'file');
156
+ await encryptFile(local, sealed, key);
157
+ await storage.put(remoteKey, sealed);
158
+ await rm(sealed, { force: true });
159
+ };
160
+ let database;
161
+ if (backups.available()) {
162
+ const latest = fresh ? await backups.create('esterno') : (await backups.list())[0];
163
+ if (latest) {
164
+ database = latest.name;
165
+ const target = `database/${latest.name}${SUFFIX}`;
166
+ if (!remote.has(target))
167
+ await send(join(backups.folder(), latest.name), target);
168
+ }
169
+ }
170
+ const media = resolve(mediaDir);
171
+ let mediaSent = 0;
172
+ for (const name of await filesUnder(media)) {
173
+ const target = `media/${name}${SUFFIX}`;
174
+ if (remote.has(target))
175
+ continue;
176
+ await send(join(media, name), target);
177
+ mediaSent++;
178
+ }
179
+ const fonts = join(resolve(mediaDir), '..', 'fonts');
180
+ let fontsSent = 0;
181
+ for (const name of await filesUnder(fonts)) {
182
+ const target = `fonts/${name}${SUFFIX}`;
183
+ if (remote.has(target))
184
+ continue;
185
+ await send(join(fonts, name), target);
186
+ fontsSent++;
187
+ }
188
+ // The newest copies stay; the media are never deleted (an old copy of the database may use them).
189
+ const copies = (await storage.list('database/'))
190
+ .filter((i) => i.key.endsWith(SUFFIX))
191
+ .sort((a, b) => b.key.localeCompare(a.key));
192
+ for (const old of copies.slice(Math.max(1, s.keep)))
193
+ await storage.delete(old.key);
194
+ return { at: new Date().toISOString(), ok: true, database, media: mediaSent, fonts: fontsSent };
195
+ }
196
+ finally {
197
+ await rm(work, { recursive: true, force: true });
198
+ }
199
+ }
200
+ /** "Prova il collegamento": writes, reads back, lists and deletes a small file. */
201
+ export async function testOffsite(s) {
202
+ const storage = storageOf(s);
203
+ if (!storage)
204
+ throw new Error('Scegli S3 o WebDAV e compila i campi');
205
+ const key = `.materialize-prova-${randomBytes(4).toString('hex')}.txt`;
206
+ const text = `Prova di Materialize, ${new Date().toISOString()}`;
207
+ await storage.putText(key, text);
208
+ try {
209
+ if ((await storage.getText(key)) !== text)
210
+ throw new Error('Il file di prova è tornato diverso');
211
+ await storage.list('');
212
+ }
213
+ finally {
214
+ await storage.delete(key);
215
+ }
216
+ }
217
+ /** Every encrypted file under a folder (or one file), decrypted next to it without the suffix. */
218
+ export async function decryptTree(source, key) {
219
+ const info = await stat(source);
220
+ const files = info.isDirectory()
221
+ ? (await filesUnder(source)).filter((f) => f.endsWith(SUFFIX)).map((f) => join(source, f))
222
+ : [source];
223
+ const done = [];
224
+ for (const file of files) {
225
+ const target = file.endsWith(SUFFIX) ? file.slice(0, -SUFFIX.length) : `${file}.decifrato`;
226
+ await decryptFile(file, target, key);
227
+ done.push(target);
228
+ }
229
+ return done;
230
+ }
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Storage outside the server for the backups: any S3-compatible service (Amazon S3, Cloudflare
3
+ * R2, Backblaze B2, Hetzner, Scaleway, OVH, Wasabi, MinIO…) or a WebDAV folder (Nextcloud,
4
+ * ownCloud, Synology and QNAP, pCloud, kDrive…). Written with fetch and node:crypto only.
5
+ */
6
+ export interface S3Target {
7
+ /** https://… of the service, without the bucket: https://<account>.r2.cloudflarestorage.com */
8
+ endpoint: string;
9
+ region: string;
10
+ bucket: string;
11
+ accessKeyId: string;
12
+ secretAccessKey: string;
13
+ }
14
+ export interface WebDavTarget {
15
+ /** The folder the backups go in: https://cloud.example.com/remote.php/dav/files/nome/backup/ */
16
+ url: string;
17
+ user: string;
18
+ password: string;
19
+ }
20
+ export interface RemoteItem {
21
+ key: string;
22
+ size: number;
23
+ modified: Date;
24
+ }
25
+ export interface RemoteStorage {
26
+ put(key: string, file: string): Promise<void>;
27
+ putText(key: string, text: string): Promise<void>;
28
+ get(key: string, file: string): Promise<void>;
29
+ getText(key: string): Promise<string>;
30
+ /** Everything under a folder of the storage ("database/", "media/"), with sizes. */
31
+ list(prefix: string): Promise<RemoteItem[]>;
32
+ delete(key: string): Promise<void>;
33
+ }
34
+ export declare class RemoteError extends Error {
35
+ readonly status?: number;
36
+ constructor(message: string, status?: number);
37
+ }
38
+ /**
39
+ * A request signed with AWS Signature Version 4, which every S3-compatible service accepts. The
40
+ * body is not hashed (UNSIGNED-PAYLOAD): files are streamed over HTTPS, as large as they are.
41
+ */
42
+ export declare function signS3(target: S3Target, method: string, key: string, query?: Record<string, string>, headers?: Record<string, string>, now?: Date, payloadHash?: string): {
43
+ url: string;
44
+ headers: Record<string, string>;
45
+ };
46
+ export declare function createS3Storage(target: S3Target, prefix?: string): RemoteStorage;
47
+ export declare function createWebDavStorage(target: WebDavTarget): RemoteStorage;