main.go (26416B)
1 // recovery is a local utility for retrieving, decrypting, and displaying 2 // age-encrypted backup files from S3. It does more than just decrypt: it 3 // reconstructs full conversation history, downloads backed-up media, and 4 // shows account data summaries. 5 // 6 // Credentials are read from the Kubernetes Secret YAML at 7 // ../secret.yaml (relative to the working directory) — the same file that the 8 // CronJob references. No env vars or extra config needed. 9 // 10 // Usage (run from the backup-tool/ directory): 11 // 12 // go run ./recovery/ -identity ~/age.key -prefix mtrnord 13 // go run ./recovery/ -identity ~/age.key -prefix mtrnord -room '!roomid:server' 14 // go run ./recovery/ -identity ~/age.key -prefix mtrnord -all -out ./decrypted 15 // 16 // Optional overrides: 17 // 18 // -secret path to secret.yaml (default: ../secret.yaml) 19 // -endpoint S3 endpoint (default: hel1.your-objectstorage.com) 20 // -region S3 region (default: hel1) 21 // -bucket S3 bucket name (default: midnightthoughts-matrix-backup) 22 // -out output directory (default: ./decrypted) 23 package main 24 25 import ( 26 "bufio" 27 "bytes" 28 "context" 29 "crypto/aes" 30 "crypto/cipher" 31 "crypto/sha256" 32 "encoding/base64" 33 "encoding/json" 34 "errors" 35 "flag" 36 "fmt" 37 "io" 38 "log/slog" 39 "os" 40 "path/filepath" 41 "sort" 42 "strings" 43 "time" 44 45 "filippo.io/age" 46 "github.com/aws/aws-sdk-go-v2/aws" 47 awsconfig "github.com/aws/aws-sdk-go-v2/config" 48 "github.com/aws/aws-sdk-go-v2/credentials" 49 "github.com/aws/aws-sdk-go-v2/service/s3" 50 s3types "github.com/aws/aws-sdk-go-v2/service/s3/types" 51 "gopkg.in/yaml.v3" 52 ) 53 54 // ───────────────────────────────────────────────────────────────────────────── 55 // CLI flags 56 // ───────────────────────────────────────────────────────────────────────────── 57 58 var ( 59 flagIdentity = flag.String("identity", "", "path to age identity file (private key) [required]") 60 flagPrefix = flag.String("prefix", "", "account prefix: mtrnord or lexi [required]") 61 flagSecret = flag.String("secret", "../secret.yaml", "path to secret.yaml") 62 flagEndpoint = flag.String("endpoint", "hel1.your-objectstorage.com", "S3 endpoint") 63 flagRegion = flag.String("region", "hel1", "S3 region") 64 flagBucket = flag.String("bucket", "midnightthoughts-matrix-backup", "S3 bucket") 65 flagRoom = flag.String("room", "", "room ID to decrypt (empty = list rooms only)") 66 flagOut = flag.String("out", "./decrypted", "output directory for decrypted files") 67 flagAll = flag.Bool("all", false, "dump history for ALL rooms") 68 ) 69 70 // ───────────────────────────────────────────────────────────────────────────── 71 // Secret YAML parser 72 // ───────────────────────────────────────────────────────────────────────────── 73 74 // parseSecretYAML extracts the stringData map from a Kubernetes Secret YAML. 75 func parseSecretYAML(path string) (map[string]string, error) { 76 f, err := os.Open(path) 77 if err != nil { 78 return nil, fmt.Errorf("open secret file: %w", err) 79 } 80 defer f.Close() 81 82 var secret struct { 83 Sops map[string]any `yaml:"sops"` 84 StringData map[string]string `yaml:"stringData"` 85 } 86 if err := yaml.NewDecoder(f).Decode(&secret); err != nil { 87 return nil, fmt.Errorf("decode secret yaml: %w", err) 88 } 89 if secret.Sops != nil { 90 return nil, fmt.Errorf("secret.yaml is SOPS-encrypted — run `sops -d %s > /tmp/secret-plain.yaml` and point -secret at the plaintext file", path) 91 } 92 if secret.StringData == nil { 93 return nil, fmt.Errorf("secret.yaml has no stringData section") 94 } 95 return secret.StringData, nil 96 } 97 98 // ───────────────────────────────────────────────────────────────────────────── 99 // S3 100 // ───────────────────────────────────────────────────────────────────────────── 101 102 var ( 103 s3c *s3.Client 104 s3BucketName string 105 ) 106 107 func initS3(ctx context.Context, endpoint, region, bucket, accessKey, secretKey string) error { 108 s3BucketName = bucket 109 cfg, err := awsconfig.LoadDefaultConfig(ctx, 110 awsconfig.WithRegion(region), 111 awsconfig.WithCredentialsProvider(credentials.NewStaticCredentialsProvider(accessKey, secretKey, "")), 112 ) 113 if err != nil { 114 return err 115 } 116 ep := "https://" + endpoint 117 s3c = s3.NewFromConfig(cfg, func(o *s3.Options) { 118 o.UsePathStyle = true 119 o.BaseEndpoint = &ep 120 }) 121 return nil 122 } 123 124 func s3GetBytes(ctx context.Context, key string) ([]byte, error) { 125 out, err := s3c.GetObject(ctx, &s3.GetObjectInput{ 126 Bucket: aws.String(s3BucketName), 127 Key: aws.String(key), 128 }) 129 if err != nil { 130 var nsk *s3types.NoSuchKey 131 if errors.As(err, &nsk) { 132 return nil, nil 133 } 134 return nil, err 135 } 136 defer out.Body.Close() 137 return io.ReadAll(out.Body) 138 } 139 140 func listPrefix(ctx context.Context, prefix string) ([]string, error) { 141 var keys []string 142 paginator := s3.NewListObjectsV2Paginator(s3c, &s3.ListObjectsV2Input{ 143 Bucket: aws.String(s3BucketName), 144 Prefix: aws.String(prefix), 145 }) 146 for paginator.HasMorePages() { 147 page, err := paginator.NextPage(ctx) 148 if err != nil { 149 return nil, err 150 } 151 for _, obj := range page.Contents { 152 keys = append(keys, *obj.Key) 153 } 154 } 155 return keys, nil 156 } 157 158 // ───────────────────────────────────────────────────────────────────────────── 159 // Age decryption 160 // ───────────────────────────────────────────────────────────────────────────── 161 162 var ageIdentities []age.Identity 163 164 func loadIdentities(path string) error { 165 f, err := os.Open(path) 166 if err != nil { 167 return fmt.Errorf("open identity file: %w", err) 168 } 169 defer f.Close() 170 ids, err := age.ParseIdentities(f) 171 if err != nil { 172 return fmt.Errorf("parse identities: %w", err) 173 } 174 ageIdentities = ids 175 return nil 176 } 177 178 func ageDecrypt(data []byte) ([]byte, error) { 179 r, err := age.Decrypt(bytes.NewReader(data), ageIdentities...) 180 if err != nil { 181 return nil, err 182 } 183 return io.ReadAll(r) 184 } 185 186 // tryDecodeBase64 attempts to decode a base64 string using all four standard 187 // variants (raw/padded × URL-safe/standard) and returns the first that succeeds. 188 // Matrix encrypted-media fields are specified as URL-safe unpadded base64, but 189 // older clients used standard base64 with + and / characters. 190 func tryDecodeBase64(s string) ([]byte, error) { 191 for _, enc := range []*base64.Encoding{ 192 base64.RawURLEncoding, 193 base64.URLEncoding, 194 base64.RawStdEncoding, 195 base64.StdEncoding, 196 } { 197 if b, err := enc.DecodeString(s); err == nil { 198 return b, nil 199 } 200 } 201 return nil, fmt.Errorf("cannot base64-decode %q", s) 202 } 203 204 func getDecryptedAge(ctx context.Context, key string) ([]byte, error) { 205 raw, err := s3GetBytes(ctx, key) 206 if err != nil { 207 return nil, err 208 } 209 if raw == nil { 210 return nil, fmt.Errorf("key not found: %s", key) 211 } 212 return ageDecrypt(raw) 213 } 214 215 // ───────────────────────────────────────────────────────────────────────────── 216 // Room list 217 // ───────────────────────────────────────────────────────────────────────────── 218 219 type roomEntry struct { 220 RoomID string `json:"room_id"` 221 Name string `json:"name"` 222 Type string `json:"type"` 223 Aliases []string `json:"aliases"` 224 CanonicalAlias string `json:"canonical_alias,omitempty"` 225 MemberCount int `json:"member_count"` 226 Encrypted bool `json:"encrypted"` 227 ViaServers []string `json:"via_servers,omitempty"` 228 } 229 230 func listRooms(ctx context.Context, prefix string) ([]roomEntry, error) { 231 data, err := getDecryptedAge(ctx, prefix+"/rooms-latest.json.age") 232 if err != nil { 233 return nil, fmt.Errorf("fetch room list: %w", err) 234 } 235 var rooms []roomEntry 236 if err := json.Unmarshal(data, &rooms); err != nil { 237 return nil, fmt.Errorf("parse room list: %w", err) 238 } 239 return rooms, nil 240 } 241 242 // ───────────────────────────────────────────────────────────────────────────── 243 // Room account data (tags) 244 // ───────────────────────────────────────────────────────────────────────────── 245 246 // loadRoomAccountData returns the per-room account data map, or nil if absent. 247 func loadRoomAccountData(ctx context.Context, prefix string) map[string]map[string]json.RawMessage { 248 raw, err := s3GetBytes(ctx, prefix+"/room-account-data-latest.json.age") 249 if err != nil || raw == nil { 250 return nil 251 } 252 plain, err := ageDecrypt(raw) 253 if err != nil { 254 slog.Warn("Failed to decrypt room account data", "error", err) 255 return nil 256 } 257 var result map[string]map[string]json.RawMessage 258 if err := json.Unmarshal(plain, &result); err != nil { 259 slog.Warn("Failed to parse room account data", "error", err) 260 return nil 261 } 262 return result 263 } 264 265 // roomTagLabel returns a short label for known room tags (empty string if none). 266 func roomTagLabel(roomID string, roomAccountData map[string]map[string]json.RawMessage) string { 267 if roomAccountData == nil { 268 return "" 269 } 270 rd, ok := roomAccountData[roomID] 271 if !ok { 272 return "" 273 } 274 tagRaw, ok := rd["m.tag"] 275 if !ok { 276 return "" 277 } 278 var tags struct { 279 Tags map[string]json.RawMessage `json:"tags"` 280 } 281 if json.Unmarshal(tagRaw, &tags) != nil { 282 return "" 283 } 284 if _, ok := tags.Tags["m.favourite"]; ok { 285 return " [fav]" 286 } 287 if _, ok := tags.Tags["m.lowpriority"]; ok { 288 return " [low]" 289 } 290 return "" 291 } 292 293 // ───────────────────────────────────────────────────────────────────────────── 294 // Account data summary 295 // ───────────────────────────────────────────────────────────────────────────── 296 297 // printAccountDataSummary prints a brief summary of global account data. 298 func printAccountDataSummary(ctx context.Context, prefix string) { 299 raw, err := s3GetBytes(ctx, prefix+"/account-data-latest.json.age") 300 if err != nil || raw == nil { 301 return 302 } 303 plain, err := ageDecrypt(raw) 304 if err != nil { 305 return 306 } 307 var data map[string]json.RawMessage 308 if err := json.Unmarshal(plain, &data); err != nil { 309 return 310 } 311 312 var pushRuleCount int 313 if pr, ok := data["m.push_rules"]; ok { 314 var rules struct { 315 Global map[string][]json.RawMessage `json:"global"` 316 } 317 if json.Unmarshal(pr, &rules) == nil { 318 for _, list := range rules.Global { 319 pushRuleCount += len(list) 320 } 321 } 322 } 323 324 var ignoredCount int 325 if il, ok := data["m.ignored_user_list"]; ok { 326 var ignored struct { 327 IgnoredUsers map[string]json.RawMessage `json:"ignored_users"` 328 } 329 if json.Unmarshal(il, &ignored) == nil { 330 ignoredCount = len(ignored.IgnoredUsers) 331 } 332 } 333 334 if pushRuleCount > 0 || ignoredCount > 0 { 335 fmt.Printf("Account data: %d push rules, %d ignored users\n\n", pushRuleCount, ignoredCount) 336 } 337 } 338 339 // ───────────────────────────────────────────────────────────────────────────── 340 // History events 341 // ───────────────────────────────────────────────────────────────────────────── 342 343 type historyEvent struct { 344 EventID string `json:"event_id"` 345 Sender string `json:"sender"` 346 Type string `json:"type"` 347 Timestamp int64 `json:"origin_server_ts"` 348 Content json.RawMessage `json:"content"` 349 Encrypted bool `json:"encrypted,omitempty"` 350 } 351 352 // displayNameFor returns the best human-readable name for a sender. 353 func displayNameFor(userID string, displayNames map[string]string) string { 354 if n, ok := displayNames[userID]; ok && n != "" { 355 return n 356 } 357 if len(userID) > 1 && userID[0] == '@' { 358 if i := strings.Index(userID[1:], ":"); i >= 0 { 359 return userID[1 : i+1] 360 } 361 } 362 return userID 363 } 364 365 // prettyContent returns a readable one-liner for common event types. 366 func prettyContent(evType string, rawContent json.RawMessage) string { 367 var c map[string]json.RawMessage 368 if json.Unmarshal(rawContent, &c) != nil { 369 return string(rawContent) 370 } 371 switch evType { 372 case "m.room.message": 373 var body, msgtype string 374 json.Unmarshal(c["body"], &body) //nolint:errcheck 375 json.Unmarshal(c["msgtype"], &msgtype) //nolint:errcheck 376 switch msgtype { 377 case "m.image": 378 return "[image: " + body + "]" 379 case "m.file": 380 return "[file: " + body + "]" 381 case "m.video": 382 return "[video: " + body + "]" 383 case "m.audio": 384 return "[audio: " + body + "]" 385 default: 386 return body 387 } 388 case "m.room.member": 389 var membership, dn string 390 json.Unmarshal(c["membership"], &membership) //nolint:errcheck 391 json.Unmarshal(c["displayname"], &dn) //nolint:errcheck 392 if dn != "" { 393 return fmt.Sprintf("[membership: %s, displayname: %s]", membership, dn) 394 } 395 return fmt.Sprintf("[membership: %s]", membership) 396 case "m.reaction": 397 var rel map[string]json.RawMessage 398 if raw, ok := c["m.relates_to"]; ok { 399 if json.Unmarshal(raw, &rel) == nil { 400 var key string 401 json.Unmarshal(rel["key"], &key) //nolint:errcheck 402 return "[reaction: " + key + "]" 403 } 404 } 405 case "m.room.encrypted": 406 return "[encrypted — session not available]" 407 } 408 return string(rawContent) 409 } 410 411 func dumpHistory(ctx context.Context, prefix, roomID, roomName, outDir string, roomAccountData map[string]map[string]json.RawMessage) error { 412 safeKey := strings.NewReplacer("/", "_", ":", "_").Replace(roomID) 413 histPrefix := prefix + "/history/" + safeKey + "/" 414 415 keys, err := listPrefix(ctx, histPrefix) 416 if err != nil { 417 return fmt.Errorf("list history keys: %w", err) 418 } 419 if len(keys) == 0 { 420 slog.Info("No history files found", "room_id", roomID) 421 return nil 422 } 423 424 var allEvents []historyEvent 425 displayNames := make(map[string]string) 426 427 for _, key := range keys { 428 if !strings.HasSuffix(key, ".age") { 429 continue 430 } 431 data, err := getDecryptedAge(ctx, key) 432 if err != nil { 433 slog.Warn("Failed to decrypt", "key", key, "error", err) 434 continue 435 } 436 sc := bufio.NewScanner(bytes.NewReader(data)) 437 for sc.Scan() { 438 line := sc.Bytes() 439 if len(line) == 0 { 440 continue 441 } 442 var ev historyEvent 443 if err := json.Unmarshal(line, &ev); err != nil { 444 slog.Warn("Failed to parse event line", "error", err) 445 continue 446 } 447 allEvents = append(allEvents, ev) 448 if ev.Type == "m.room.member" { 449 var mc struct { 450 Displayname string `json:"displayname"` 451 } 452 if json.Unmarshal(ev.Content, &mc) == nil && mc.Displayname != "" { 453 displayNames[ev.Sender] = mc.Displayname 454 } 455 } 456 } 457 if err := sc.Err(); err != nil { 458 slog.Warn("Scanner error reading history file", "key", key, "error", err) 459 } 460 } 461 462 if len(allEvents) == 0 { 463 slog.Info("No events after parsing", "room_id", roomID) 464 return nil 465 } 466 467 // Sort by timestamp. 468 sort.Slice(allEvents, func(i, j int) bool { 469 return allEvents[i].Timestamp < allEvents[j].Timestamp 470 }) 471 472 // Deduplicate by event ID. 473 seen := make(map[string]struct{}, len(allEvents)) 474 deduped := allEvents[:0] 475 for _, ev := range allEvents { 476 if _, dup := seen[ev.EventID]; dup { 477 continue 478 } 479 seen[ev.EventID] = struct{}{} 480 deduped = append(deduped, ev) 481 } 482 allEvents = deduped 483 484 roomDir := filepath.Join(outDir, safeKey) 485 if err := os.MkdirAll(roomDir, 0700); err != nil { 486 return err 487 } 488 489 // Write plain-text log with mode 0600 (contains decrypted messages). 490 txtPath := filepath.Join(roomDir, "history.txt") 491 tf, err := os.OpenFile(txtPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600) 492 if err != nil { 493 return err 494 } 495 defer tf.Close() 496 497 fmt.Fprintf(tf, "Room: %s (%s)\n", roomName, roomID) 498 fmt.Fprintf(tf, "Events: %d\n\n", len(allEvents)) 499 for _, ev := range allEvents { 500 ts := time.UnixMilli(ev.Timestamp).UTC().Format("2006-01-02 15:04:05") 501 sender := displayNameFor(ev.Sender, displayNames) 502 content := prettyContent(ev.Type, ev.Content) 503 if ev.Encrypted { 504 fmt.Fprintf(tf, "[%s] <%s> [encrypted] %s\n", ts, sender, content) 505 } else { 506 fmt.Fprintf(tf, "[%s] <%s> %s\n", ts, sender, content) 507 } 508 } 509 510 // Write raw JSONL with mode 0600. 511 jsonlPath := filepath.Join(roomDir, "history.jsonl") 512 jf, err := os.OpenFile(jsonlPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600) 513 if err != nil { 514 return err 515 } 516 defer jf.Close() 517 enc := json.NewEncoder(jf) 518 for _, ev := range allEvents { 519 if err := enc.Encode(ev); err != nil { 520 slog.Warn("Failed to encode event to JSONL, skipping", "event_id", ev.EventID, "error", err) 521 } 522 } 523 524 // Write room tags if present. 525 if roomAccountData != nil { 526 if rd, ok := roomAccountData[roomID]; ok { 527 if tagRaw, ok := rd["m.tag"]; ok { 528 tagPath := filepath.Join(roomDir, "room-tags.json") 529 if data, err := json.MarshalIndent(tagRaw, "", " "); err == nil { 530 _ = os.WriteFile(tagPath, data, 0600) 531 } 532 } 533 } 534 } 535 536 slog.Info("Dumped room history", 537 "room", roomName, 538 "events", len(allEvents), 539 "txt", txtPath, 540 "jsonl", jsonlPath, 541 ) 542 543 downloadMedia(ctx, prefix, allEvents, roomDir) 544 return nil 545 } 546 547 // downloadMedia saves backed-up media files for the given events into a 548 // "media/" subdirectory inside roomDir. Files already present are skipped. 549 func downloadMedia(ctx context.Context, prefix string, events []historyEvent, roomDir string) { 550 mediaDir := filepath.Join(roomDir, "media") 551 552 mediaMsgTypes := map[string]bool{ 553 "m.image": true, "m.file": true, "m.video": true, 554 "m.audio": true, "m.sticker": true, 555 } 556 557 downloaded, skipped, missing := 0, 0, 0 558 559 for _, ev := range events { 560 if ev.Type != "m.room.message" && ev.Type != "m.sticker" { 561 continue 562 } 563 564 var c struct { 565 MsgType string `json:"msgtype"` 566 Body string `json:"body"` 567 URL string `json:"url"` 568 File *struct { 569 URL string `json:"url"` 570 Key struct { 571 K string `json:"k"` 572 } `json:"key"` 573 IV string `json:"iv"` 574 Hashes map[string]string `json:"hashes"` 575 } `json:"file"` 576 } 577 if json.Unmarshal(ev.Content, &c) != nil { 578 continue 579 } 580 if ev.Type == "m.room.message" && !mediaMsgTypes[c.MsgType] { 581 continue 582 } 583 584 mxcURL := c.URL 585 encrypted := false 586 if c.File != nil && c.File.URL != "" { 587 mxcURL = c.File.URL 588 encrypted = true 589 } 590 if mxcURL == "" || !strings.HasPrefix(mxcURL, "mxc://") { 591 continue 592 } 593 594 rest := mxcURL[len("mxc://"):] 595 slash := strings.Index(rest, "/") 596 if slash < 0 { 597 continue 598 } 599 server, mediaID := rest[:slash], rest[slash+1:] 600 if server == "" || mediaID == "" { 601 continue 602 } 603 604 s3Key := prefix + "/media/" + server + "/" + mediaID 605 data, err := s3GetBytes(ctx, s3Key) 606 if err != nil { 607 slog.Warn("Media fetch error", "mxc", mxcURL, "error", err) 608 missing++ 609 continue 610 } 611 if data == nil { 612 slog.Debug("Media not in backup", "mxc", mxcURL) 613 missing++ 614 continue 615 } 616 617 // Verify SHA-256 hash before decrypting to detect corruption/tampering. 618 if encrypted && c.File != nil { 619 if sha, ok := c.File.Hashes["sha256"]; ok { 620 sum := sha256.Sum256(data) 621 if base64.RawStdEncoding.EncodeToString(sum[:]) != sha { 622 slog.Warn("Media hash mismatch, skipping", "mxc", mxcURL) 623 missing++ 624 continue 625 } 626 } 627 628 // Key and IV may use URL-safe or standard base64, with or without 629 // padding, depending on which client sent the message. 630 keyBytes, err1 := tryDecodeBase64(c.File.Key.K) 631 ivBytes, err2 := tryDecodeBase64(c.File.IV) 632 if err1 != nil || err2 != nil || len(keyBytes) != 32 { 633 slog.Warn("Bad key/IV for encrypted media", "mxc", mxcURL, 634 "key_err", err1, "iv_err", err2, "key_len", len(keyBytes), "iv_len", len(ivBytes)) 635 missing++ 636 continue 637 } 638 // The spec stores the 64-bit random IV prefix; the full 128-bit 639 // AES-CTR counter block is IV || zeros (counter starts at 0). 640 // Some clients store all 16 bytes directly — accept both. 641 if len(ivBytes) == 8 { 642 full := make([]byte, 16) 643 copy(full, ivBytes) 644 ivBytes = full 645 } 646 if len(ivBytes) != 16 { 647 slog.Warn("Bad IV length for encrypted media", "mxc", mxcURL, "iv_len", len(ivBytes)) 648 missing++ 649 continue 650 } 651 block, err := aes.NewCipher(keyBytes) 652 if err != nil { 653 slog.Warn("AES init failed", "error", err) 654 missing++ 655 continue 656 } 657 plain := make([]byte, len(data)) 658 cipher.NewCTR(block, ivBytes).XORKeyStream(plain, data) 659 data = plain 660 } 661 662 // Sanitise the filename: strip path-separator characters and control chars, 663 // then verify the resolved path stays within mediaDir. 664 safeName := strings.Map(func(r rune) rune { 665 if r < 0x20 || strings.ContainsRune(`/\:*?"<>|`, r) { 666 return '_' 667 } 668 return r 669 }, c.Body) 670 if safeName == "" { 671 safeName = mediaID 672 } 673 outName := mediaID[:8] + "_" + safeName 674 outPath := filepath.Join(mediaDir, outName) 675 676 // Prevent path traversal: ensure the resolved path is under mediaDir. 677 cleanMedia := filepath.Clean(mediaDir) + string(os.PathSeparator) 678 if !strings.HasPrefix(filepath.Clean(outPath)+string(os.PathSeparator), cleanMedia) { 679 slog.Warn("Skipping unsafe media path", "body", c.Body, "mxc", mxcURL) 680 missing++ 681 continue 682 } 683 684 if _, err := os.Stat(outPath); err == nil { 685 skipped++ 686 continue 687 } 688 if err := os.MkdirAll(mediaDir, 0700); err != nil { 689 slog.Warn("Cannot create media dir", "error", err) 690 return 691 } 692 if err := os.WriteFile(outPath, data, 0600); err != nil { 693 slog.Warn("Cannot write media file", "path", outPath, "error", err) 694 continue 695 } 696 downloaded++ 697 } 698 699 slog.Info("Media download complete", 700 "downloaded", downloaded, 701 "skipped", skipped, 702 "not_in_backup", missing, 703 "dir", mediaDir, 704 ) 705 } 706 707 // ───────────────────────────────────────────────────────────────────────────── 708 // Main 709 // ───────────────────────────────────────────────────────────────────────────── 710 711 func main() { 712 flag.Parse() 713 714 if *flagIdentity == "" || *flagPrefix == "" { 715 fmt.Fprintln(os.Stderr, "Usage: go run ./recovery/ -identity <age-key-file> -prefix <mtrnord|lexi> [-room <room-id>] [-all] [-out ./decrypted]") 716 os.Exit(1) 717 } 718 719 slog.SetDefault(slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelInfo}))) 720 721 ctx := context.Background() 722 723 secrets, err := parseSecretYAML(*flagSecret) 724 if err != nil { 725 slog.Error("Failed to parse secret.yaml", "path", *flagSecret, "error", err) 726 os.Exit(1) 727 } 728 accessKey := secrets["s3_access_key"] 729 secretKey := secrets["s3_secret_key"] 730 if accessKey == "" || secretKey == "" { 731 slog.Error("secret.yaml missing s3_access_key or s3_secret_key", "path", *flagSecret) 732 os.Exit(1) 733 } 734 slog.Info("Loaded S3 credentials from secret.yaml") 735 736 if err := loadIdentities(*flagIdentity); err != nil { 737 slog.Error("Failed to load age identity", "error", err) 738 os.Exit(1) 739 } 740 741 if err := initS3(ctx, *flagEndpoint, *flagRegion, *flagBucket, accessKey, secretKey); err != nil { 742 slog.Error("Failed to init S3", "error", err) 743 os.Exit(1) 744 } 745 746 rooms, err := listRooms(ctx, *flagPrefix) 747 if err != nil { 748 slog.Error("Failed to list rooms", "error", err) 749 os.Exit(1) 750 } 751 752 roomAccountData := loadRoomAccountData(ctx, *flagPrefix) 753 printAccountDataSummary(ctx, *flagPrefix) 754 755 fmt.Printf("Rooms for prefix %q (%d total):\n\n", *flagPrefix, len(rooms)) 756 fmt.Printf("%-55s %-7s %-7s %s\n", "ROOM ID", "TYPE", "MEMBERS", "NAME") 757 fmt.Printf("%s\n", strings.Repeat("-", 110)) 758 for _, r := range rooms { 759 enc := "" 760 if r.Encrypted { 761 enc = " [E2EE]" 762 } 763 tag := roomTagLabel(r.RoomID, roomAccountData) 764 fmt.Printf("%-55s %-7s %-7d %s%s%s\n", r.RoomID, r.Type, r.MemberCount, r.Name, enc, tag) 765 if r.CanonicalAlias != "" { 766 fmt.Printf(" alias: %s\n", r.CanonicalAlias) 767 } else if len(r.Aliases) > 0 { 768 fmt.Printf(" alias: %s\n", r.Aliases[0]) 769 } 770 if len(r.ViaServers) > 0 { 771 fmt.Printf(" via: %s\n", strings.Join(r.ViaServers, ", ")) 772 } 773 } 774 fmt.Println() 775 776 if !*flagAll && *flagRoom == "" { 777 return 778 } 779 780 if err := os.MkdirAll(*flagOut, 0700); err != nil { 781 slog.Error("Failed to create output dir", "error", err) 782 os.Exit(1) 783 } 784 785 if *flagAll { 786 for _, r := range rooms { 787 if err := dumpHistory(ctx, *flagPrefix, r.RoomID, r.Name, *flagOut, roomAccountData); err != nil { 788 slog.Warn("Failed to dump room", "room_id", r.RoomID, "error", err) 789 } 790 } 791 } else { 792 name := *flagRoom 793 for _, r := range rooms { 794 if r.RoomID == *flagRoom { 795 name = r.Name 796 break 797 } 798 } 799 if err := dumpHistory(ctx, *flagPrefix, *flagRoom, name, *flagOut, roomAccountData); err != nil { 800 slog.Error("Failed to dump room", "room_id", *flagRoom, "error", err) 801 os.Exit(1) 802 } 803 } 804 }