From 70f6d6546fcecf866097f494d19f7b99e32a7f71 Mon Sep 17 00:00:00 2001 From: Tovi Jaeschke-Rogers Date: Wed, 31 Aug 2022 06:37:51 +0930 Subject: [PATCH] Add conversation image support --- Backend/Api/Friends/AcceptFriendRequest.go | 2 +- Backend/Api/Messages/AddConversationImage.go | 64 +++++++++++++++++++ Backend/Api/Messages/Conversations.go | 42 ++++++++---- Backend/Api/Messages/UpdateConversation.go | 7 +- Backend/Api/Routes.go | 1 + Backend/Database/Attachments.go | 42 ++++++++++++ Backend/Database/ConversationDetails.go | 7 +- Backend/Models/Conversations.go | 8 ++- .../lib/exceptions/update_data_exception.dart | 13 ++++ mobile/lib/models/conversations.dart | 12 ++++ mobile/lib/models/image_message.dart | 26 ++------ mobile/lib/utils/storage/conversations.dart | 44 +++++++++++-- mobile/lib/utils/storage/get_file.dart | 32 ++++++++++ .../lib/views/main/conversation/settings.dart | 16 ++++- 14 files changed, 268 insertions(+), 48 deletions(-) create mode 100644 Backend/Api/Messages/AddConversationImage.go create mode 100644 Backend/Database/Attachments.go create mode 100644 mobile/lib/exceptions/update_data_exception.dart create mode 100644 mobile/lib/utils/storage/get_file.dart diff --git a/Backend/Api/Friends/AcceptFriendRequest.go b/Backend/Api/Friends/AcceptFriendRequest.go index adfa0e5..aa9e233 100644 --- a/Backend/Api/Friends/AcceptFriendRequest.go +++ b/Backend/Api/Friends/AcceptFriendRequest.go @@ -32,7 +32,7 @@ func AcceptFriendRequest(w http.ResponseWriter, r *http.Request) { oldFriendRequest, err = Database.GetFriendRequestByID(friendRequestID) if err != nil { - http.Error(w, "Error", http.StatusInternalServerError) + http.Error(w, "Not Found", http.StatusNotFound) return } diff --git a/Backend/Api/Messages/AddConversationImage.go b/Backend/Api/Messages/AddConversationImage.go new file mode 100644 index 0000000..1da2866 --- /dev/null +++ b/Backend/Api/Messages/AddConversationImage.go @@ -0,0 +1,64 @@ +package Messages + +import ( + "encoding/base64" + "encoding/json" + "net/http" + + "git.tovijaeschke.xyz/tovi/Envelope/Backend/Database" + "git.tovijaeschke.xyz/tovi/Envelope/Backend/Models" + "git.tovijaeschke.xyz/tovi/Envelope/Backend/Util" + "github.com/gorilla/mux" +) + +// AddConversationImage adds an image for a conversation icon +func AddConversationImage(w http.ResponseWriter, r *http.Request) { + var ( + attachment Models.Attachment + conversationDetail Models.ConversationDetail + urlVars map[string]string + detailID string + decodedFile []byte + fileName string + ok bool + err error + ) + + urlVars = mux.Vars(r) + detailID, ok = urlVars["detailID"] + if !ok { + http.Error(w, "Not Found", http.StatusNotFound) + return + } + + conversationDetail, err = Database.GetConversationDetailByID(detailID) + if err != nil { + http.Error(w, "Not Found", http.StatusNotFound) + return + } + + err = json.NewDecoder(r.Body).Decode(&attachment) + if err != nil { + http.Error(w, "Error", http.StatusInternalServerError) + return + } + + if attachment.Data == "" { + http.Error(w, "Error", http.StatusInternalServerError) + return + } + + decodedFile, err = base64.StdEncoding.DecodeString(attachment.Data) + fileName, err = Util.WriteFile(decodedFile) + attachment.FilePath = fileName + + conversationDetail.Attachment = attachment + + err = Database.UpdateConversationDetail(&conversationDetail) + if err != nil { + http.Error(w, "Error", http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusNoContent) +} diff --git a/Backend/Api/Messages/Conversations.go b/Backend/Api/Messages/Conversations.go index 27d1470..dde7583 100644 --- a/Backend/Api/Messages/Conversations.go +++ b/Backend/Api/Messages/Conversations.go @@ -2,6 +2,7 @@ package Messages import ( "encoding/json" + "fmt" "net/http" "net/url" "strings" @@ -14,10 +15,10 @@ import ( // EncryptedConversationList returns an encrypted list of all Conversations func EncryptedConversationList(w http.ResponseWriter, r *http.Request) { var ( - userConversations []Models.UserConversation - userSession Models.Session - returnJSON []byte - err error + conversationDetails []Models.UserConversation + userSession Models.Session + returnJSON []byte + err error ) userSession, err = Auth.CheckCookie(r) @@ -26,7 +27,7 @@ func EncryptedConversationList(w http.ResponseWriter, r *http.Request) { return } - userConversations, err = Database.GetUserConversationsByUserId( + conversationDetails, err = Database.GetUserConversationsByUserId( userSession.UserID.String(), ) if err != nil { @@ -34,7 +35,7 @@ func EncryptedConversationList(w http.ResponseWriter, r *http.Request) { return } - returnJSON, err = json.MarshalIndent(userConversations, "", " ") + returnJSON, err = json.MarshalIndent(conversationDetails, "", " ") if err != nil { http.Error(w, "Error", http.StatusInternalServerError) return @@ -47,12 +48,14 @@ func EncryptedConversationList(w http.ResponseWriter, r *http.Request) { // EncryptedConversationDetailsList returns an encrypted list of all ConversationDetails func EncryptedConversationDetailsList(w http.ResponseWriter, r *http.Request) { var ( - userConversations []Models.ConversationDetail - query url.Values - conversationIds []string - returnJSON []byte - ok bool - err error + conversationDetails []Models.ConversationDetail + detail Models.ConversationDetail + query url.Values + conversationIds []string + returnJSON []byte + i int + ok bool + err error ) query = r.URL.Query() @@ -65,7 +68,7 @@ func EncryptedConversationDetailsList(w http.ResponseWriter, r *http.Request) { // TODO: Fix error handling here conversationIds = strings.Split(conversationIds[0], ",") - userConversations, err = Database.GetConversationDetailsByIds( + conversationDetails, err = Database.GetConversationDetailsByIds( conversationIds, ) if err != nil { @@ -73,7 +76,18 @@ func EncryptedConversationDetailsList(w http.ResponseWriter, r *http.Request) { return } - returnJSON, err = json.MarshalIndent(userConversations, "", " ") + for i, detail = range conversationDetails { + if detail.AttachmentID == nil { + continue + } + + conversationDetails[i].Attachment.ImageLink = fmt.Sprintf( + "http://192.168.1.5:8080/files/%s", + detail.Attachment.FilePath, + ) + } + + returnJSON, err = json.MarshalIndent(conversationDetails, "", " ") if err != nil { http.Error(w, "Error", http.StatusInternalServerError) return diff --git a/Backend/Api/Messages/UpdateConversation.go b/Backend/Api/Messages/UpdateConversation.go index 93b5215..4900ba8 100644 --- a/Backend/Api/Messages/UpdateConversation.go +++ b/Backend/Api/Messages/UpdateConversation.go @@ -10,16 +10,17 @@ import ( "git.tovijaeschke.xyz/tovi/Envelope/Backend/Models" ) -type RawUpdateConversationData struct { +type rawUpdateConversationData struct { ID string `json:"id"` Name string `json:"name"` Users []Models.ConversationDetailUser `json:"users"` UserConversations []Models.UserConversation `json:"user_conversations"` } +// UpdateConversation updates the conversation data, such as title, users, etc func UpdateConversation(w http.ResponseWriter, r *http.Request) { var ( - rawConversationData RawCreateConversationData + rawConversationData rawUpdateConversationData messageThread Models.ConversationDetail err error ) @@ -52,5 +53,5 @@ func UpdateConversation(w http.ResponseWriter, r *http.Request) { } } - w.WriteHeader(http.StatusOK) + w.WriteHeader(http.StatusNoContent) } diff --git a/Backend/Api/Routes.go b/Backend/Api/Routes.go index 5892d46..90f0ed8 100644 --- a/Backend/Api/Routes.go +++ b/Backend/Api/Routes.go @@ -77,6 +77,7 @@ func InitAPIEndpoints(router *mux.Router) { authAPI.HandleFunc("/conversation_details", Messages.EncryptedConversationDetailsList).Methods("GET") authAPI.HandleFunc("/conversations", Messages.CreateConversation).Methods("POST") authAPI.HandleFunc("/conversations", Messages.UpdateConversation).Methods("PUT") + authAPI.HandleFunc("/conversations/{detailID}/image", Messages.AddConversationImage).Methods("POST") authAPI.HandleFunc("/message", Messages.CreateMessage).Methods("POST") authAPI.HandleFunc("/messages/{associationKey}", Messages.Messages).Methods("GET") diff --git a/Backend/Database/Attachments.go b/Backend/Database/Attachments.go new file mode 100644 index 0000000..3097a04 --- /dev/null +++ b/Backend/Database/Attachments.go @@ -0,0 +1,42 @@ +package Database + +import ( + "git.tovijaeschke.xyz/tovi/Envelope/Backend/Models" + + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +// GetAttachmentByID gets the attachment record by the id +func GetAttachmentByID(id string) (Models.MessageData, error) { + var ( + messageData Models.MessageData + err error + ) + + err = DB.Preload(clause.Associations). + First(&messageData, "id = ?", id). + Error + + return messageData, err +} + +// CreateAttachment creates the attachment record +func CreateAttachment(messageData *Models.MessageData) error { + var ( + err error + ) + + err = DB.Session(&gorm.Session{FullSaveAssociations: true}). + Create(messageData). + Error + + return err +} + +// DeleteAttachment deletes the attachment record +func DeleteAttachment(messageData *Models.MessageData) error { + return DB.Session(&gorm.Session{FullSaveAssociations: true}). + Delete(messageData). + Error +} diff --git a/Backend/Database/ConversationDetails.go b/Backend/Database/ConversationDetails.go index 9893022..af04edb 100644 --- a/Backend/Database/ConversationDetails.go +++ b/Backend/Database/ConversationDetails.go @@ -7,7 +7,8 @@ import ( "gorm.io/gorm/clause" ) -func GetConversationDetailById(id string) (Models.ConversationDetail, error) { +// GetConversationDetailByID gets by id +func GetConversationDetailByID(id string) (Models.ConversationDetail, error) { var ( messageThread Models.ConversationDetail err error @@ -21,6 +22,7 @@ func GetConversationDetailById(id string) (Models.ConversationDetail, error) { return messageThread, err } +// GetConversationDetailsByIds gets by multiple ids func GetConversationDetailsByIds(id []string) ([]Models.ConversationDetail, error) { var ( messageThread []Models.ConversationDetail @@ -35,12 +37,14 @@ func GetConversationDetailsByIds(id []string) ([]Models.ConversationDetail, erro return messageThread, err } +// CreateConversationDetail creates a ConversationDetail record func CreateConversationDetail(messageThread *Models.ConversationDetail) error { return DB.Session(&gorm.Session{FullSaveAssociations: true}). Create(messageThread). Error } +// UpdateConversationDetail updates a ConversationDetail record func UpdateConversationDetail(messageThread *Models.ConversationDetail) error { return DB.Session(&gorm.Session{FullSaveAssociations: true}). Where("id = ?", messageThread.ID). @@ -48,6 +52,7 @@ func UpdateConversationDetail(messageThread *Models.ConversationDetail) error { Error } +// DeleteConversationDetail deletes a ConversationDetail record func DeleteConversationDetail(messageThread *Models.ConversationDetail) error { return DB.Session(&gorm.Session{FullSaveAssociations: true}). Delete(messageThread). diff --git a/Backend/Models/Conversations.go b/Backend/Models/Conversations.go index fa88987..1c9e53a 100644 --- a/Backend/Models/Conversations.go +++ b/Backend/Models/Conversations.go @@ -7,9 +7,11 @@ import ( // ConversationDetail stores the name for the conversation type ConversationDetail struct { Base - Name string `gorm:"not null" json:"name"` // Stored encrypted - Users []ConversationDetailUser ` json:"users"` - TwoUser string `gorm:"not null" json:"two_user"` + Name string `gorm:"not null" json:"name"` // Stored encrypted + Users []ConversationDetailUser ` json:"users"` + TwoUser string `gorm:"not null" json:"two_user"` + AttachmentID *uuid.UUID ` json:"attachment_id"` + Attachment Attachment ` json:"attachment"` } // ConversationDetailUser all users associated with a customer diff --git a/mobile/lib/exceptions/update_data_exception.dart b/mobile/lib/exceptions/update_data_exception.dart new file mode 100644 index 0000000..8d1d6bb --- /dev/null +++ b/mobile/lib/exceptions/update_data_exception.dart @@ -0,0 +1,13 @@ + +class UpdateDataException implements Exception { + final String _message; + + UpdateDataException([ + this._message = 'An error occured while updating data.', + ]); + + @override + String toString() { + return _message; + } +} diff --git a/mobile/lib/models/conversations.dart b/mobile/lib/models/conversations.dart index f5c7134..d8222d3 100644 --- a/mobile/lib/models/conversations.dart +++ b/mobile/lib/models/conversations.dart @@ -335,6 +335,18 @@ class Conversation { return returnData; } + Map payloadImageJson() { + if (icon == null) { + return {}; + } + + return { + 'data': AesHelper.aesEncrypt(symmetricKey, Uint8List.fromList(icon!.readAsBytesSync())), + 'mimetype': lookupMimeType(icon!.path), + 'extension': getExtension(icon!.path), + }; + } + Map toMap() { return { 'id': id, diff --git a/mobile/lib/models/image_message.dart b/mobile/lib/models/image_message.dart index e092d36..9d80dbf 100644 --- a/mobile/lib/models/image_message.dart +++ b/mobile/lib/models/image_message.dart @@ -2,9 +2,8 @@ import 'dart:convert'; import 'dart:io'; import 'dart:typed_data'; -import 'package:Envelope/utils/storage/session_cookie.dart'; +import 'package:Envelope/utils/storage/get_file.dart'; import 'package:Envelope/utils/storage/write_file.dart'; -import 'package:http/http.dart' as http; import 'package:mime/mime.dart'; import 'package:pointycastle/pointycastle.dart'; import 'package:uuid/uuid.dart'; @@ -55,25 +54,10 @@ class ImageMessage extends Message { base64.decode(json['message_data']['sender_id']), ); - var resp = await http.get( - Uri.parse(json['message_data']['attachment']['image_link']), - headers: { - 'cookie': await getSessionCookie(), - } - ); - - if (resp.statusCode != 200) { - throw Exception('Could not get attachment file'); - } - - var data = AesHelper.aesDecryptBytes( - base64.decode(symmetricKey), - resp.bodyBytes, - ); - - File file = await writeImage( + File file = await getFile( + json['message_data']['attachment']['image_link'], '${json['id']}', - data, + symmetricKey, ); return ImageMessage( @@ -127,7 +111,7 @@ class ImageMessage extends Message { Uint8List.fromList(base64.encode(symmetricKey).codeUnits), ), 'attachment': { - 'data': AesHelper.aesEncrypt(symmetricKey, Uint8List.fromList(file.readAsBytesSync())), + 'data': AesHelper.aesEncrypt(base64.encode(symmetricKey), Uint8List.fromList(file.readAsBytesSync())), 'mimetype': lookupMimeType(file.path), 'extension': getExtension(file.path), } diff --git a/mobile/lib/utils/storage/conversations.dart b/mobile/lib/utils/storage/conversations.dart index 985b9ea..b5fce2b 100644 --- a/mobile/lib/utils/storage/conversations.dart +++ b/mobile/lib/utils/storage/conversations.dart @@ -1,5 +1,7 @@ import 'dart:convert'; +import 'package:Envelope/exceptions/update_data_exception.dart'; +import 'package:Envelope/utils/storage/get_file.dart'; import 'package:flutter/material.dart'; import 'package:http/http.dart' as http; import 'package:pointycastle/export.dart'; @@ -13,12 +15,17 @@ import '/utils/encryption/aes_helper.dart'; import '/utils/storage/database.dart'; import '/utils/storage/session_cookie.dart'; -Future updateConversation(Conversation conversation, { includeUsers = true } ) async { +Future updateConversation( + Conversation conversation, + { + includeUsers = false, + updatedImage = false, + } ) async { String sessionCookie = await getSessionCookie(); Map conversationJson = await conversation.payloadJson(includeUsers: includeUsers); - var x = await http.put( + var resp = await http.put( await MyProfile.getServerUrl('api/v1/auth/conversations'), headers: { 'Content-Type': 'application/json; charset=UTF-8', @@ -27,8 +34,28 @@ Future updateConversation(Conversation conversation, { includeUsers = true body: jsonEncode(conversationJson), ); - // TODO: Handle errors here - print(x.statusCode); + if (resp.statusCode != 204) { + throw UpdateDataException('Unable to update conversation, please try again later.'); + } + + if (!updatedImage) { + return; + } + + Map attachmentJson = conversation.payloadImageJson(); + + resp = await http.post( + await MyProfile.getServerUrl('api/v1/auth/conversations/${conversation.id}/image'), + headers: { + 'Content-Type': 'application/json; charset=UTF-8', + 'cookie': sessionCookie, + }, + body: jsonEncode(attachmentJson), + ); + + if (resp.statusCode != 204) { + throw UpdateDataException('Unable to update conversation image, please try again later.'); + } } // TODO: Refactor this function @@ -116,6 +143,15 @@ Future updateConversations() async { ); } + // TODO: Handle exception here + if (conversationDetailJson['attachment_id'] != null) { + conversation.icon = await getFile( + conversationDetailJson['attachment']['image_link'], + conversation.id, + conversation.symmetricKey, + ); + } + await db.insert( 'conversations', conversation.toMap(), diff --git a/mobile/lib/utils/storage/get_file.dart b/mobile/lib/utils/storage/get_file.dart new file mode 100644 index 0000000..3047f67 --- /dev/null +++ b/mobile/lib/utils/storage/get_file.dart @@ -0,0 +1,32 @@ +import 'dart:io'; + +import 'package:http/http.dart' as http; + +import '/utils/encryption/aes_helper.dart'; +import '/utils/storage/session_cookie.dart'; +import '/utils/storage/write_file.dart'; + +Future getFile(String link, String imageName, dynamic symmetricKey) async { + var resp = await http.get( + Uri.parse(link), + headers: { + 'cookie': await getSessionCookie(), + } + ); + + if (resp.statusCode != 200) { + throw Exception('Could not get attachment file'); + } + + var data = AesHelper.aesDecryptBytes( + symmetricKey, + resp.bodyBytes, + ); + + File file = await writeImage( + imageName, + data, + ); + + return file; +} diff --git a/mobile/lib/views/main/conversation/settings.dart b/mobile/lib/views/main/conversation/settings.dart index 4fd36c5..2c87896 100644 --- a/mobile/lib/views/main/conversation/settings.dart +++ b/mobile/lib/views/main/conversation/settings.dart @@ -1,6 +1,8 @@ import 'dart:io'; import 'package:Envelope/components/custom_title_bar.dart'; +import 'package:Envelope/components/flash_message.dart'; +import 'package:Envelope/exceptions/update_data_exception.dart'; import 'package:Envelope/models/friends.dart'; import 'package:Envelope/utils/encryption/crypto_utils.dart'; import 'package:Envelope/utils/storage/write_file.dart'; @@ -103,10 +105,14 @@ class _ConversationSettingsState extends State { onPressed: () { Navigator.of(context).push( MaterialPageRoute(builder: (context) => ConversationEditDetails( + // TODO: Move saveCallback to somewhere else saveCallback: (String conversationName, File? file) async { + bool updatedImage = false; + File? writtenFile; if (file != null) { + updatedImage = file.hashCode != widget.conversation.icon.hashCode; writtenFile = await writeImage( widget.conversation.id, file.readAsBytesSync(), @@ -124,7 +130,15 @@ class _ConversationSettingsState extends State { whereArgs: [widget.conversation.id], ); - await updateConversation(widget.conversation, includeUsers: true); + await updateConversation(widget.conversation, updatedImage: updatedImage) + .catchError((error) { + String message = error.toString(); + if (error.runtimeType != UpdateDataException) { + message = 'An error occured, please try again later'; + } + + showMessage(message, context); + }); setState(() {}); Navigator.pop(context); },