cluster

Infrastructure files for Nordgedanken and Midnightthoughts.
git clone git://archive.git.mtrnord.blog/MTRNord/cluster.git
Log | Files | Refs | README

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 }