accountdata.go (5508B)
1 // accountdata.go — backup of global account data, per-room account data, and 2 // the initial profile snapshot. All three use a retry-on-next-run pattern: 3 // failures are logged as warnings so the next CronJob run retries automatically. 4 package main 5 6 import ( 7 "context" 8 "encoding/json" 9 "log/slog" 10 "net/url" 11 12 "maunium.net/go/mautrix" 13 "maunium.net/go/mautrix/event" 14 ) 15 16 // backupAccountData saves a snapshot of all global account-data events to S3. 17 // The strategy is overwrite-every-run so the file is always current; a failed 18 // write leaves the previous version in place and the next run retries. 19 // 20 // S3 keys: 21 // 22 // {prefix}/account-data-latest.json.age 23 // {prefix}/account-data-{dateStr}.json.age 24 func backupAccountData(ctx context.Context, client *mautrix.Client, syncResp *mautrix.RespSync, prefix string) { 25 accountData := make(map[string]json.RawMessage) 26 27 // Seed from the sync response (present on first sync and when changed). 28 for _, ev := range syncResp.AccountData.Events { 29 if ev.Content.VeryRaw != nil { 30 accountData[ev.Type.Type] = ev.Content.VeryRaw 31 } 32 } 33 34 // Always fetch these key types directly from the API so they're present even 35 // on incremental syncs where account data hasn't changed. 36 for _, t := range []string{"m.push_rules", "m.ignored_user_list", "m.direct"} { 37 if _, ok := accountData[t]; !ok { 38 var raw json.RawMessage 39 if err := getAccountData(ctx, client, t, &raw); err == nil && raw != nil { 40 accountData[t] = raw 41 } 42 } 43 } 44 45 if len(accountData) == 0 { 46 slog.Info("No account data to save", "prefix", prefix) 47 return 48 } 49 50 data, err := json.MarshalIndent(accountData, "", " ") 51 if err != nil { 52 slog.Warn("Failed to marshal account data", "error", err) 53 return 54 } 55 if err := s3PutAge(ctx, prefix+"/account-data-latest.json", data); err != nil { 56 slog.Warn("Failed to save account data", "prefix", prefix, "error", err) 57 return 58 } 59 if err := s3PutAge(ctx, prefix+"/account-data-"+dateStr+".json", data); err != nil { 60 slog.Warn("Failed to save dated account data", "prefix", prefix, "error", err) 61 } 62 slog.Info("Account data saved", "prefix", prefix, "types", len(accountData)) 63 } 64 65 // backupRoomAccountData saves per-room account-data (room tags, read markers, 66 // etc.) to S3 using a merge pattern so rooms absent from this sync retain their 67 // last-known values. 68 // 69 // S3 key: 70 // 71 // {prefix}/room-account-data-latest.json.age 72 func backupRoomAccountData(ctx context.Context, client *mautrix.Client, syncResp *mautrix.RespSync, prefix string) { 73 // Load existing data (merge pattern — rooms not in this sync are preserved). 74 existing := make(map[string]map[string]json.RawMessage) 75 if raw, err := getDecryptedAgeFromS3(ctx, prefix+"/room-account-data-latest.json.age"); err != nil { 76 slog.Warn("Could not load previous room account data", "error", err) 77 } else if raw != nil { 78 if err := json.Unmarshal(raw, &existing); err != nil { 79 slog.Warn("Previous room account data is corrupt, starting fresh", "error", err) 80 } 81 } 82 83 for roomID, joinedRoom := range syncResp.Rooms.Join { 84 roomData := existing[string(roomID)] 85 if roomData == nil { 86 roomData = make(map[string]json.RawMessage) 87 } 88 89 // Collect all account-data types present in the sync delta. 90 for _, ev := range joinedRoom.AccountData.Events { 91 if ev.Content.VeryRaw != nil { 92 roomData[ev.Type.Type] = ev.Content.VeryRaw 93 } 94 } 95 96 // Always fetch m.room.tag directly so it's present even without a delta. 97 tagPath := "/_matrix/client/v3/user/" + 98 url.PathEscape(client.UserID.String()) + 99 "/rooms/" + 100 url.PathEscape(string(roomID)) + 101 "/tags" 102 var tags json.RawMessage 103 if err := matrixGetJSON(ctx, client, tagPath, &tags); err == nil && tags != nil { 104 roomData[event.AccountDataRoomTags.Type] = tags 105 } 106 107 existing[string(roomID)] = roomData 108 } 109 110 data, err := json.MarshalIndent(existing, "", " ") 111 if err != nil { 112 slog.Warn("Failed to marshal room account data", "error", err) 113 return 114 } 115 if err := s3PutAge(ctx, prefix+"/room-account-data-latest.json", data); err != nil { 116 slog.Warn("Failed to save room account data", "prefix", prefix, "error", err) 117 return 118 } 119 slog.Info("Room account data saved", "prefix", prefix, "rooms", len(existing)) 120 } 121 122 // backupProfileSnapshot saves the user's current displayname and avatar_url to 123 // S3 on the first run. Subsequent runs skip it (write-once semantics); if the 124 // write failed on a previous run the file is absent and this run will retry. 125 // 126 // S3 key: 127 // 128 // {prefix}/profile-snapshot.json.age 129 func backupProfileSnapshot(ctx context.Context, client *mautrix.Client, prefix string) { 130 snapshotKey := prefix + "/profile-snapshot.json.age" 131 if s3Exists(ctx, snapshotKey) { 132 return 133 } 134 var profile struct { 135 Displayname string `json:"displayname"` 136 AvatarURL string `json:"avatar_url"` 137 } 138 path := "/_matrix/client/v3/profile/" + url.PathEscape(client.UserID.String()) 139 if err := matrixGetJSON(ctx, client, path, &profile); err != nil { 140 slog.Warn("Failed to fetch profile snapshot", "error", err) 141 return 142 } 143 data, err := json.MarshalIndent(profile, "", " ") 144 if err != nil { 145 slog.Warn("Failed to marshal profile snapshot", "error", err) 146 return 147 } 148 // s3PutAge appends ".age" — writes to prefix+"/profile-snapshot.json.age". 149 if err := s3PutAge(ctx, prefix+"/profile-snapshot.json", data); err != nil { 150 slog.Warn("Failed to save profile snapshot", "prefix", prefix, "error", err) 151 return 152 } 153 slog.Info("Profile snapshot saved", "prefix", prefix, "displayname", profile.Displayname) 154 }