]> git.giacomolli.uk - onechat/blob - backup.go
Use both logs for WhatsApp import cutoffs
[onechat] / backup.go
1 package main
2
3 import (
4         "context"
5         "encoding/json"
6         "errors"
7         "fmt"
8         "io"
9         "io/fs"
10         "net"
11         "os"
12         "path/filepath"
13         "strings"
14 )
15
16 // backup asks the daemon for a private archive and prints its path and size.
17 func backup(ctx context.Context, path string, out io.Writer) (err error) {
18         dir, err := dataDir()
19         if err != nil {
20                 return fmt.Errorf("find data directory: %w", err)
21         }
22
23         path, err = filepath.Abs(path)
24         if err != nil {
25                 return fmt.Errorf("find backup path: %w", err)
26         }
27
28         var dialer net.Dialer
29
30         conn, err := dialer.DialContext(ctx, "unix", filepath.Join(dir, socketFileName))
31         if err != nil {
32                 return errors.New("backup needs a running daemon")
33         }
34
35         defer func() {
36                 err = errors.Join(err, conn.Close())
37         }()
38
39         stop := context.AfterFunc(ctx, func() {
40                 _ = conn.Close()
41         })
42         defer stop()
43
44         err = json.NewEncoder(conn).Encode(SocketRequest{
45                 Type: "backup",
46                 Text: path,
47         })
48         if err != nil {
49                 return fmt.Errorf("send backup request: %w", err)
50         }
51
52         decoder := json.NewDecoder(conn)
53         for {
54                 var event Event
55
56                 err = decoder.Decode(&event)
57                 if err != nil {
58                         return fmt.Errorf("read backup answer: %w", err)
59                 }
60
61                 switch event.Kind {
62                 case "backup":
63                         _, err = fmt.Fprintln(out, event.Text)
64                         if err != nil {
65                                 return fmt.Errorf("write backup answer: %w", err)
66                         }
67
68                         return nil
69                 case "error":
70                         return fmt.Errorf("backup failed: %s", event.Text)
71                 }
72         }
73 }
74
75 // restore runs the offline restore command and reports its data directory.
76 func restore(ctx context.Context, path string, out io.Writer) error {
77         dir, err := dataDir()
78         if err != nil {
79                 return fmt.Errorf("find restore directory: %w", err)
80         }
81
82         err = backupRestore(ctx, path, dir)
83         if err != nil {
84                 return fmt.Errorf("restore archive: %w", err)
85         }
86
87         _, err = fmt.Fprintln(out, "Restored", dir)
88         if err != nil {
89                 return fmt.Errorf("write restore answer: %w", err)
90         }
91
92         return nil
93 }
94
95 // backupData stages a session snapshot, then copies the data under snapshot's
96 // write locks. Compression runs after the locks are released.
97 func backupData(ctx context.Context, dir, path string, whatsapp *WhatsApp, snapshot func(context.Context, func() error) error) (size int64, err error) {
98         if !filepath.IsAbs(path) {
99                 return 0, errors.New("backup path must be absolute")
100         }
101
102         stage, err := os.MkdirTemp("", "onechat-backup-*")
103         if err != nil {
104                 return 0, fmt.Errorf("create backup directory: %w", err)
105         }
106
107         defer func() {
108                 err = errors.Join(err, os.RemoveAll(stage))
109         }()
110
111         err = whatsapp.BackupSession(ctx, filepath.Join(stage, whatsappDirName, whatsappSessionFileName))
112         if err != nil {
113                 return 0, fmt.Errorf("copy WhatsApp session: %w", err)
114         }
115
116         err = snapshot(ctx, func() error {
117                 return backupCopy(ctx, dir, stage, path)
118         })
119         if err != nil {
120                 return 0, fmt.Errorf("copy data directory: %w", err)
121         }
122
123         size, err = backupArchive(ctx, stage, path)
124         if err != nil {
125                 return 0, fmt.Errorf("archive backup: %w", err)
126         }
127
128         return size, nil
129 }
130
131 // backupRestore installs a validated archive into an empty data directory while
132 // holding the token lock. It keeps the lock file so daemon starts use that same
133 // inode, including after a failed restore.
134 func backupRestore(ctx context.Context, path, dir string) (err error) {
135         dir, err = filepath.Abs(dir)
136         if err != nil {
137                 return fmt.Errorf("find restore destination: %w", err)
138         }
139
140         entries, err := os.ReadDir(dir)
141         if err != nil {
142                 return fmt.Errorf("read restore destination: %w", err)
143         }
144
145         if len(entries) != 0 {
146                 hasLock := false
147                 for _, entry := range entries {
148                         hasLock = hasLock || entry.Name() == tokenLockFileName
149                 }
150
151                 if !hasLock {
152                         return errors.New("restore needs an empty data directory")
153                 }
154         }
155
156         tokens, err := lockTokens(dir)
157         if err != nil {
158                 return fmt.Errorf("restore needs a stopped daemon: %w", err)
159         }
160
161         defer func() {
162                 err = errors.Join(err, tokens.Close())
163         }()
164
165         if len(entries) != 0 {
166                 return errors.New("restore needs an empty data directory")
167         }
168
169         entries, err = os.ReadDir(dir)
170         if err != nil {
171                 return fmt.Errorf("check restore destination: %w", err)
172         }
173
174         if len(entries) != 1 || entries[0].Name() != tokenLockFileName {
175                 return errors.New("restore needs an empty data directory")
176         }
177
178         stage, err := os.MkdirTemp(filepath.Dir(dir), ".onechat-restore-*")
179         if err != nil {
180                 return fmt.Errorf("create restore directory: %w", err)
181         }
182
183         defer func() {
184                 err = errors.Join(err, os.RemoveAll(stage))
185         }()
186
187         err = backupExtract(ctx, path, stage)
188         if err != nil {
189                 return fmt.Errorf("extract backup: %w", err)
190         }
191
192         entries, err = os.ReadDir(stage)
193         if err != nil {
194                 return fmt.Errorf("read restored files: %w", err)
195         }
196
197         for _, entry := range entries {
198                 err = os.Rename(filepath.Join(stage, entry.Name()), filepath.Join(dir, entry.Name()))
199                 if err != nil {
200                         return fmt.Errorf("install restored entry: %w", err)
201                 }
202         }
203
204         return nil
205 }
206
207 // backupCopy stages regular files and directories, excluding runtime files, the
208 // output archive, and raw session database files. Each file keeps its mode.
209 func backupCopy(ctx context.Context, source, target, output string) error {
210         var directories []string
211
212         modes := make(map[string]fs.FileMode)
213
214         err := filepath.WalkDir(source, func(path string, entry fs.DirEntry, err error) error {
215                 if err != nil {
216                         return fmt.Errorf("read data entry: %w", err)
217                 }
218
219                 name, err := filepath.Rel(source, path)
220                 if err != nil {
221                         return fmt.Errorf("name data entry: %w", err)
222                 }
223
224                 if name == "." {
225                         return nil
226                 }
227
228                 absolute, err := filepath.Abs(path)
229                 if err != nil {
230                         return fmt.Errorf("find data entry: %w", err)
231                 }
232
233                 session := filepath.Join(whatsappDirName, whatsappSessionFileName)
234                 if backupExcluded(name) || absolute == output || name == session || strings.HasPrefix(name, session+"-") {
235                         if entry.IsDir() {
236                                 return filepath.SkipDir
237                         }
238
239                         return nil
240                 }
241
242                 info, err := entry.Info()
243                 if err != nil {
244                         return fmt.Errorf("read data file: %w", err)
245                 }
246
247                 destination := filepath.Join(target, name)
248                 switch {
249                 case info.IsDir():
250                         err = os.MkdirAll(destination, 0o700)
251                         directories = append(directories, destination)
252                         modes[destination] = info.Mode().Perm()
253                 case info.Mode().IsRegular():
254                         err = backupStageFile(ctx, path, destination)
255                 default:
256                         return fmt.Errorf("unsupported data entry %q", name)
257                 }
258
259                 if err != nil {
260                         return fmt.Errorf("stage data entry %q: %w", name, err)
261                 }
262
263                 return nil
264         })
265         if err != nil {
266                 return fmt.Errorf("stage backup: %w", err)
267         }
268
269         for i := len(directories) - 1; i >= 0; i-- {
270                 path := directories[i]
271                 err = os.Chmod(path, modes[path])
272                 if err != nil {
273                         return fmt.Errorf("stage directory mode: %w", err)
274                 }
275         }
276
277         return nil
278 }
279
280 // backupStageFile opens before it reads size and mode, so an atomic state file
281 // replacement cannot join the metadata of one version to the bytes of another.
282 func backupStageFile(ctx context.Context, source, target string) (err error) {
283         input, err := os.Open(source)
284         if err != nil {
285                 return fmt.Errorf("open snapshot source: %w", err)
286         }
287
288         defer func() {
289                 err = errors.Join(err, input.Close())
290         }()
291
292         info, err := input.Stat()
293         if err != nil {
294                 return fmt.Errorf("read snapshot source: %w", err)
295         }
296
297         if !info.Mode().IsRegular() {
298                 return errors.New("snapshot source must be a regular file")
299         }
300
301         file, err := os.OpenFile(target, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
302         if err != nil {
303                 return fmt.Errorf("create staged file: %w", err)
304         }
305
306         defer func() {
307                 err = errors.Join(err, file.Close())
308         }()
309
310         _, err = io.CopyN(file, BackupReader{
311                 ctx:    ctx,
312                 reader: input,
313         }, info.Size())
314         if err != nil {
315                 return fmt.Errorf("stage file: %w", err)
316         }
317
318         err = file.Chmod(info.Mode().Perm())
319         if err != nil {
320                 return fmt.Errorf("stage file mode: %w", err)
321         }
322
323         return nil
324 }