305 lines
11 KiB
Go
305 lines
11 KiB
Go
package libgm
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/base64"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"github.com/google/uuid"
|
|
"google.golang.org/protobuf/proto"
|
|
|
|
"go.mau.fi/mautrix-gmessages/libgm/crypto"
|
|
"go.mau.fi/mautrix-gmessages/libgm/gmproto"
|
|
"go.mau.fi/mautrix-gmessages/libgm/util"
|
|
)
|
|
|
|
type MediaType struct {
|
|
Extension string
|
|
Format string
|
|
Type gmproto.MediaFormats
|
|
}
|
|
|
|
var MimeToMediaType = map[string]MediaType{
|
|
"image/jpeg": {Extension: "jpeg", Type: gmproto.MediaFormats_IMAGE_JPEG},
|
|
"image/jpg": {Extension: "jpg", Type: gmproto.MediaFormats_IMAGE_JPG},
|
|
"image/png": {Extension: "png", Type: gmproto.MediaFormats_IMAGE_PNG},
|
|
"image/gif": {Extension: "gif", Type: gmproto.MediaFormats_IMAGE_GIF},
|
|
"image/wbmp": {Extension: "wbmp", Type: gmproto.MediaFormats_IMAGE_WBMP},
|
|
"image/bmp": {Extension: "bmp", Type: gmproto.MediaFormats_IMAGE_X_MS_BMP},
|
|
"image/x-ms-bmp": {Extension: "bmp", Type: gmproto.MediaFormats_IMAGE_X_MS_BMP},
|
|
|
|
"video/mp4": {Extension: "mp4", Type: gmproto.MediaFormats_VIDEO_MP4},
|
|
"video/3gpp2": {Extension: "3gpp2", Type: gmproto.MediaFormats_VIDEO_3G2},
|
|
"video/3gpp": {Extension: "3gpp", Type: gmproto.MediaFormats_VIDEO_3GPP},
|
|
"video/webm": {Extension: "webm", Type: gmproto.MediaFormats_VIDEO_WEBM},
|
|
"video/x-matroska": {Extension: "mkv", Type: gmproto.MediaFormats_VIDEO_MKV},
|
|
|
|
"audio/aac": {Extension: "aac", Type: gmproto.MediaFormats_AUDIO_AAC},
|
|
"audio/amr": {Extension: "amr", Type: gmproto.MediaFormats_AUDIO_AMR},
|
|
"audio/mp3": {Extension: "mp3", Type: gmproto.MediaFormats_AUDIO_MP3},
|
|
"audio/mpeg": {Extension: "mpeg", Type: gmproto.MediaFormats_AUDIO_MPEG},
|
|
"audio/mpg": {Extension: "mpg", Type: gmproto.MediaFormats_AUDIO_MPG},
|
|
"audio/mp4": {Extension: "mp4", Type: gmproto.MediaFormats_AUDIO_MP4},
|
|
"audio/mp4-latm": {Extension: "latm", Type: gmproto.MediaFormats_AUDIO_MP4_LATM},
|
|
"audio/3gpp": {Extension: "3gpp", Type: gmproto.MediaFormats_AUDIO_3GPP},
|
|
"audio/ogg": {Extension: "ogg", Type: gmproto.MediaFormats_AUDIO_OGG},
|
|
|
|
"text/vcard": {Extension: "vcard", Type: gmproto.MediaFormats_TEXT_VCARD},
|
|
"application/pdf": {Extension: "pdf", Type: gmproto.MediaFormats_APP_PDF},
|
|
"text/plain": {Extension: "txt", Type: gmproto.MediaFormats_APP_TXT},
|
|
"text/html": {Extension: "html", Type: gmproto.MediaFormats_APP_HTML},
|
|
"application/msword": {Extension: "doc", Type: gmproto.MediaFormats_APP_DOC},
|
|
"application/vnd.openxmlformats-officedocument.wordprocessingml.document": {Extension: "docx", Type: gmproto.MediaFormats_APP_DOCX},
|
|
"application/vnd.openxmlformats-officedocument.presentationml.presentation": {Extension: "pptx", Type: gmproto.MediaFormats_APP_PPTX},
|
|
"application/vnd.ms-powerpoint": {Extension: "ppt", Type: gmproto.MediaFormats_APP_PPT},
|
|
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": {Extension: "xlsx", Type: gmproto.MediaFormats_APP_XLSX},
|
|
"application/vnd.ms-excel": {Extension: "xls", Type: gmproto.MediaFormats_APP_XLS},
|
|
"application/vnd.android.package-archive": {Extension: "apk", Type: gmproto.MediaFormats_APP_APK},
|
|
"application/zip": {Extension: "zip", Type: gmproto.MediaFormats_APP_ZIP},
|
|
"application/java-archive": {Extension: "jar", Type: gmproto.MediaFormats_APP_JAR},
|
|
"text/x-calendar": {Extension: "vcs", Type: gmproto.MediaFormats_CAL_TEXT_VCALENDAR},
|
|
"text/calendar": {Extension: "ics", Type: gmproto.MediaFormats_CAL_TEXT_CALENDAR},
|
|
|
|
"image": {Type: gmproto.MediaFormats_IMAGE_UNSPECIFIED},
|
|
"video": {Type: gmproto.MediaFormats_VIDEO_UNSPECIFIED},
|
|
"audio": {Type: gmproto.MediaFormats_AUDIO_UNSPECIFIED},
|
|
"application": {Type: gmproto.MediaFormats_APP_UNSPECIFIED},
|
|
"text": {Type: gmproto.MediaFormats_APP_TXT},
|
|
}
|
|
|
|
var FormatToMediaType = map[gmproto.MediaFormats]MediaType{
|
|
gmproto.MediaFormats_CAL_TEXT_XVCALENDAR: MimeToMediaType["text/x-calendar"],
|
|
gmproto.MediaFormats_CAL_APPLICATION_VCS: MimeToMediaType["text/x-calendar"],
|
|
gmproto.MediaFormats_CAL_APPLICATION_ICS: MimeToMediaType["text/calendar"],
|
|
//gmproto.MediaFormats_CAL_APPLICATION_HBSVCS: ???
|
|
}
|
|
|
|
func init() {
|
|
for key, mediaType := range MimeToMediaType {
|
|
if strings.ContainsRune(key, '/') {
|
|
mediaType.Format = key
|
|
}
|
|
FormatToMediaType[mediaType.Type] = mediaType
|
|
}
|
|
}
|
|
|
|
func (c *Client) UploadMedia(data []byte, fileName, mime string) (*gmproto.MediaContent, error) {
|
|
mediaType := MimeToMediaType[mime]
|
|
if mediaType.Type == 0 {
|
|
mediaType = MimeToMediaType[strings.Split(mime, "/")[0]]
|
|
}
|
|
decryptionKey := crypto.GenerateKey(32)
|
|
cryptor, err := crypto.NewAESGCMHelper(decryptionKey)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
encryptedBytes, err := cryptor.EncryptData(data)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to encrypt media: %w", err)
|
|
}
|
|
startUploadImage, err := c.StartUploadMedia(encryptedBytes, mime)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to start upload: %w", err)
|
|
}
|
|
upload, err := c.FinalizeUploadMedia(startUploadImage)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to finalize upload: %w", err)
|
|
}
|
|
return &gmproto.MediaContent{
|
|
Format: mediaType.Type,
|
|
MediaID: upload.MediaID,
|
|
MediaName: fileName,
|
|
Size: int64(len(data)),
|
|
DecryptionKey: decryptionKey,
|
|
MimeType: mime,
|
|
}, nil
|
|
}
|
|
|
|
type StartGoogleUpload struct {
|
|
UploadID string
|
|
UploadURL string
|
|
UploadStatus string
|
|
ChunkGranularity int64
|
|
ControlURL string
|
|
MimeType string
|
|
|
|
EncryptedMediaBytes []byte
|
|
}
|
|
|
|
type MediaUpload struct {
|
|
MediaID string
|
|
MediaNumber int64
|
|
}
|
|
|
|
func isBase64Character(char byte) bool {
|
|
return (char >= 'A' && char <= 'Z') || (char >= 'a' && char <= 'z') || (char >= '0' && char <= '9') || char == '+' || char == '/' || char == '='
|
|
}
|
|
|
|
func isStandardBase64(data []byte) bool {
|
|
if len(data)%4 != 0 {
|
|
return false
|
|
}
|
|
for _, char := range data {
|
|
if !isBase64Character(char) {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
func (c *Client) FinalizeUploadMedia(upload *StartGoogleUpload) (*MediaUpload, error) {
|
|
encryptedImageSize := strconv.Itoa(len(upload.EncryptedMediaBytes))
|
|
|
|
finalizeUploadHeaders := util.NewMediaUploadHeaders(encryptedImageSize, "upload, finalize", "0", upload.MimeType, "")
|
|
req, err := http.NewRequest(http.MethodPost, upload.UploadURL, bytes.NewBuffer(upload.EncryptedMediaBytes))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to prepare request: %w", err)
|
|
}
|
|
req.Header = *finalizeUploadHeaders
|
|
|
|
res, err := c.http.Do(req)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to send request: %w", err)
|
|
}
|
|
defer res.Body.Close()
|
|
|
|
if res.StatusCode != 200 {
|
|
return nil, fmt.Errorf("unexpected status code %d", res.StatusCode)
|
|
}
|
|
respData, err := io.ReadAll(res.Body)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to read response: %w", err)
|
|
}
|
|
if isStandardBase64(respData) {
|
|
n, err := base64.StdEncoding.Decode(respData, respData)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to decode response: %w", err)
|
|
}
|
|
respData = respData[:n]
|
|
}
|
|
|
|
c.Logger.Debug().
|
|
Str("upload_status", res.Header.Get("x-goog-upload-status")).
|
|
Msg("Upload complete")
|
|
|
|
mediaIDs := &gmproto.UploadMediaResponse{}
|
|
err = proto.Unmarshal(respData, mediaIDs)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to unmarshal response: %w", err)
|
|
}
|
|
return &MediaUpload{
|
|
MediaID: mediaIDs.Media.MediaID,
|
|
MediaNumber: mediaIDs.Media.MediaNumber,
|
|
}, nil
|
|
}
|
|
|
|
func (c *Client) StartUploadMedia(encryptedImageBytes []byte, mime string) (*StartGoogleUpload, error) {
|
|
encryptedImageSize := strconv.Itoa(len(encryptedImageBytes))
|
|
|
|
startUploadHeaders := util.NewMediaUploadHeaders(encryptedImageSize, "start", "", mime, "resumable")
|
|
startUploadPayload, err := c.buildStartUploadPayload()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to build payload: %w", err)
|
|
}
|
|
|
|
req, err := http.NewRequest(http.MethodPost, util.UploadMediaURL, bytes.NewBuffer([]byte(startUploadPayload)))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to prepare request: %w", err)
|
|
}
|
|
req.Header = *startUploadHeaders
|
|
|
|
res, err := c.http.Do(req)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to send request: %w", err)
|
|
}
|
|
_ = res.Body.Close()
|
|
|
|
if res.StatusCode != 200 {
|
|
return nil, fmt.Errorf("unexpected status code %d", res.StatusCode)
|
|
}
|
|
|
|
chunkGranularity, err := strconv.Atoi(res.Header.Get("x-goog-upload-chunk-granularity"))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to parse chunk granularity: %w", err)
|
|
}
|
|
|
|
uploadResponse := &StartGoogleUpload{
|
|
UploadID: res.Header.Get("x-guploader-uploadid"),
|
|
UploadURL: res.Header.Get("x-goog-upload-url"),
|
|
UploadStatus: res.Header.Get("x-goog-upload-status"),
|
|
ChunkGranularity: int64(chunkGranularity),
|
|
ControlURL: res.Header.Get("x-goog-upload-control-url"),
|
|
MimeType: mime,
|
|
|
|
EncryptedMediaBytes: encryptedImageBytes,
|
|
}
|
|
return uploadResponse, nil
|
|
}
|
|
|
|
func (c *Client) buildStartUploadPayload() (string, error) {
|
|
protoData := &gmproto.StartMediaUploadRequest{
|
|
AttachmentType: 1,
|
|
AuthData: &gmproto.AuthMessage{
|
|
RequestID: uuid.NewString(),
|
|
TachyonAuthToken: c.AuthData.TachyonAuthToken,
|
|
Network: c.AuthData.AuthNetwork(),
|
|
ConfigVersion: util.ConfigMessage,
|
|
},
|
|
Mobile: c.AuthData.Mobile,
|
|
}
|
|
|
|
protoDataBytes, err := proto.Marshal(protoData)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
protoDataEncoded := base64.StdEncoding.EncodeToString(protoDataBytes)
|
|
|
|
return protoDataEncoded, nil
|
|
}
|
|
|
|
func (c *Client) DownloadMedia(mediaID string, key []byte) ([]byte, error) {
|
|
downloadMetadata := &gmproto.DownloadAttachmentRequest{
|
|
Info: &gmproto.AttachmentInfo{
|
|
AttachmentID: mediaID,
|
|
Encrypted: true,
|
|
},
|
|
AuthData: &gmproto.AuthMessage{
|
|
RequestID: uuid.NewString(),
|
|
TachyonAuthToken: c.AuthData.TachyonAuthToken,
|
|
Network: c.AuthData.AuthNetwork(),
|
|
ConfigVersion: util.ConfigMessage,
|
|
},
|
|
}
|
|
downloadMetadataBytes, err := proto.Marshal(downloadMetadata)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to marshal download request: %w", err)
|
|
}
|
|
downloadMetadataEncoded := base64.StdEncoding.EncodeToString(downloadMetadataBytes)
|
|
req, err := http.NewRequest(http.MethodGet, util.UploadMediaURL, nil)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to prepare request: %w", err)
|
|
}
|
|
util.BuildUploadHeaders(req, downloadMetadataEncoded)
|
|
res, err := c.http.Do(req)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to send request: %w", err)
|
|
}
|
|
defer res.Body.Close()
|
|
respData, err := io.ReadAll(res.Body)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to read response: %w", err)
|
|
}
|
|
cryptor, err := crypto.NewAESGCMHelper(key)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
decryptedImageBytes, err := cryptor.DecryptData(respData)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to decrypt media: %w", err)
|
|
}
|
|
return decryptedImageBytes, nil
|
|
}
|