]> git.giacomolli.uk - onechat/blob - backup.go
Keep the README for the user
[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 }