Browse Source

Add profile images for user profiles

pull/3/head
Tovi Jaeschke-Rogers 2 years ago
parent
commit
19fbb9c25a
11 changed files with 252 additions and 79 deletions
  1. +50
    -0
      Backend/Api/Auth/AddProfileImage.go
  2. +2
    -2
      Backend/Api/Auth/ChangeMessageExpiry.go
  3. +47
    -56
      Backend/Api/Auth/Login.go
  4. +0
    -1
      Backend/Api/Messages/Conversations.go
  5. +1
    -0
      Backend/Api/Routes.go
  6. +12
    -0
      Backend/Database/Seeder/UserSeeder.go
  7. +14
    -2
      Backend/Models/Users.go
  8. +30
    -3
      mobile/lib/models/my_profile.dart
  9. +11
    -10
      mobile/lib/views/authentication/login.dart
  10. +0
    -2
      mobile/lib/views/main/conversation/message.dart
  11. +85
    -3
      mobile/lib/views/main/profile/profile.dart

+ 50
- 0
Backend/Api/Auth/AddProfileImage.go View File

@ -0,0 +1,50 @@
package Auth
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"
)
// AddProfileImage adds a profile image
func AddProfileImage(w http.ResponseWriter, r *http.Request) {
var (
user Models.User
attachment Models.Attachment
decodedFile []byte
fileName string
err error
)
// Ignore error here, as middleware should handle auth
user, _ = CheckCookieCurrentUser(w, r)
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
user.Attachment = attachment
err = Database.UpdateUser(user.ID.String(), &user)
if err != nil {
http.Error(w, "Error", http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusNoContent)
}

+ 2
- 2
Backend/Api/Auth/ChangeMessageExpiry.go View File

@ -10,7 +10,7 @@ import (
) )
type rawChangeMessageExpiry struct { type rawChangeMessageExpiry struct {
MessageExpiry string `json:"message_exipry"`
MessageExpiry string `json:"message_expiry"`
} }
// ChangeMessageExpiry handles changing default message expiry for user // ChangeMessageExpiry handles changing default message expiry for user
@ -37,7 +37,7 @@ func ChangeMessageExpiry(w http.ResponseWriter, r *http.Request) {
return return
} }
user.AsymmetricPrivateKey = changeMessageExpiry.MessageExpiry
user.MessageExpiryDefault.Scan(changeMessageExpiry.MessageExpiry)
err = Database.UpdateUser( err = Database.UpdateUser(
user.ID.String(), user.ID.String(),


+ 47
- 56
Backend/Api/Auth/Login.go View File

@ -3,6 +3,7 @@ package Auth
import ( import (
"database/sql/driver" "database/sql/driver"
"encoding/json" "encoding/json"
"fmt"
"net/http" "net/http"
"time" "time"
@ -16,73 +17,43 @@ type credentials struct {
} }
type loginResponse struct { type loginResponse struct {
Status string `json:"status"`
Message string `json:"message"`
AsymmetricPublicKey string `json:"asymmetric_public_key"`
AsymmetricPrivateKey string `json:"asymmetric_private_key"`
UserID string `json:"user_id"` UserID string `json:"user_id"`
Username string `json:"username"` Username string `json:"username"`
AsymmetricPublicKey string `json:"asymmetric_public_key"`
AsymmetricPrivateKey string `json:"asymmetric_private_key"`
SymmetricKey string `json:"symmetric_key"`
MessageExpiryDefault string `json:"message_expiry_default"` MessageExpiryDefault string `json:"message_expiry_default"`
ImageLink string `json:"image_link"`
} }
func makeLoginResponse(w http.ResponseWriter, code int, message, pubKey, privKey string, user Models.User) {
// Login logs the user into the system
func Login(w http.ResponseWriter, r *http.Request) {
var ( var (
status = "error"
creds credentials
user Models.User
session Models.Session
expiresAt time.Time
messageExpiryRaw driver.Value messageExpiryRaw driver.Value
messageExpiry string messageExpiry string
imageLink string
returnJSON []byte returnJSON []byte
err error err error
) )
if code >= 200 && code <= 300 {
status = "success"
}
messageExpiryRaw, _ = user.MessageExpiryDefault.Value()
messageExpiry, _ = messageExpiryRaw.(string)
returnJSON, err = json.MarshalIndent(loginResponse{
Status: status,
Message: message,
AsymmetricPublicKey: pubKey,
AsymmetricPrivateKey: privKey,
UserID: user.ID.String(),
Username: user.Username,
MessageExpiryDefault: messageExpiry,
}, "", " ")
if err != nil {
http.Error(w, "Error", http.StatusInternalServerError)
return
}
// Return updated json
w.WriteHeader(code)
w.Write(returnJSON)
}
// Login logs the user into the system
func Login(w http.ResponseWriter, r *http.Request) {
var (
creds credentials
userData Models.User
session Models.Session
expiresAt time.Time
err error
)
err = json.NewDecoder(r.Body).Decode(&creds) err = json.NewDecoder(r.Body).Decode(&creds)
if err != nil { if err != nil {
makeLoginResponse(w, http.StatusInternalServerError, "An error occurred", "", "", userData)
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return return
} }
userData, err = Database.GetUserByUsername(creds.Username)
user, err = Database.GetUserByUsername(creds.Username)
if err != nil { if err != nil {
makeLoginResponse(w, http.StatusUnauthorized, "An error occurred", "", "", userData)
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return return
} }
if !CheckPasswordHash(creds.Password, userData.Password) {
makeLoginResponse(w, http.StatusUnauthorized, "An error occurred", "", "", userData)
if !CheckPasswordHash(creds.Password, user.Password) {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return return
} }
@ -90,13 +61,13 @@ func Login(w http.ResponseWriter, r *http.Request) {
expiresAt = time.Now().Add(12 * time.Hour) expiresAt = time.Now().Add(12 * time.Hour)
session = Models.Session{ session = Models.Session{
UserID: userData.ID,
UserID: user.ID,
Expiry: expiresAt, Expiry: expiresAt,
} }
err = Database.CreateSession(&session) err = Database.CreateSession(&session)
if err != nil { if err != nil {
makeLoginResponse(w, http.StatusUnauthorized, "An error occurred", "", "", userData)
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return return
} }
@ -106,12 +77,32 @@ func Login(w http.ResponseWriter, r *http.Request) {
Expires: expiresAt, Expires: expiresAt,
}) })
makeLoginResponse(
w,
http.StatusOK,
"Successfully logged in",
userData.AsymmetricPublicKey,
userData.AsymmetricPrivateKey,
userData,
)
if user.AttachmentID != nil {
imageLink = fmt.Sprintf(
"http://192.168.1.5:8080/files/%s",
user.Attachment.FilePath,
)
}
messageExpiryRaw, _ = user.MessageExpiryDefault.Value()
messageExpiry, _ = messageExpiryRaw.(string)
returnJSON, err = json.MarshalIndent(loginResponse{
UserID: user.ID.String(),
Username: user.Username,
AsymmetricPublicKey: user.AsymmetricPublicKey,
AsymmetricPrivateKey: user.AsymmetricPrivateKey,
SymmetricKey: user.SymmetricKey,
MessageExpiryDefault: messageExpiry,
ImageLink: imageLink,
}, "", " ")
if err != nil {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
// Return updated json
w.WriteHeader(http.StatusOK)
w.Write(returnJSON)
} }

+ 0
- 1
Backend/Api/Messages/Conversations.go View File

@ -65,7 +65,6 @@ func EncryptedConversationDetailsList(w http.ResponseWriter, r *http.Request) {
return return
} }
// TODO: Fix error handling here
conversationIds = strings.Split(conversationIds[0], ",") conversationIds = strings.Split(conversationIds[0], ",")
conversationDetails, err = Database.GetConversationDetailsByIds( conversationDetails, err = Database.GetConversationDetailsByIds(


+ 1
- 0
Backend/Api/Routes.go View File

@ -64,6 +64,7 @@ func InitAPIEndpoints(router *mux.Router) {
authAPI.HandleFunc("/change_password", Auth.ChangePassword).Methods("POST") authAPI.HandleFunc("/change_password", Auth.ChangePassword).Methods("POST")
authAPI.HandleFunc("/message_expiry", Auth.ChangeMessageExpiry).Methods("POST") authAPI.HandleFunc("/message_expiry", Auth.ChangeMessageExpiry).Methods("POST")
authAPI.HandleFunc("/image", Auth.AddProfileImage).Methods("POST")
authAPI.HandleFunc("/users", Users.SearchUsers).Methods("GET") authAPI.HandleFunc("/users", Users.SearchUsers).Methods("GET")


+ 12
- 0
Backend/Database/Seeder/UserSeeder.go View File

@ -1,6 +1,8 @@
package Seeder package Seeder
import ( import (
"encoding/base64"
"git.tovijaeschke.xyz/tovi/Envelope/Backend/Api/Auth" "git.tovijaeschke.xyz/tovi/Envelope/Backend/Api/Auth"
"git.tovijaeschke.xyz/tovi/Envelope/Backend/Database" "git.tovijaeschke.xyz/tovi/Envelope/Backend/Database"
"git.tovijaeschke.xyz/tovi/Envelope/Backend/Models" "git.tovijaeschke.xyz/tovi/Envelope/Backend/Models"
@ -23,10 +25,16 @@ var userNames = []string{
func createUser(username string) (Models.User, error) { func createUser(username string) (Models.User, error) {
var ( var (
userData Models.User userData Models.User
userKey aesKey
password string password string
err error err error
) )
userKey, err = generateAesKey()
if err != nil {
panic(err)
}
password, err = Auth.HashPassword("password") password, err = Auth.HashPassword("password")
if err != nil { if err != nil {
return Models.User{}, err return Models.User{}, err
@ -37,12 +45,16 @@ func createUser(username string) (Models.User, error) {
Password: password, Password: password,
AsymmetricPrivateKey: encryptedPrivateKey, AsymmetricPrivateKey: encryptedPrivateKey,
AsymmetricPublicKey: publicKey, AsymmetricPublicKey: publicKey,
SymmetricKey: base64.StdEncoding.EncodeToString(
encryptWithPublicKey(userKey.Key, decodedPublicKey),
),
} }
err = Database.CreateUser(&userData) err = Database.CreateUser(&userData)
return userData, err return userData, err
} }
// SeedUsers used to create dummy users for testing & development
func SeedUsers() { func SeedUsers() {
var ( var (
i int i int


+ 14
- 2
Backend/Models/Users.go View File

@ -3,6 +3,7 @@ package Models
import ( import (
"database/sql/driver" "database/sql/driver"
"github.com/gofrs/uuid"
"gorm.io/gorm" "gorm.io/gorm"
) )
@ -58,6 +59,17 @@ type User struct {
ConfirmPassword string `gorm:"-" json:"confirm_password"` ConfirmPassword string `gorm:"-" json:"confirm_password"`
AsymmetricPrivateKey string `gorm:"not null" json:"asymmetric_private_key"` // Stored encrypted AsymmetricPrivateKey string `gorm:"not null" json:"asymmetric_private_key"` // Stored encrypted
AsymmetricPublicKey string `gorm:"not null" json:"asymmetric_public_key"` AsymmetricPublicKey string `gorm:"not null" json:"asymmetric_public_key"`
MessageExpiryDefault MessageExpiry `gorm:"default:no_expiry" json:"-" sql:"type:ENUM('fifteen_min', 'thirty_min', 'one_hour', 'three_hour', 'six_hour', 'twelve_hour', 'one_day', 'three_day')"` // Stored encrypted
SymmetricKey string `gorm:"not null" json:"symmetric_key"` // Stored encrypted
AttachmentID *uuid.UUID ` json:"attachment_id"`
Attachment Attachment ` json:"attachment"`
MessageExpiryDefault MessageExpiry `gorm:"default:no_expiry" json:"-" sql:"type:ENUM(
'fifteen_min',
'thirty_min',
'one_hour',
'three_hour',
'six_hour',
'twelve_hour',
'one_day',
'three_day'
)"` // Stored encrypted
} }

+ 30
- 3
mobile/lib/models/my_profile.dart View File

@ -1,6 +1,8 @@
import 'dart:convert'; import 'dart:convert';
import 'dart:io';
import 'package:Envelope/components/select_message_ttl.dart';
import 'package:Envelope/utils/storage/get_file.dart';
import 'package:flutter/material.dart';
import 'package:flutter_dotenv/flutter_dotenv.dart'; import 'package:flutter_dotenv/flutter_dotenv.dart';
import 'package:pointycastle/impl.dart'; import 'package:pointycastle/impl.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
@ -17,7 +19,9 @@ class MyProfile {
String? friendId; String? friendId;
RSAPrivateKey? privateKey; RSAPrivateKey? privateKey;
RSAPublicKey? publicKey; RSAPublicKey? publicKey;
String? symmetricKey;
DateTime? loggedInAt; DateTime? loggedInAt;
File? image;
String messageExpiryDefault = 'no_expiry'; String messageExpiryDefault = 'no_expiry';
MyProfile({ MyProfile({
@ -26,7 +30,9 @@ class MyProfile {
this.friendId, this.friendId,
this.privateKey, this.privateKey,
this.publicKey, this.publicKey,
this.symmetricKey,
this.loggedInAt, this.loggedInAt,
this.image,
required this.messageExpiryDefault, required this.messageExpiryDefault,
}); });
@ -44,8 +50,10 @@ class MyProfile {
username: json['username'], username: json['username'],
privateKey: privateKey, privateKey: privateKey,
publicKey: publicKey, publicKey: publicKey,
symmetricKey: json['symmetric_key'],
loggedInAt: loggedInAt, loggedInAt: loggedInAt,
messageExpiryDefault: json['message_expiry_default']
messageExpiryDefault: json['message_expiry_default'],
image: json['file'] != null ? File(json['file']) : null,
); );
} }
@ -57,7 +65,7 @@ class MyProfile {
logged_in_at: $loggedInAt logged_in_at: $loggedInAt
public_key: $publicKey public_key: $publicKey
private_key: $privateKey private_key: $privateKey
''';
''';
} }
String toJson() { String toJson() {
@ -70,8 +78,10 @@ class MyProfile {
'asymmetric_public_key': publicKey != null ? 'asymmetric_public_key': publicKey != null ?
CryptoUtils.encodeRSAPublicKeyToPem(publicKey!) : CryptoUtils.encodeRSAPublicKeyToPem(publicKey!) :
null, null,
'symmetric_key': symmetricKey,
'logged_in_at': loggedInAt?.toIso8601String(), 'logged_in_at': loggedInAt?.toIso8601String(),
'message_expiry_default': messageExpiryDefault, 'message_expiry_default': messageExpiryDefault,
'file': image?.path,
}); });
} }
@ -80,7 +90,24 @@ class MyProfile {
password, password,
base64.decode(json['asymmetric_private_key']) base64.decode(json['asymmetric_private_key'])
); );
json['symmetric_key'] = base64.encode(CryptoUtils.rsaDecrypt(
base64.decode(json['symmetric_key']),
CryptoUtils.rsaPrivateKeyFromPem(json['asymmetric_private_key']),
));
if (json['image_link'] != '') {
File profileIcon = await getFile(
json['image_link'],
json['user_id'],
json['symmetric_key'],
);
json['file'] = profileIcon.path;
}
MyProfile profile = MyProfile._fromJson(json); MyProfile profile = MyProfile._fromJson(json);
final preferences = await SharedPreferences.getInstance(); final preferences = await SharedPreferences.getInstance();
preferences.setString('profile', profile.toJson()); preferences.setString('profile', profile.toJson());
return profile; return profile;


+ 11
- 10
mobile/lib/views/authentication/login.dart View File

@ -8,30 +8,30 @@ import '/models/my_profile.dart';
import '/utils/storage/session_cookie.dart'; import '/utils/storage/session_cookie.dart';
class LoginResponse { class LoginResponse {
final String status;
final String message;
final String publicKey;
final String privateKey;
final String userId; final String userId;
final String username; final String username;
final String publicKey;
final String privateKey;
final String symmetricKey;
final String? imageLink;
const LoginResponse({ const LoginResponse({
required this.status,
required this.message,
required this.publicKey, required this.publicKey,
required this.privateKey, required this.privateKey,
required this.symmetricKey,
required this.userId, required this.userId,
required this.username, required this.username,
this.imageLink,
}); });
factory LoginResponse.fromJson(Map<String, dynamic> json) { factory LoginResponse.fromJson(Map<String, dynamic> json) {
return LoginResponse( return LoginResponse(
status: json['status'],
message: json['message'],
publicKey: json['asymmetric_public_key'],
privateKey: json['asymmetric_private_key'],
userId: json['user_id'], userId: json['user_id'],
username: json['username'], username: json['username'],
publicKey: json['asymmetric_public_key'],
privateKey: json['asymmetric_private_key'],
symmetricKey: json['symmetric_key'],
imageLink: json['image_link'],
); );
} }
} }
@ -175,6 +175,7 @@ class _LoginWidgetState extends State<LoginWidget> {
ModalRoute.withName('/home'), ModalRoute.withName('/home'),
); );
}).catchError((error) { }).catchError((error) {
print(error);
showMessage( showMessage(
'Could not login to Envelope, please try again later.', 'Could not login to Envelope, please try again later.',
context, context,


+ 0
- 2
mobile/lib/views/main/conversation/message.dart View File

@ -150,8 +150,6 @@ class _ConversationMessageState extends State<ConversationMessage> {
if (delta == null) { if (delta == null) {
return; return;
} }
print(delta);
}); });
} }


+ 85
- 3
mobile/lib/views/main/profile/profile.dart View File

@ -1,10 +1,18 @@
import 'dart:convert'; import 'dart:convert';
import 'dart:io';
import 'dart:typed_data';
import 'package:Envelope/components/file_picker.dart';
import 'package:Envelope/components/flash_message.dart'; import 'package:Envelope/components/flash_message.dart';
import 'package:Envelope/utils/encryption/aes_helper.dart';
import 'package:Envelope/utils/storage/session_cookie.dart'; import 'package:Envelope/utils/storage/session_cookie.dart';
import 'package:Envelope/utils/storage/write_file.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_dotenv/flutter_dotenv.dart'; import 'package:flutter_dotenv/flutter_dotenv.dart';
import 'package:image_picker/image_picker.dart';
import 'package:mime/mime.dart';
import 'package:qr_flutter/qr_flutter.dart'; import 'package:qr_flutter/qr_flutter.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:sliding_up_panel/sliding_up_panel.dart'; import 'package:sliding_up_panel/sliding_up_panel.dart';
import 'package:http/http.dart' as http; import 'package:http/http.dart' as http;
@ -31,6 +39,8 @@ class Profile extends StatefulWidget {
class _ProfileState extends State<Profile> { class _ProfileState extends State<Profile> {
final PanelController _panelController = PanelController(); final PanelController _panelController = PanelController();
bool showFileSelector = false;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
@ -63,7 +73,8 @@ class _ProfileState extends State<Profile> {
child: Column( child: Column(
children: <Widget>[ children: <Widget>[
usernameHeading(), usernameHeading(),
const SizedBox(height: 30),
fileSelector(),
SizedBox(height: showFileSelector ? 10 : 30),
settings(), settings(),
const SizedBox(height: 30), const SizedBox(height: 30),
logout(), logout(),
@ -77,11 +88,20 @@ class _ProfileState extends State<Profile> {
Widget usernameHeading() { Widget usernameHeading() {
return Row( return Row(
children: <Widget> [ children: <Widget> [
const CustomCircleAvatar(
icon: Icon(Icons.person, size: 40),
CustomCircleAvatar(
image: widget.profile.image,
icon: const Icon(Icons.person, size: 40),
radius: 30, radius: 30,
editImageCallback: () {
setState(() {
showFileSelector = true;
});
},
), ),
const SizedBox(width: 20), const SizedBox(width: 20),
Expanded( Expanded(
flex: 1, flex: 1,
child: Text( child: Text(
@ -92,6 +112,7 @@ class _ProfileState extends State<Profile> {
), ),
), ),
), ),
IconButton( IconButton(
onPressed: () => _panelController.open(), onPressed: () => _panelController.open(),
icon: const Icon(Icons.qr_code_2), icon: const Icon(Icons.qr_code_2),
@ -100,6 +121,59 @@ class _ProfileState extends State<Profile> {
); );
} }
Widget fileSelector() {
if (!showFileSelector) {
return const SizedBox.shrink();
}
return Padding(
padding: const EdgeInsets.only(top: 10),
child: FilePicker(
cameraHandle: _setProfileImage,
galleryHandleSingle: _setProfileImage,
)
);
}
Future<void> _setProfileImage(XFile image) async {
widget.profile.image = await writeImage(
widget.profile.id,
File(image.path).readAsBytesSync(),
);
setState(() {
showFileSelector = false;
});
saveProfile();
Map<String, dynamic> payload = {
'data': AesHelper.aesEncrypt(
widget.profile.symmetricKey!,
Uint8List.fromList(widget.profile.image!.readAsBytesSync())
),
'mimetype': lookupMimeType(widget.profile.image!.path),
'extension': getExtension(widget.profile.image!.path),
};
http.post(
await MyProfile.getServerUrl('api/v1/auth/image'),
headers: {
'cookie': await getSessionCookie(),
},
body: jsonEncode(payload),
).then((http.Response response) {
if (response.statusCode == 204) {
return;
}
showMessage(
'Could not change your default message expiry, please try again later.',
context,
);
});
}
Widget logout() { Widget logout() {
bool isTesting = dotenv.env['ENVIRONMENT'] == 'development'; bool isTesting = dotenv.env['ENVIRONMENT'] == 'development';
@ -190,6 +264,8 @@ class _ProfileState extends State<Profile> {
context, context,
); );
}); });
saveProfile();
}, },
)) ))
); );
@ -241,6 +317,7 @@ class _ProfileState extends State<Profile> {
privateKey: widget.profile.privateKey!, privateKey: widget.profile.privateKey!,
)) ))
); );
saveProfile();
} }
), ),
], ],
@ -281,4 +358,9 @@ class _ProfileState extends State<Profile> {
] ]
); );
} }
Future<void> saveProfile() async {
final preferences = await SharedPreferences.getInstance();
preferences.setString('profile', widget.profile.toJson());
}
} }

Loading…
Cancel
Save