media.go (2624B)
1 // media.go — Matrix media download and S3 storage. 2 package main 3 4 import ( 5 "context" 6 "fmt" 7 "io" 8 "log/slog" 9 "net/http" 10 "net/url" 11 "strings" 12 "time" 13 14 "maunium.net/go/mautrix" 15 ) 16 17 // mediaHTTPClient is used for all media downloads; the 5-minute timeout 18 // prevents a single large file from stalling the entire backup run. 19 var mediaHTTPClient = &http.Client{Timeout: 5 * time.Minute} 20 21 var mediaMsgTypes = map[string]bool{ 22 "m.image": true, 23 "m.file": true, 24 "m.video": true, 25 "m.audio": true, 26 } 27 28 // downloadAndStoreMedia downloads a single mxc:// URL and stores it under 29 // prefix/label/server/mediaID in S3, skipping if already present. 30 func downloadAndStoreMedia(ctx context.Context, client *mautrix.Client, mxcURL, prefix, label string) error { 31 if !strings.HasPrefix(mxcURL, "mxc://") { 32 return nil 33 } 34 rest := mxcURL[len("mxc://"):] 35 slashIdx := strings.Index(rest, "/") 36 if slashIdx < 0 { 37 return nil 38 } 39 server := rest[:slashIdx] 40 mediaID := rest[slashIdx+1:] 41 if server == "" || mediaID == "" { 42 return nil 43 } 44 45 s3Key := prefix + "/" + label + "/" + server + "/" + mediaID 46 if s3Exists(ctx, s3Key) { 47 return nil 48 } 49 50 base := strings.TrimRight(client.HomeserverURL.String(), "/") 51 downloadURL := base + "/_matrix/media/v3/download/" + 52 url.PathEscape(server) + "/" + url.PathEscape(mediaID) 53 54 req, err := http.NewRequestWithContext(ctx, http.MethodGet, downloadURL, nil) 55 if err != nil { 56 return err 57 } 58 req.Header.Set("Authorization", "Bearer "+client.AccessToken) 59 resp, err := mediaHTTPClient.Do(req) 60 if err != nil { 61 return err 62 } 63 defer resp.Body.Close() 64 65 if resp.StatusCode != http.StatusOK { 66 return fmt.Errorf("media download HTTP %d for %s", resp.StatusCode, mxcURL) 67 } 68 69 data, err := io.ReadAll(resp.Body) 70 if err != nil { 71 return err 72 } 73 74 ct := resp.Header.Get("Content-Type") 75 if ct == "" { 76 ct = "application/octet-stream" 77 } 78 79 finalKey := s3Key 80 if ext := extFromContentType(ct); ext != "" { 81 finalKey = s3Key + ext 82 } 83 if err := s3Put(ctx, finalKey, data, ct); err != nil { 84 return err 85 } 86 slog.Info("Stored media", "key", finalKey, "bytes", len(data)) 87 return nil 88 } 89 90 func extFromContentType(ct string) string { 91 if i := strings.Index(ct, ";"); i >= 0 { 92 ct = ct[:i] 93 } 94 switch strings.TrimSpace(ct) { 95 case "image/jpeg": 96 return ".jpg" 97 case "image/png": 98 return ".png" 99 case "image/gif": 100 return ".gif" 101 case "image/webp": 102 return ".webp" 103 case "video/mp4": 104 return ".mp4" 105 case "video/webm": 106 return ".webm" 107 case "audio/mpeg": 108 return ".mp3" 109 case "audio/ogg": 110 return ".ogg" 111 case "audio/opus": 112 return ".opus" 113 case "application/pdf": 114 return ".pdf" 115 } 116 return "" 117 }