diff --git a/Backend/Api/Auth/ChangeMessageExpiry.go b/Backend/Api/Auth/ChangeMessageExpiry.go new file mode 100644 index 0000000..8f8721f --- /dev/null +++ b/Backend/Api/Auth/ChangeMessageExpiry.go @@ -0,0 +1,52 @@ +package Auth + +import ( + "encoding/json" + "io/ioutil" + "net/http" + + "git.tovijaeschke.xyz/tovi/Envelope/Backend/Database" + "git.tovijaeschke.xyz/tovi/Envelope/Backend/Models" +) + +type rawChangeMessageExpiry struct { + MessageExpiry string `json:"message_exipry"` +} + +// ChangeMessageExpiry handles changing default message expiry for user +func ChangeMessageExpiry(w http.ResponseWriter, r *http.Request) { + var ( + user Models.User + changeMessageExpiry rawChangeMessageExpiry + requestBody []byte + err error + ) + + // Ignore error here, as middleware should handle auth + user, _ = CheckCookieCurrentUser(w, r) + + requestBody, err = ioutil.ReadAll(r.Body) + if err != nil { + http.Error(w, "Error", http.StatusInternalServerError) + return + } + + err = json.Unmarshal(requestBody, &changeMessageExpiry) + if err != nil { + http.Error(w, "Error", http.StatusInternalServerError) + return + } + + user.AsymmetricPrivateKey = changeMessageExpiry.MessageExpiry + + err = Database.UpdateUser( + user.ID.String(), + &user, + ) + if err != nil { + http.Error(w, "Error", http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusOK) +} diff --git a/Backend/Api/Auth/ChangePassword.go b/Backend/Api/Auth/ChangePassword.go new file mode 100644 index 0000000..f4335cc --- /dev/null +++ b/Backend/Api/Auth/ChangePassword.go @@ -0,0 +1,76 @@ +package Auth + +import ( + "encoding/json" + "io/ioutil" + "net/http" + + "git.tovijaeschke.xyz/tovi/Envelope/Backend/Database" + "git.tovijaeschke.xyz/tovi/Envelope/Backend/Models" +) + +type rawChangePassword struct { + OldPassword string `json:"old_password"` + NewPassword string `json:"new_password"` + NewPasswordConfirm string `json:"new_password_confirm"` + PrivateKey string `json:"private_key"` +} + +// ChangePassword handle change password action +func ChangePassword(w http.ResponseWriter, r *http.Request) { + var ( + user Models.User + changePassword rawChangePassword + requestBody []byte + err error + ) + + user, err = CheckCookieCurrentUser(w, r) + if err != nil { + // Don't bother showing an error here, as the middleware handles auth + return + } + + requestBody, err = ioutil.ReadAll(r.Body) + if err != nil { + http.Error(w, "Error", http.StatusInternalServerError) + return + } + + err = json.Unmarshal(requestBody, &changePassword) + if err != nil { + http.Error(w, "Error", http.StatusInternalServerError) + return + } + + if !CheckPasswordHash(changePassword.OldPassword, user.Password) { + http.Error(w, "Invalid Current Password", http.StatusForbidden) + return + } + + // This should never occur, due to frontend validation + if changePassword.NewPassword != changePassword.NewPasswordConfirm { + http.Error(w, "Invalid New Password", http.StatusUnprocessableEntity) + return + } + + user.Password, err = HashPassword(changePassword.NewPassword) + if err != nil { + http.Error(w, "Error", http.StatusInternalServerError) + return + } + + // Private key doesn't change at this point, is just re-encrypted with the new password + user.AsymmetricPrivateKey = changePassword.PrivateKey + + err = Database.UpdateUser( + user.ID.String(), + &user, + ) + if err != nil { + http.Error(w, "Error", http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusOK) +} diff --git a/Backend/Api/Auth/Check.go b/Backend/Api/Auth/Check.go index e503183..a5f49ba 100644 --- a/Backend/Api/Auth/Check.go +++ b/Backend/Api/Auth/Check.go @@ -4,6 +4,7 @@ import ( "net/http" ) +// Check is used to check session viability func Check(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) } diff --git a/Backend/Api/Auth/Login.go b/Backend/Api/Auth/Login.go index 44f26e7..61225af 100644 --- a/Backend/Api/Auth/Login.go +++ b/Backend/Api/Auth/Login.go @@ -1,6 +1,7 @@ package Auth import ( + "database/sql/driver" "encoding/json" "net/http" "time" @@ -9,7 +10,7 @@ import ( "git.tovijaeschke.xyz/tovi/Envelope/Backend/Models" ) -type Credentials struct { +type credentials struct { Username string `json:"username"` Password string `json:"password"` } @@ -21,25 +22,32 @@ type loginResponse struct { AsymmetricPrivateKey string `json:"asymmetric_private_key"` UserID string `json:"user_id"` Username string `json:"username"` + MessageExpiryDefault string `json:"message_expiry_default"` } func makeLoginResponse(w http.ResponseWriter, code int, message, pubKey, privKey string, user Models.User) { var ( - status string = "error" - returnJson []byte - err error + status = "error" + messageExpiryRaw driver.Value + messageExpiry string + returnJSON []byte + err error ) - if code > 200 && code < 300 { + if code >= 200 && code <= 300 { status = "success" } - returnJson, err = json.MarshalIndent(loginResponse{ + 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) @@ -48,12 +56,13 @@ func makeLoginResponse(w http.ResponseWriter, code int, message, pubKey, privKey // Return updated json w.WriteHeader(code) - w.Write(returnJson) + w.Write(returnJSON) } +// Login logs the user into the system func Login(w http.ResponseWriter, r *http.Request) { var ( - creds Credentials + creds credentials userData Models.User session Models.Session expiresAt time.Time diff --git a/Backend/Api/Auth/Signup.go b/Backend/Api/Auth/Signup.go index 57509ab..b60f880 100644 --- a/Backend/Api/Auth/Signup.go +++ b/Backend/Api/Auth/Signup.go @@ -18,15 +18,15 @@ type signupResponse struct { func makeSignupResponse(w http.ResponseWriter, code int, message string) { var ( - status string = "error" - returnJson []byte + status = "error" + returnJSON []byte err error ) if code > 200 && code < 300 { status = "success" } - returnJson, err = json.MarshalIndent(signupResponse{ + returnJSON, err = json.MarshalIndent(signupResponse{ Status: status, Message: message, }, "", " ") @@ -37,10 +37,11 @@ func makeSignupResponse(w http.ResponseWriter, code int, message string) { // Return updated json w.WriteHeader(code) - w.Write(returnJson) + w.Write(returnJSON) } +// Signup to the platform func Signup(w http.ResponseWriter, r *http.Request) { var ( userData Models.User diff --git a/Backend/Api/Routes.go b/Backend/Api/Routes.go index 50f4f01..999a2f2 100644 --- a/Backend/Api/Routes.go +++ b/Backend/Api/Routes.go @@ -61,6 +61,9 @@ func InitAPIEndpoints(router *mux.Router) { authAPI.HandleFunc("/check", Auth.Check).Methods("GET") + authAPI.HandleFunc("/change_password", Auth.ChangePassword).Methods("POST") + authAPI.HandleFunc("/message_expiry", Auth.ChangeMessageExpiry).Methods("POST") + authAPI.HandleFunc("/users", Users.SearchUsers).Methods("GET") authAPI.HandleFunc("/friend_requests", Friends.EncryptedFriendRequestList).Methods("GET") diff --git a/Backend/Database/Init.go b/Backend/Database/Init.go index 4124949..4481002 100644 --- a/Backend/Database/Init.go +++ b/Backend/Database/Init.go @@ -10,13 +10,14 @@ import ( ) const ( - dbUrl = "postgres://postgres:@localhost:5432/envelope" - dbTestUrl = "postgres://postgres:@localhost:5432/envelope_test" + dbURL = "postgres://postgres:@localhost:5432/envelope" + dbTestURL = "postgres://postgres:@localhost:5432/envelope_test" ) +// DB db var DB *gorm.DB -func GetModels() []interface{} { +func getModels() []interface{} { return []interface{}{ &Models.Session{}, &Models.User{}, @@ -29,6 +30,7 @@ func GetModels() []interface{} { } } +// Init initializes the database connection func Init() { var ( model interface{} @@ -37,7 +39,7 @@ func Init() { log.Println("Initializing database...") - DB, err = gorm.Open(postgres.Open(dbUrl), &gorm.Config{}) + DB, err = gorm.Open(postgres.Open(dbURL), &gorm.Config{}) if err != nil { log.Fatalln(err) @@ -45,24 +47,28 @@ func Init() { log.Println("Running AutoMigrate...") - for _, model = range GetModels() { - DB.AutoMigrate(model) + for _, model = range getModels() { + err = DB.AutoMigrate(model) + if err != nil { + log.Fatalln(err) + } } } +// InitTest initializes the test datbase func InitTest() { var ( model interface{} err error ) - DB, err = gorm.Open(postgres.Open(dbTestUrl), &gorm.Config{}) + DB, err = gorm.Open(postgres.Open(dbTestURL), &gorm.Config{}) if err != nil { log.Fatalln(err) } - for _, model = range GetModels() { + for _, model = range getModels() { DB.Migrator().DropTable(model) DB.AutoMigrate(model) } diff --git a/Backend/Database/Seeder/MessageSeeder.go b/Backend/Database/Seeder/MessageSeeder.go index 0480131..1bdffb9 100644 --- a/Backend/Database/Seeder/MessageSeeder.go +++ b/Backend/Database/Seeder/MessageSeeder.go @@ -298,7 +298,7 @@ func SeedMessages() { panic(err) } - for i = 0; i <= 20; i++ { + for i = 0; i <= 100; i++ { err = seedMessage( primaryUser, secondaryUser, diff --git a/Backend/Database/Seeder/Seed.go b/Backend/Database/Seeder/Seed.go index 7e9a373..7bd5c40 100644 --- a/Backend/Database/Seeder/Seed.go +++ b/Backend/Database/Seeder/Seed.go @@ -58,6 +58,7 @@ var ( decodedPrivateKey *rsa.PrivateKey ) +// Seed seeds semi random data for use in testing & development func Seed() { var ( block *pem.Block diff --git a/Backend/Models/Messages.go b/Backend/Models/Messages.go index 663d72d..9e995b5 100644 --- a/Backend/Models/Messages.go +++ b/Backend/Models/Messages.go @@ -1,12 +1,14 @@ package Models import ( + "database/sql" "time" "github.com/gofrs/uuid" ) -// TODO: Add support for images +// MessageData holds the content of the message +// encrypted through the Message.SymmetricKey type MessageData struct { Base Data string `gorm:"not null" json:"data"` // Stored encrypted @@ -14,11 +16,13 @@ type MessageData struct { SymmetricKey string `gorm:"not null" json:"symmetric_key"` // Stored encrypted } +// Message holds data pertaining to each users' message type Message struct { Base - MessageDataID uuid.UUID `json:"message_data_id"` - MessageData MessageData `json:"message_data"` - SymmetricKey string `json:"symmetric_key" gorm:"not null"` // Stored encrypted - AssociationKey string `json:"association_key" gorm:"not null"` // TODO: This links all encrypted messages for a user in a thread together. Find a way to fix this - CreatedAt time.Time `json:"created_at" gorm:"not null"` + MessageDataID uuid.UUID ` json:"message_data_id"` + MessageData MessageData ` json:"message_data"` + SymmetricKey string `gorm:"not null" json:"symmetric_key"` // Stored encrypted + AssociationKey string `gorm:"not null" json:"association_key"` // Stored encrypted + Expiry sql.NullTime ` json:"expiry"` + CreatedAt time.Time `gorm:"not null" json:"created_at"` } diff --git a/Backend/Models/Users.go b/Backend/Models/Users.go index 4727e26..685b774 100644 --- a/Backend/Models/Users.go +++ b/Backend/Models/Users.go @@ -1,10 +1,12 @@ package Models import ( + "database/sql/driver" + "gorm.io/gorm" ) -// Prevent updating the email if it has not changed +// BeforeUpdate prevents updating the email if it has not changed // This stops a unique constraint error func (u *User) BeforeUpdate(tx *gorm.DB) (err error) { if !tx.Statement.Changed("Username") { @@ -13,11 +15,49 @@ func (u *User) BeforeUpdate(tx *gorm.DB) (err error) { return nil } +// MessageExpiry holds values for how long messages should expire by default +type MessageExpiry []uint8 + +const ( + // MessageExpiryFifteenMin expires after 15 minutes + MessageExpiryFifteenMin = "fifteen_min" + // MessageExpiryThirtyMin expires after 30 minutes + MessageExpiryThirtyMin = "thirty_min" + // MessageExpiryOneHour expires after one hour + MessageExpiryOneHour = "one_hour" + // MessageExpiryThreeHour expires after three hours + MessageExpiryThreeHour = "three_hour" + // MessageExpirySixHour expires after six hours + MessageExpirySixHour = "six_hour" + // MessageExpiryTwelveHour expires after twelve hours + MessageExpiryTwelveHour = "twelve_hour" + // MessageExpiryOneDay expires after one day + MessageExpiryOneDay = "one_day" + // MessageExpiryThreeDay expires after three days + MessageExpiryThreeDay = "three_day" + // MessageExpiryNoExpiry never expires + MessageExpiryNoExpiry = "no_expiry" +) + +// Scan new value into MessageExpiry +func (e *MessageExpiry) Scan(value interface{}) error { + *e = MessageExpiry(value.(string)) + return nil +} + +// Value gets value out of MessageExpiry column +func (e MessageExpiry) Value() (driver.Value, error) { + return string(e), nil +} + +// User holds user data type User struct { Base - Username string `gorm:"not null;unique" json:"username"` - Password string `gorm:"not null" json:"password"` - ConfirmPassword string `gorm:"-" json:"confirm_password"` - AsymmetricPrivateKey string `gorm:"not null" json:"asymmetric_private_key"` // Stored encrypted - AsymmetricPublicKey string `gorm:"not null" json:"asymmetric_public_key"` + Username string `gorm:"not null;unique" json:"username"` + Password string `gorm:"not null" json:"password"` + ConfirmPassword string `gorm:"-" json:"confirm_password"` + AsymmetricPrivateKey string `gorm:"not null" json:"asymmetric_private_key"` // Stored encrypted + 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 + } diff --git a/mobile/lib/components/custom_title_bar.dart b/mobile/lib/components/custom_title_bar.dart index 527b1d2..45cd96b 100644 --- a/mobile/lib/components/custom_title_bar.dart +++ b/mobile/lib/components/custom_title_bar.dart @@ -8,12 +8,14 @@ class CustomTitleBar extends StatelessWidget with PreferredSizeWidget { required this.showBack, this.rightHandButton, this.backgroundColor, + this.beforeBack, }) : super(key: key); final Text title; final bool showBack; final IconButton? rightHandButton; final Color? backgroundColor; + final Future Function()? beforeBack; @override Size get preferredSize => const Size.fromHeight(kToolbarHeight); @@ -59,7 +61,11 @@ class CustomTitleBar extends StatelessWidget with PreferredSizeWidget { Widget _backButton(BuildContext context) { return IconButton( - onPressed: (){ + onPressed: () { + if (beforeBack != null) { + beforeBack!().then((dynamic) => Navigator.pop(context)); + return; + } Navigator.pop(context); }, icon: Icon( diff --git a/mobile/lib/components/qr_reader.dart b/mobile/lib/components/qr_reader.dart index 1ff79ed..25ea239 100644 --- a/mobile/lib/components/qr_reader.dart +++ b/mobile/lib/components/qr_reader.dart @@ -1,9 +1,7 @@ import 'dart:convert'; import 'dart:io'; -import 'package:Envelope/utils/storage/session_cookie.dart'; import 'package:flutter/material.dart'; -import 'package:flutter_dotenv/flutter_dotenv.dart'; import 'package:pointycastle/impl.dart'; import 'package:qr_code_scanner/qr_code_scanner.dart'; import 'package:sqflite/sqflite.dart'; @@ -16,6 +14,7 @@ import '/utils/encryption/aes_helper.dart'; import '/utils/encryption/crypto_utils.dart'; import '/utils/storage/database.dart'; import '/utils/strings.dart'; +import '/utils/storage/session_cookie.dart'; import 'flash_message.dart'; class QrReader extends StatefulWidget { @@ -128,7 +127,7 @@ class _QrReaderState extends State { ]); var resp = await http.post( - Uri.parse('${dotenv.env["SERVER_URL"]}api/v1/auth/friend_request/qr_code'), + await MyProfile.getServerUrl('api/v1/auth/friend_request/qr_code'), headers: { 'Content-Type': 'application/json; charset=UTF-8', 'cookie': await getSessionCookie(), diff --git a/mobile/lib/components/select_message_ttl.dart b/mobile/lib/components/select_message_ttl.dart new file mode 100644 index 0000000..c2be882 --- /dev/null +++ b/mobile/lib/components/select_message_ttl.dart @@ -0,0 +1,108 @@ +import 'package:flutter/material.dart'; + +import '/components/custom_title_bar.dart'; + +const Map messageExpiryValues = { + 'no_expiry': 'No Expiry', + 'fifteen_min': '15 Minutes', + 'thirty_min': '30 Minutes', + 'one_hour': '1 Hour', + 'three_hour': '3 Hours', + 'six_hour': '6 Hours', + 'twelve_day': '12 Hours', + 'one_day': '1 Day', + 'three_day': '3 Days', +}; + +class SelectMessageTTL extends StatefulWidget { + const SelectMessageTTL({ + Key? key, + required this.widgetTitle, + required this.backCallback, + this.currentSelected, + }) : super(key: key); + + final String widgetTitle; + final Future Function(String messageExpiry) backCallback; + final String? currentSelected; + + @override + _SelectMessageTTLState createState() => _SelectMessageTTLState(); +} + +class _SelectMessageTTLState extends State { + String selectedExpiry = 'no_expiry'; + + @override + void initState() { + selectedExpiry = widget.currentSelected ?? 'no_expiry'; + super.initState(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: CustomTitleBar( + title: Text( + widget.widgetTitle, + style: const TextStyle( + fontSize: 28, + fontWeight: FontWeight.bold + ) + ), + showBack: true, + backgroundColor: Colors.transparent, + beforeBack: () async { + widget.backCallback(selectedExpiry); + }, + ), + body: Padding( + padding: const EdgeInsets.only(top: 30), + child: list(), + ), + ); + } + + Widget list() { + return ListView.builder( + itemCount: messageExpiryValues.length, + shrinkWrap: true, + itemBuilder: (context, i) { + String key = messageExpiryValues.keys.elementAt(i); + + return GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () { + setState(() { + selectedExpiry = key; + }); + }, + + child: Padding( + padding: const EdgeInsets.only(left: 30, right: 20, top: 8, bottom: 8), + child: Row( + children: [ + selectedExpiry == key ? + const Icon(Icons.check) : + const SizedBox(width: 20), + const SizedBox(width: 16), + Expanded( + child: Align( + alignment: Alignment.centerLeft, + child: Text( + messageExpiryValues[key] ?? '', + style: const TextStyle( + fontSize: 20, + fontWeight: FontWeight.normal, + ), + ), + ) + ) + ], + ) + ) + ); + }, + ); + } +} diff --git a/mobile/lib/components/user_search_result.dart b/mobile/lib/components/user_search_result.dart index 4b0155d..c8c7b95 100644 --- a/mobile/lib/components/user_search_result.dart +++ b/mobile/lib/components/user_search_result.dart @@ -119,7 +119,7 @@ class _UserSearchResultState extends State{ }); var resp = await http.post( - Uri.parse('${dotenv.env["SERVER_URL"]}api/v1/auth/friend_request'), + await MyProfile.getServerUrl('api/v1/auth/friend_request'), headers: { 'cookie': await getSessionCookie(), }, diff --git a/mobile/lib/main.dart b/mobile/lib/main.dart index 656c188..ce2ea93 100644 --- a/mobile/lib/main.dart +++ b/mobile/lib/main.dart @@ -49,6 +49,8 @@ class MyApp extends StatelessWidget { brightness: Brightness.dark, primaryColor: Colors.orange.shade900, backgroundColor: Colors.grey.shade800, + scaffoldBackgroundColor: Colors.grey[850], + disabledColor: Colors.grey[400], colorScheme: ColorScheme( brightness: Brightness.dark, primary: Colors.orange.shade900, diff --git a/mobile/lib/models/my_profile.dart b/mobile/lib/models/my_profile.dart index 0c0207f..526e668 100644 --- a/mobile/lib/models/my_profile.dart +++ b/mobile/lib/models/my_profile.dart @@ -1,9 +1,16 @@ import 'dart:convert'; -import 'package:Envelope/utils/encryption/aes_helper.dart'; -import 'package:Envelope/utils/encryption/crypto_utils.dart'; + +import 'package:Envelope/components/select_message_ttl.dart'; +import 'package:flutter_dotenv/flutter_dotenv.dart'; import 'package:pointycastle/impl.dart'; import 'package:shared_preferences/shared_preferences.dart'; +import '/utils/encryption/aes_helper.dart'; +import '/utils/encryption/crypto_utils.dart'; + +// TODO: Replace this with the prod url when server is deployed +String defaultServerUrl = dotenv.env['SERVER_URL'] ?? 'http://192.168.1.5:8080'; + class MyProfile { String id; String username; @@ -11,6 +18,7 @@ class MyProfile { RSAPrivateKey? privateKey; RSAPublicKey? publicKey; DateTime? loggedInAt; + String messageExpiryDefault = 'no_expiry'; MyProfile({ required this.id, @@ -19,6 +27,7 @@ class MyProfile { this.privateKey, this.publicKey, this.loggedInAt, + required this.messageExpiryDefault, }); factory MyProfile._fromJson(Map json) { @@ -36,6 +45,7 @@ class MyProfile { privateKey: privateKey, publicKey: publicKey, loggedInAt: loggedInAt, + messageExpiryDefault: json['message_expiry_default'] ); } @@ -61,6 +71,7 @@ class MyProfile { CryptoUtils.encodeRSAPublicKeyToPem(publicKey!) : null, 'logged_in_at': loggedInAt?.toIso8601String(), + 'message_expiry_default': messageExpiryDefault, }); } @@ -107,5 +118,22 @@ class MyProfile { } return profile.privateKey!; } + + static setServerUrl(String url) async { + final preferences = await SharedPreferences.getInstance(); + preferences.setString('server_url', url); + } + + static Future getServerUrl(String path) async { + final preferences = await SharedPreferences.getInstance(); + + String? baseUrl = preferences.getString('server_url'); + if (baseUrl == null) { + setServerUrl(defaultServerUrl); + return Uri.parse('$defaultServerUrl$path'); + } + + return Uri.parse('$baseUrl$path'); + } } diff --git a/mobile/lib/utils/storage/conversations.dart b/mobile/lib/utils/storage/conversations.dart index fc65477..985b9ea 100644 --- a/mobile/lib/utils/storage/conversations.dart +++ b/mobile/lib/utils/storage/conversations.dart @@ -1,12 +1,11 @@ import 'dart:convert'; -import 'package:Envelope/components/flash_message.dart'; import 'package:flutter/material.dart'; -import 'package:flutter_dotenv/flutter_dotenv.dart'; import 'package:http/http.dart' as http; import 'package:pointycastle/export.dart'; import 'package:sqflite/sqflite.dart'; +import '/components/flash_message.dart'; import '/models/conversation_users.dart'; import '/models/conversations.dart'; import '/models/my_profile.dart'; @@ -20,7 +19,7 @@ Future updateConversation(Conversation conversation, { includeUsers = true Map conversationJson = await conversation.payloadJson(includeUsers: includeUsers); var x = await http.put( - Uri.parse('${dotenv.env["SERVER_URL"]}api/v1/auth/conversations'), + await MyProfile.getServerUrl('api/v1/auth/conversations'), headers: { 'Content-Type': 'application/json; charset=UTF-8', 'cookie': sessionCookie, @@ -38,7 +37,7 @@ Future updateConversations() async { // try { var resp = await http.get( - Uri.parse('${dotenv.env["SERVER_URL"]}api/v1/auth/conversations'), + await MyProfile.getServerUrl('api/v1/auth/conversations'), headers: { 'cookie': await getSessionCookie(), } @@ -68,7 +67,7 @@ Future updateConversations() async { Map params = {}; params['conversation_detail_ids'] = conversationsDetailIds.join(','); - var uri = Uri.parse('${dotenv.env["SERVER_URL"]}api/v1/auth/conversation_details'); + var uri = await MyProfile.getServerUrl('api/v1/auth/conversation_details'); uri = uri.replace(queryParameters: params); resp = await http.get( @@ -150,7 +149,7 @@ Future uploadConversation(Conversation conversation, BuildContext context) Map conversationJson = await conversation.payloadJson(); var resp = await http.post( - Uri.parse('${dotenv.env["SERVER_URL"]}api/v1/auth/conversations'), + await MyProfile.getServerUrl('api/v1/auth/conversations'), headers: { 'Content-Type': 'application/json; charset=UTF-8', 'cookie': sessionCookie, diff --git a/mobile/lib/utils/storage/friends.dart b/mobile/lib/utils/storage/friends.dart index 9ed41eb..52fe63e 100644 --- a/mobile/lib/utils/storage/friends.dart +++ b/mobile/lib/utils/storage/friends.dart @@ -1,6 +1,5 @@ import 'dart:convert'; -import 'package:flutter_dotenv/flutter_dotenv.dart'; import 'package:http/http.dart' as http; import 'package:pointycastle/export.dart'; import 'package:sqflite/sqflite.dart'; @@ -15,7 +14,7 @@ Future updateFriends() async { // try { var resp = await http.get( - Uri.parse('${dotenv.env["SERVER_URL"]}api/v1/auth/friend_requests'), + await MyProfile.getServerUrl('api/v1/auth/friend_requests'), headers: { 'cookie': await getSessionCookie(), } diff --git a/mobile/lib/utils/storage/messages.dart b/mobile/lib/utils/storage/messages.dart index b551672..da715e0 100644 --- a/mobile/lib/utils/storage/messages.dart +++ b/mobile/lib/utils/storage/messages.dart @@ -1,6 +1,5 @@ import 'dart:convert'; -import 'package:flutter_dotenv/flutter_dotenv.dart'; import 'package:http/http.dart' as http; import 'package:sqflite/sqflite.dart'; import 'package:uuid/uuid.dart'; @@ -43,9 +42,9 @@ Future sendMessage(Conversation conversation, String data) async { String sessionCookie = await getSessionCookie(); message.payloadJson(conversation, messageId) - .then((messageJson) { + .then((messageJson) async { return http.post( - Uri.parse('${dotenv.env["SERVER_URL"]}api/v1/auth/message'), + await MyProfile.getServerUrl('api/v1/auth/message'), headers: { 'Content-Type': 'application/json; charset=UTF-8', 'cookie': sessionCookie, @@ -75,7 +74,7 @@ Future updateMessageThread(Conversation conversation, {MyProfile? profile} ConversationUser currentUser = await getConversationUser(conversation, profile.id); var resp = await http.get( - Uri.parse('${dotenv.env["SERVER_URL"]}api/v1/auth/messages/${currentUser.associationKey}'), + await MyProfile.getServerUrl('api/v1/auth/messages/${currentUser.associationKey}'), headers: { 'cookie': await getSessionCookie(), } diff --git a/mobile/lib/views/authentication/login.dart b/mobile/lib/views/authentication/login.dart index b608703..dd8e869 100644 --- a/mobile/lib/views/authentication/login.dart +++ b/mobile/lib/views/authentication/login.dart @@ -1,7 +1,9 @@ import 'dart:convert'; + import 'package:flutter/material.dart'; import 'package:http/http.dart' as http; -import 'package:flutter_dotenv/flutter_dotenv.dart'; + +import '/components/flash_message.dart'; import '/models/my_profile.dart'; import '/utils/storage/session_cookie.dart'; @@ -34,51 +36,26 @@ class LoginResponse { } } -Future login(context, String username, String password) async { - final resp = await http.post( - Uri.parse('${dotenv.env["SERVER_URL"]}api/v1/login'), - headers: { - 'Content-Type': 'application/json; charset=UTF-8', - }, - body: jsonEncode({ - 'username': username, - 'password': password, - }), - ); - - if (resp.statusCode != 200) { - throw Exception(resp.body); - } - - String? rawCookie = resp.headers['set-cookie']; - if (rawCookie != null) { - int index = rawCookie.indexOf(';'); - setSessionCookie((index == -1) ? rawCookie : rawCookie.substring(0, index)); - } - - return await MyProfile.login(json.decode(resp.body), password); -} - class Login extends StatelessWidget { const Login({Key? key}) : super(key: key); @override Widget build(BuildContext context) { return Scaffold( - appBar: AppBar( - title: null, - automaticallyImplyLeading: true, - leading: IconButton(icon: const Icon(Icons.arrow_back), - onPressed:() => { - Navigator.pop(context) - } - ), - backgroundColor: Colors.transparent, - shadowColor: Colors.transparent, - ), - body: const SafeArea( - child: LoginWidget(), + appBar: AppBar( + title: null, + automaticallyImplyLeading: true, + leading: IconButton(icon: const Icon(Icons.arrow_back), + onPressed:() => { + Navigator.pop(context) + } ), + backgroundColor: Colors.transparent, + shadowColor: Colors.transparent, + ), + body: const SafeArea( + child: LoginWidget(), + ), ); } } @@ -93,125 +70,153 @@ class LoginWidget extends StatefulWidget { class _LoginWidgetState extends State { final _formKey = GlobalKey(); - TextEditingController usernameController = TextEditingController(); - TextEditingController passwordController = TextEditingController(); + final TextEditingController _usernameController = TextEditingController(); + final TextEditingController _passwordController = TextEditingController(); @override Widget build(BuildContext context) { const TextStyle inputTextStyle = TextStyle( - fontSize: 18, + fontSize: 18, ); final OutlineInputBorder inputBorderStyle = OutlineInputBorder( - borderRadius: BorderRadius.circular(5), - borderSide: const BorderSide( - color: Colors.transparent, - ) + borderRadius: BorderRadius.circular(5), + borderSide: const BorderSide( + color: Colors.transparent, + ) ); final ButtonStyle buttonStyle = ElevatedButton.styleFrom( - primary: Theme.of(context).colorScheme.surface, - onPrimary: Theme.of(context).colorScheme.onSurface, - minimumSize: const Size.fromHeight(50), - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 10), - textStyle: TextStyle( - fontSize: 20, - fontWeight: FontWeight.bold, - color: Theme.of(context).colorScheme.error, - ), + primary: Theme.of(context).colorScheme.surface, + onPrimary: Theme.of(context).colorScheme.onSurface, + minimumSize: const Size.fromHeight(50), + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 10), + textStyle: TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + color: Theme.of(context).colorScheme.error, + ), ); return Center( - child: Form( - key: _formKey, - child: Center( - child: Padding( - padding: const EdgeInsets.only( - left: 20, - right: 20, - top: 0, - bottom: 80, - ), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Text( - 'Login', - style: TextStyle( - fontSize: 35, - color: Theme.of(context).colorScheme.onBackground, - ), - ), - const SizedBox(height: 30), - TextFormField( - controller: usernameController, - decoration: InputDecoration( - hintText: 'Username', - enabledBorder: inputBorderStyle, - focusedBorder: inputBorderStyle, - ), - style: inputTextStyle, - // The validator receives the text that the user has entered. - validator: (value) { - if (value == null || value.isEmpty) { - return 'Enter a username'; - } - return null; - }, - ), - const SizedBox(height: 10), - TextFormField( - controller: passwordController, - obscureText: true, - enableSuggestions: false, - autocorrect: false, - decoration: InputDecoration( - hintText: 'Password', - enabledBorder: inputBorderStyle, - focusedBorder: inputBorderStyle, - ), - style: inputTextStyle, - // The validator receives the text that the user has entered. - validator: (value) { - if (value == null || value.isEmpty) { - return 'Enter a password'; - } - return null; - }, - ), - const SizedBox(height: 15), - ElevatedButton( - style: buttonStyle, - onPressed: () { - if (_formKey.currentState!.validate()) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Processing Data')), - ); - - login( - context, - usernameController.text, - passwordController.text, - ).then((val) { - Navigator. - pushNamedAndRemoveUntil( - context, - '/home', - ModalRoute.withName('/home'), - ); - }).catchError((error) { - print(error); // TODO: Show error on interface - }); - } - }, - child: const Text('Submit'), - ), - ], - ) - ) + child: Form( + key: _formKey, + child: Center( + child: Padding( + padding: const EdgeInsets.only( + left: 20, + right: 20, + top: 0, + bottom: 80, + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Text( + 'Login', + style: TextStyle( + fontSize: 35, + color: Theme.of(context).colorScheme.onBackground, + ), + ), + const SizedBox(height: 30), + TextFormField( + controller: _usernameController, + decoration: InputDecoration( + hintText: 'Username', + enabledBorder: inputBorderStyle, + focusedBorder: inputBorderStyle, + ), + style: inputTextStyle, + // The validator receives the text that the user has entered. + validator: (value) { + if (value == null || value.isEmpty) { + return 'Enter a username'; + } + return null; + }, + ), + const SizedBox(height: 10), + TextFormField( + controller: _passwordController, + obscureText: true, + enableSuggestions: false, + autocorrect: false, + decoration: InputDecoration( + hintText: 'Password', + enabledBorder: inputBorderStyle, + focusedBorder: inputBorderStyle, + ), + style: inputTextStyle, + // The validator receives the text that the user has entered. + validator: (value) { + if (value == null || value.isEmpty) { + return 'Enter a password'; + } + return null; + }, + ), + const SizedBox(height: 15), + ElevatedButton( + style: buttonStyle, + onPressed: () { + if (_formKey.currentState!.validate()) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Processing Data')), + ); + + login() + .then((val) { + Navigator. + pushNamedAndRemoveUntil( + context, + '/home', + ModalRoute.withName('/home'), + ); + }).catchError((error) { + showMessage( + 'Could not login to Envelope, please try again later.', + context, + ); + }); + } + }, + child: const Text('Submit'), + ), + ], + ) ) + ) ) ); } + + Future login() async { + final resp = await http.post( + await MyProfile.getServerUrl('api/v1/login'), + headers: { + 'Content-Type': 'application/json; charset=UTF-8', + }, + body: jsonEncode({ + 'username': _usernameController.text, + 'password': _passwordController.text, + }), + ); + + if (resp.statusCode != 200) { + throw Exception(resp.body); + } + + String? rawCookie = resp.headers['set-cookie']; + if (rawCookie != null) { + int index = rawCookie.indexOf(';'); + setSessionCookie((index == -1) ? rawCookie : rawCookie.substring(0, index)); + } + + return await MyProfile.login( + json.decode(resp.body), + _passwordController.text, + ); + } } diff --git a/mobile/lib/views/authentication/signup.dart b/mobile/lib/views/authentication/signup.dart index c9d3447..2a190e0 100644 --- a/mobile/lib/views/authentication/signup.dart +++ b/mobile/lib/views/authentication/signup.dart @@ -1,44 +1,14 @@ import 'dart:convert'; import 'dart:typed_data'; +import 'package:Envelope/components/flash_message.dart'; +import 'package:Envelope/models/my_profile.dart'; import 'package:flutter/material.dart'; -import 'package:flutter_dotenv/flutter_dotenv.dart'; import 'package:http/http.dart' as http; import '/utils/encryption/aes_helper.dart'; import '/utils/encryption/crypto_utils.dart'; -Future signUp(context, String username, String password, String confirmPassword) async { - var keyPair = CryptoUtils.generateRSAKeyPair(); - - var rsaPubPem = CryptoUtils.encodeRSAPublicKeyToPem(keyPair.publicKey); - var rsaPrivPem = CryptoUtils.encodeRSAPrivateKeyToPem(keyPair.privateKey); - - String encRsaPriv = AesHelper.aesEncrypt(password, Uint8List.fromList(rsaPrivPem.codeUnits)); - - // TODO: Check for timeout here - final resp = await http.post( - Uri.parse('${dotenv.env["SERVER_URL"]}api/v1/signup'), - headers: { - 'Content-Type': 'application/json; charset=UTF-8', - }, - body: jsonEncode({ - 'username': username, - 'password': password, - 'confirm_password': confirmPassword, - 'asymmetric_public_key': rsaPubPem, - 'asymmetric_private_key': encRsaPriv, - }), - ); - - SignupResponse response = SignupResponse.fromJson(jsonDecode(resp.body)); - - if (resp.statusCode != 201) { - throw Exception(response.message); - } - - return response; -} class Signup extends StatelessWidget { const Signup({Key? key}) : super(key: key); @@ -47,17 +17,17 @@ class Signup extends StatelessWidget { Widget build(BuildContext context) { return Scaffold( appBar: AppBar( - title: null, - automaticallyImplyLeading: true, - //`true` if you want Flutter to automatically add Back Button when needed, - //or `false` if you want to force your own back button every where - leading: IconButton(icon: const Icon(Icons.arrow_back), - onPressed:() => { - Navigator.pop(context) - } - ), - backgroundColor: Colors.transparent, - shadowColor: Colors.transparent, + title: null, + automaticallyImplyLeading: true, + //`true` if you want Flutter to automatically add Back Button when needed, + //or `false` if you want to force your own back button every where + leading: IconButton(icon: const Icon(Icons.arrow_back), + onPressed:() => { + Navigator.pop(context) + } + ), + backgroundColor: Colors.transparent, + shadowColor: Colors.transparent, ), body: const SafeArea( child: SignupWidget(), @@ -77,8 +47,8 @@ class SignupResponse { factory SignupResponse.fromJson(Map json) { return SignupResponse( - status: json['status'], - message: json['message'], + status: json['status'], + message: json['message'], ); } } @@ -93,22 +63,26 @@ class SignupWidget extends StatefulWidget { class _SignupWidgetState extends State { final _formKey = GlobalKey(); - TextEditingController usernameController = TextEditingController(); - TextEditingController passwordController = TextEditingController(); - TextEditingController passwordConfirmController = TextEditingController(); + final TextEditingController _usernameController = TextEditingController(); + final TextEditingController _passwordController = TextEditingController(); + final TextEditingController _passwordConfirmController = TextEditingController(); + final TextEditingController _serverUrlController = TextEditingController(); + + bool showUrlInput = false; + + final OutlineInputBorder inputBorderStyle = OutlineInputBorder( + borderRadius: BorderRadius.circular(5), + borderSide: const BorderSide( + color: Colors.transparent, + ) + ); + + final TextStyle inputTextStyle = const TextStyle( + fontSize: 18, + ); @override Widget build(BuildContext context) { - const TextStyle inputTextStyle = TextStyle( - fontSize: 18, - ); - - final OutlineInputBorder inputBorderStyle = OutlineInputBorder( - borderRadius: BorderRadius.circular(5), - borderSide: const BorderSide( - color: Colors.transparent, - ) - ); final ButtonStyle buttonStyle = ElevatedButton.styleFrom( primary: Theme.of(context).colorScheme.surface, @@ -123,115 +97,209 @@ class _SignupWidgetState extends State { ); return Center( + child: SingleChildScrollView( child: Form( - key: _formKey, - child: Center( - child: Padding( - padding: const EdgeInsets.only( - left: 20, - right: 20, - top: 0, - bottom: 100, + key: _formKey, + child: Center( + child: Padding( + padding: const EdgeInsets.only( + left: 20, + right: 20, + top: 0, + bottom: 100, + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Text( + 'Sign Up', + style: TextStyle( + fontSize: 35, + color: Theme.of(context).colorScheme.onBackground, ), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Text( - 'Sign Up', - style: TextStyle( - fontSize: 35, - color: Theme.of(context).colorScheme.onBackground, - ), - ), - const SizedBox(height: 30), - TextFormField( - controller: usernameController, - decoration: InputDecoration( - hintText: 'Username', - enabledBorder: inputBorderStyle, - focusedBorder: inputBorderStyle, - ), - style: inputTextStyle, - // The validator receives the text that the user has entered. - validator: (value) { - if (value == null || value.isEmpty) { - return 'Create a username'; - } - return null; - }, - ), - const SizedBox(height: 10), - TextFormField( - controller: passwordController, - obscureText: true, - enableSuggestions: false, - autocorrect: false, - decoration: InputDecoration( - hintText: 'Password', - enabledBorder: inputBorderStyle, - focusedBorder: inputBorderStyle, - ), - style: inputTextStyle, - // The validator receives the text that the user has entered. - validator: (value) { - if (value == null || value.isEmpty) { - return 'Enter a password'; - } - return null; - }, - ), - const SizedBox(height: 10), - TextFormField( - controller: passwordConfirmController, - obscureText: true, - enableSuggestions: false, - autocorrect: false, - decoration: InputDecoration( - hintText: 'Password', - enabledBorder: inputBorderStyle, - focusedBorder: inputBorderStyle, - ), - style: inputTextStyle, - // The validator receives the text that the user has entered. - validator: (value) { - if (value == null || value.isEmpty) { - return 'Confirm your password'; - } - if (value != passwordController.text) { - return 'Passwords do not match'; - } - return null; - }, - ), - const SizedBox(height: 15), - ElevatedButton( - style: buttonStyle, - onPressed: () { - if (_formKey.currentState!.validate()) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Processing Data')), - ); - - signUp( - context, - usernameController.text, - passwordController.text, - passwordConfirmController.text - ).then((value) { - Navigator.of(context).popUntil((route) => route.isFirst); - }).catchError((error) { - print(error); // TODO: Show error on interface - }); - } - }, - child: const Text('Submit'), - ), - ], - ) + ), + const SizedBox(height: 30), + input( + _usernameController, + 'Username', + false, + (value) { + if (value == null || value.isEmpty) { + return 'Create a username'; + } + return null; + }, + ), + const SizedBox(height: 10), + input( + _passwordController, + 'Password', + true, + (value) { + if (value == null || value.isEmpty) { + return 'Enter a password'; + } + return null; + }, + ), + const SizedBox(height: 10), + input( + _passwordConfirmController, + 'Confirm Password', + true, + (value) { + if (value == null || value.isEmpty) { + return 'Confirm your password'; + } + if (value != _passwordController.text) { + return 'Passwords do not match'; + } + return null; + }, + ), + const SizedBox(height: 15), + serverUrl(), + const SizedBox(height: 15), + ElevatedButton( + style: buttonStyle, + onPressed: () { + if (!_formKey.currentState!.validate()) { + return; + } + + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Processing Data')), + ); + + signUp() + .then((dynamic) { + Navigator.of(context).popUntil((route) => route.isFirst); + }).catchError((error) { + showMessage('Failed to signup to Envelope, please try again later', context); + }); + }, + child: const Text('Submit'), + ), + ], ) + ) ) + ) ) ); } + + Widget input( + TextEditingController textController, + String hintText, + bool password, + String? Function(dynamic) validationFunction, + ) { + return TextFormField( + controller: textController, + obscureText: password, + enableSuggestions: false, + autocorrect: false, + decoration: InputDecoration( + hintText: hintText, + enabledBorder: inputBorderStyle, + focusedBorder: inputBorderStyle, + ), + style: inputTextStyle, + validator: validationFunction, + ); + } + + Widget serverUrl() { + if (!showUrlInput) { + return + Padding( + padding: const EdgeInsets.only(top: 0, bottom: 10), + child: Row( + children: [ + SizedBox( + height: 10, + child: IconButton( + onPressed: () { + setState(() { + showUrlInput = true; + }); + }, + icon: Icon( + Icons.edit, + color: Theme.of(context).disabledColor, + ), + splashRadius: 2, + padding: const EdgeInsets.all(2), + iconSize: 15, + ), + ), + const SizedBox(width: 2), + Column( + children: [ + const SizedBox(height: 10), + Text( + 'Server URL - $defaultServerUrl', + style: TextStyle( + color: Theme.of(context).disabledColor, + fontSize: 12, + ), + ), + ], + ), + ], + ), + ); + } + + if (_serverUrlController.text == '') { + _serverUrlController.text = defaultServerUrl; + } + + return input( + _serverUrlController, + 'Server URL', + false, + (dynamic) { + return null; + }, + ); + } + + Future signUp() async { + await MyProfile.setServerUrl(_serverUrlController.text); + + var keyPair = CryptoUtils.generateRSAKeyPair(); + + var rsaPubPem = CryptoUtils.encodeRSAPublicKeyToPem(keyPair.publicKey); + var rsaPrivPem = CryptoUtils.encodeRSAPrivateKeyToPem(keyPair.privateKey); + + String encRsaPriv = AesHelper.aesEncrypt( + _passwordController.text, + Uint8List.fromList(rsaPrivPem.codeUnits), + ); + + final resp = await http.post( + await MyProfile.getServerUrl('api/v1/signup'), + headers: { + 'Content-Type': 'application/json; charset=UTF-8', + }, + body: jsonEncode({ + 'username': _usernameController.text, + 'password': _passwordController.text, + 'confirm_password': _passwordConfirmController.text, + 'asymmetric_public_key': rsaPubPem, + 'asymmetric_private_key': encRsaPriv, + }), + ); + + SignupResponse response = SignupResponse.fromJson(jsonDecode(resp.body)); + + if (resp.statusCode != 201) { + throw Exception(response.message); + } + + return response; + } } diff --git a/mobile/lib/views/main/conversation/detail.dart b/mobile/lib/views/main/conversation/detail.dart index d0bbca2..572f855 100644 --- a/mobile/lib/views/main/conversation/detail.dart +++ b/mobile/lib/views/main/conversation/detail.dart @@ -22,7 +22,11 @@ class ConversationDetail extends StatefulWidget{ class _ConversationDetailState extends State { List messages = []; - MyProfile profile = MyProfile(id: '', username: ''); + MyProfile profile = MyProfile( + id: '', + username: '', + messageExpiryDefault: 'no_expiry', + ); TextEditingController msgController = TextEditingController(); diff --git a/mobile/lib/views/main/conversation/list.dart b/mobile/lib/views/main/conversation/list.dart index cabf6f0..62be875 100644 --- a/mobile/lib/views/main/conversation/list.dart +++ b/mobile/lib/views/main/conversation/list.dart @@ -47,7 +47,7 @@ class _ConversationListState extends State { children: [ TextField( decoration: const InputDecoration( - hintText: "Search...", + hintText: 'Search...', prefixIcon: Icon( Icons.search, size: 20 diff --git a/mobile/lib/views/main/conversation/list_item.dart b/mobile/lib/views/main/conversation/list_item.dart index 816b996..a94e900 100644 --- a/mobile/lib/views/main/conversation/list_item.dart +++ b/mobile/lib/views/main/conversation/list_item.dart @@ -6,7 +6,7 @@ import '/models/conversations.dart'; import '/views/main/conversation/detail.dart'; import '/utils/time.dart'; -class ConversationListItem extends StatefulWidget{ +class ConversationListItem extends StatefulWidget { final Conversation conversation; const ConversationListItem({ Key? key, @@ -33,70 +33,71 @@ class _ConversationListItemState extends State { ); })).then(onGoBack) : null; }, + child: Container( - padding: const EdgeInsets.only(left: 16,right: 0,top: 10,bottom: 10), - child: !loaded ? null : Row( + padding: const EdgeInsets.only(left: 16,right: 0,top: 10,bottom: 10), + child: !loaded ? null : Row( + children: [ + Expanded( + child: Row( children: [ + CustomCircleAvatar( + initials: widget.conversation.name[0].toUpperCase(), + imagePath: null, + ), + const SizedBox(width: 16), Expanded( - child: Row( - children: [ - CustomCircleAvatar( - initials: widget.conversation.name[0].toUpperCase(), - imagePath: null, - ), - const SizedBox(width: 16), - Expanded( - child: Align( - alignment: Alignment.centerLeft, - child: Container( - color: Colors.transparent, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - widget.conversation.name, - style: const TextStyle(fontSize: 16) - ), - recentMessage != null ? - const SizedBox(height: 2) : - const SizedBox.shrink() - , - recentMessage != null ? - Text( - recentMessage!.data, - overflow: TextOverflow.ellipsis, - maxLines: 1, - style: TextStyle( - fontSize: 13, - color: Colors.grey.shade600, - fontWeight: conversation.isRead ? FontWeight.normal : FontWeight.bold, - ), - ) : - const SizedBox.shrink(), - ], - ), - ), - ), + child: Align( + alignment: Alignment.centerLeft, + child: Container( + color: Colors.transparent, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + widget.conversation.name, + style: const TextStyle(fontSize: 16) + ), + recentMessage != null ? + const SizedBox(height: 2) : + const SizedBox.shrink() + , + recentMessage != null ? + Text( + recentMessage!.data, + overflow: TextOverflow.ellipsis, + maxLines: 1, + style: TextStyle( + fontSize: 13, + color: Colors.grey.shade600, + fontWeight: conversation.isRead ? FontWeight.normal : FontWeight.bold, + ), + ) : + const SizedBox.shrink(), + ], ), - recentMessage != null ? - Padding( - padding: const EdgeInsets.only(left: 10), - child: Text( - convertToAgo(recentMessage!.createdAt, short: true), - style: TextStyle( - fontSize: 13, - color: Colors.grey.shade600, - ), - ) - ): - const SizedBox.shrink(), - ], + ), ), ), + recentMessage != null ? + Padding( + padding: const EdgeInsets.only(left: 10), + child: Text( + convertToAgo(recentMessage!.createdAt, short: true), + style: TextStyle( + fontSize: 13, + color: Colors.grey.shade600, + ), + ) + ): + const SizedBox.shrink(), ], ), ), - ); + ], + ), + ), + ); } @override diff --git a/mobile/lib/views/main/friend/add_search.dart b/mobile/lib/views/main/friend/add_search.dart index 08b09d3..3b10e38 100644 --- a/mobile/lib/views/main/friend/add_search.dart +++ b/mobile/lib/views/main/friend/add_search.dart @@ -2,12 +2,11 @@ import 'dart:convert'; import 'package:flutter/material.dart'; import 'package:http/http.dart' as http; -import 'package:flutter_dotenv/flutter_dotenv.dart'; import '/utils/storage/session_cookie.dart'; import '/components/user_search_result.dart'; import '/data_models/user_search.dart'; - +import '/models/my_profile.dart'; class FriendAddSearch extends StatefulWidget { const FriendAddSearch({ @@ -123,7 +122,7 @@ class _FriendAddSearchState extends State { Map params = {}; params['username'] = searchController.text; - var uri = Uri.parse('${dotenv.env["SERVER_URL"]}api/v1/auth/users'); + var uri = await MyProfile.getServerUrl('api/v1/auth/users'); uri = uri.replace(queryParameters: params); var resp = await http.get( diff --git a/mobile/lib/views/main/friend/request_list_item.dart b/mobile/lib/views/main/friend/request_list_item.dart index 21b81b0..0f2c278 100644 --- a/mobile/lib/views/main/friend/request_list_item.dart +++ b/mobile/lib/views/main/friend/request_list_item.dart @@ -1,16 +1,15 @@ import 'dart:convert'; import 'dart:typed_data'; -import 'package:Envelope/components/flash_message.dart'; -import 'package:Envelope/utils/storage/database.dart'; import 'package:flutter/material.dart'; -import 'package:flutter_dotenv/flutter_dotenv.dart'; import 'package:http/http.dart' as http; +import '/components/flash_message.dart'; import '/components/custom_circle_avatar.dart'; import '/models/friends.dart'; -import '/utils/storage/session_cookie.dart'; import '/models/my_profile.dart'; +import '/utils/storage/session_cookie.dart'; +import '/utils/storage/database.dart'; import '/utils/encryption/aes_helper.dart'; import '/utils/encryption/crypto_utils.dart'; import '/utils/strings.dart'; @@ -122,7 +121,7 @@ class _FriendRequestListItemState extends State { }); var resp = await http.post( - Uri.parse('${dotenv.env["SERVER_URL"]}api/v1/auth/friend_request/${widget.friend.id}'), + await MyProfile.getServerUrl('api/v1/auth/friend_request/${widget.friend.id}'), headers: { 'cookie': await getSessionCookie(), }, @@ -153,7 +152,7 @@ class _FriendRequestListItemState extends State { Future rejectFriendRequest() async { var resp = await http.delete( - Uri.parse('${dotenv.env["SERVER_URL"]}api/v1/auth/friend_request/${widget.friend.id}'), + await MyProfile.getServerUrl('api/v1/auth/friend_request/${widget.friend.id}'), headers: { 'cookie': await getSessionCookie(), }, diff --git a/mobile/lib/views/main/home.dart b/mobile/lib/views/main/home.dart index f6bfb92..b590798 100644 --- a/mobile/lib/views/main/home.dart +++ b/mobile/lib/views/main/home.dart @@ -1,5 +1,4 @@ import 'package:flutter/material.dart'; -import 'package:flutter_dotenv/flutter_dotenv.dart'; import 'package:http/http.dart' as http; import '/models/conversations.dart'; @@ -25,8 +24,9 @@ class _HomeState extends State { List friends = []; List friendRequests = []; MyProfile profile = MyProfile( - id: '', - username: '', + id: '', + username: '', + messageExpiryDefault: 'no_expiry', ); bool isLoading = true; @@ -35,10 +35,11 @@ class _HomeState extends State { const ConversationList(conversations: [], friends: []), FriendList(friends: const [], friendRequests: const [], callback: () {}), Profile( - profile: MyProfile( - id: '', - username: '', - ) + profile: MyProfile( + id: '', + username: '', + messageExpiryDefault: 'no_expiry', + ) ), ]; @@ -94,7 +95,7 @@ class _HomeState extends State { int statusCode = 200; try { var resp = await http.get( - Uri.parse('${dotenv.env["SERVER_URL"]}api/v1/auth/check'), + await MyProfile.getServerUrl('api/v1/auth/check'), headers: { 'cookie': await getSessionCookie(), } diff --git a/mobile/lib/views/main/profile/change_password.dart b/mobile/lib/views/main/profile/change_password.dart new file mode 100644 index 0000000..2f77448 --- /dev/null +++ b/mobile/lib/views/main/profile/change_password.dart @@ -0,0 +1,180 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:http/http.dart' as http; +import 'package:flutter/material.dart'; +import 'package:pointycastle/impl.dart'; + +import '/components/flash_message.dart'; +import '/components/custom_title_bar.dart'; +import '/models/my_profile.dart'; +import '/utils/encryption/aes_helper.dart'; +import '/utils/encryption/crypto_utils.dart'; +import '/utils/storage/session_cookie.dart'; + +@immutable +class ChangePassword extends StatelessWidget { + ChangePassword({ + Key? key, + required this.privateKey + }) : super(key: key); + + final RSAPrivateKey privateKey; + + final _formKey = GlobalKey(); + + final TextEditingController _currentPasswordController = TextEditingController(); + final TextEditingController _newPasswordController = TextEditingController(); + final TextEditingController _newPasswordConfirmController = TextEditingController(); + + bool invalidCurrentPassword = false; + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: const CustomTitleBar( + title: Text( + 'Profile', + style: TextStyle( + fontSize: 32, + fontWeight: FontWeight.bold + ) + ), + showBack: true, + backgroundColor: Colors.transparent, + ), + body: Form( + key: _formKey, + child: Padding( + padding: const EdgeInsets.only( + left: 20, + right: 20, + top: 30, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + const Text( + 'Change Password', + style: TextStyle( + fontSize: 25, + ), + ), + const SizedBox(height: 30), + TextFormField( + controller: _currentPasswordController, + decoration: const InputDecoration( + hintText: 'Current Password', + ), + // The validator receives the text that the user has entered. + validator: (value) { + if (value == null || value.isEmpty) { + return 'Enter your current password'; + } + if (invalidCurrentPassword) { + return 'Invalid password'; + } + return null; + }, + ), + const SizedBox(height: 10), + TextFormField( + controller: _newPasswordController, + obscureText: true, + enableSuggestions: false, + autocorrect: false, + decoration: const InputDecoration( + hintText: 'New Password', + ), + // The validator receives the text that the user has entered. + validator: (value) { + if (value == null || value.isEmpty) { + return 'Enter a new password'; + } + return null; + }, + ), + const SizedBox(height: 10), + TextFormField( + controller: _newPasswordConfirmController, + obscureText: true, + enableSuggestions: false, + autocorrect: false, + decoration: const InputDecoration( + hintText: 'Confirm Password', + ), + // The validator receives the text that the user has entered. + validator: (value) { + if (value == null || value.isEmpty) { + return 'Confirm your password'; + } + if (value != _newPasswordController.text) { + return 'Passwords do not match'; + } + return null; + }, + ), + const SizedBox(height: 15), + ElevatedButton( + onPressed: () { + if (!_formKey.currentState!.validate()) { + return; + } + + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Processing Data')), + ); + + _changePassword(context) + .then((dynamic) { + Navigator.of(context).pop(); + }); + }, + child: const Text('Submit'), + ), + ], + ) + ) + ) + ); + } + + Future _changePassword(BuildContext context) async { + String privateKeyPem = CryptoUtils.encodeRSAPrivateKeyToPem(privateKey); + + String privateKeyEncrypted = AesHelper.aesEncrypt( + _newPasswordController.text, + Uint8List.fromList(privateKeyPem.codeUnits), + ); + + String payload = jsonEncode({ + 'old_password': _currentPasswordController.text, + 'new_password': _newPasswordController.text, + 'new_password_confirm': _newPasswordConfirmController.text, + 'private_key': privateKeyEncrypted, + }); + + var resp = await http.post( + await MyProfile.getServerUrl('api/v1/auth/change_password'), + headers: { + 'Content-Type': 'application/json; charset=UTF-8', + 'cookie': await getSessionCookie(), + }, + body: payload, + ); + + if (resp.statusCode == 403) { + invalidCurrentPassword = true; + _formKey.currentState!.validate(); + return; + } + + if (resp.statusCode != 200) { + showMessage( + 'An unexpected error occured, please try again later.', + context, + ); + } + } +} + diff --git a/mobile/lib/views/main/profile/change_server_url.dart b/mobile/lib/views/main/profile/change_server_url.dart new file mode 100644 index 0000000..813397b --- /dev/null +++ b/mobile/lib/views/main/profile/change_server_url.dart @@ -0,0 +1,142 @@ +import 'package:flutter/material.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import '/components/custom_title_bar.dart'; +import '/models/my_profile.dart'; +import '/utils/storage/database.dart'; + +@immutable +class ChangeServerUrl extends StatefulWidget { + const ChangeServerUrl({ + Key? key, + }) : super(key: key); + + @override + State createState() => _ChangeServerUrl(); +} + +class _ChangeServerUrl extends State { + final _formKey = GlobalKey(); + + final TextEditingController _serverUrlController = TextEditingController(); + + bool invalidCurrentPassword = false; + + @override + void initState() { + setUrl(); + super.initState(); + } + + Future setUrl() async { + final preferences = await SharedPreferences.getInstance(); + _serverUrlController.text = preferences.getString('server_url') ?? defaultServerUrl; + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: const CustomTitleBar( + title: Text( + 'Profile', + style: TextStyle( + fontSize: 32, + fontWeight: FontWeight.bold + ) + ), + showBack: true, + backgroundColor: Colors.transparent, + ), + body: SingleChildScrollView( + child: Center( + child: Form( + key: _formKey, + child: Padding( + padding: const EdgeInsets.only( + left: 20, + right: 20, + top: 30, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + const Text( + 'Change Server Url', + style: TextStyle( + fontSize: 25, + ), + ), + const SizedBox(height: 30), + showWarning(), + const SizedBox(height: 30), + TextFormField( + controller: _serverUrlController, + decoration: const InputDecoration( + hintText: 'Server Url', + ), + // The validator receives the text that the user has entered. + validator: (String? value) { + if (value == null || !Uri.parse(value).isAbsolute) { + return 'Invalid URL'; + } + return null; + }, + ), + const SizedBox(height: 15), + ElevatedButton( + onPressed: () { + if (!_formKey.currentState!.validate()) { + return; + } + + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Processing Data')), + ); + + }, + child: const Text('CHANGE SERVER URL'), + ), + ], + ) + ) + ) + ) + ) + ); + } + + Widget showWarning() { + String warning1 = ''' +WARNING: Do not use this feature unless you know what you\'re doing! +'''; + + String warning2 = ''' +Changing the server url will disconnect you from all friends and conversations on this server, and connect you to a fresh environment. This feature is intended to be used by people that are willing to host their own Envelope server, which you can find by going to \nhttps://github.com/SomeUsername/SomeRepo.\n\n +You can revert this by entering \nhttps://envelope-messenger.com\n on the login screen. +'''; + + return Column( + children: [ + Text( + warning1, + textAlign: TextAlign.center, + style: TextStyle( + color: Theme.of(context).colorScheme.error, + ) + ), + Text( + warning2, + textAlign: TextAlign.center, + ), + ], + ); + } + + // TODO: Write user data to new server?? + Future changeUrl() async { + MyProfile.setServerUrl(_serverUrlController.text); + deleteDb(); + MyProfile.logout(); + Navigator.pushNamedAndRemoveUntil(context, '/landing', ModalRoute.withName('/landing')); + } +} diff --git a/mobile/lib/views/main/profile/profile.dart b/mobile/lib/views/main/profile/profile.dart index 5127da0..ae7df99 100644 --- a/mobile/lib/views/main/profile/profile.dart +++ b/mobile/lib/views/main/profile/profile.dart @@ -1,10 +1,21 @@ -import 'package:Envelope/components/custom_title_bar.dart'; +import 'dart:convert'; + +import 'package:Envelope/components/flash_message.dart'; +import 'package:Envelope/utils/storage/session_cookie.dart'; import 'package:flutter/material.dart'; import 'package:flutter_dotenv/flutter_dotenv.dart'; import 'package:qr_flutter/qr_flutter.dart'; -import '/utils/storage/database.dart'; -import '/models/my_profile.dart'; +import 'package:sliding_up_panel/sliding_up_panel.dart'; +import 'package:http/http.dart' as http; + +import '/components/select_message_ttl.dart'; import '/components/custom_circle_avatar.dart'; +import '/components/custom_title_bar.dart'; +import '/models/my_profile.dart'; +import '/utils/encryption/crypto_utils.dart'; +import '/utils/storage/database.dart'; +import '/views/main/profile/change_password.dart'; +import '/views/main/profile/change_server_url.dart'; class Profile extends StatefulWidget { final MyProfile profile; @@ -18,81 +29,81 @@ class Profile extends StatefulWidget { } class _ProfileState extends State { - Widget usernameHeading() { - return Row( - children: [ - const CustomCircleAvatar( - icon: Icon(Icons.person, size: 40), - imagePath: null, // TODO: Add image here - radius: 30, - ), - const SizedBox(width: 20), - Text( - widget.profile.username, - style: const TextStyle( - fontSize: 25, - fontWeight: FontWeight.w500, - ), - ), - // widget.conversation.admin ? IconButton( - // iconSize: 20, - // icon: const Icon(Icons.edit), - // padding: const EdgeInsets.all(5.0), - // splashRadius: 25, - // onPressed: () { - // // TODO: Redirect to edit screen - // }, - // ) : const SizedBox.shrink(), - ], - ); - } + final PanelController _panelController = PanelController(); - Widget _profileQrCode() { - return Container( - child: QrImage( - data: 'This is a simple QR code', - version: QrVersions.auto, - size: 130, - gapless: true, + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: const CustomTitleBar( + title: Text( + 'Profile', + style: TextStyle( + fontSize: 32, + fontWeight: FontWeight.bold + ) ), - width: 130, - height: 130, - color: Theme.of(context).colorScheme.onPrimary, + showBack: false, + backgroundColor: Colors.transparent, + ), + body: SlidingUpPanel( + controller: _panelController, + slideDirection: SlideDirection.DOWN, + defaultPanelState: PanelState.CLOSED, + color: Theme.of(context).scaffoldBackgroundColor, + backdropTapClosesPanel: true, + backdropEnabled: true, + backdropOpacity: 0.2, + minHeight: 0, + maxHeight: 450, + panel: Center( + child: _profileQrCode(), + ), + body: Padding( + padding: const EdgeInsets.only(top: 16,left: 16,right: 16), + child: Column( + children: [ + usernameHeading(), + const SizedBox(height: 30), + settings(), + const SizedBox(height: 30), + logout(), + ], + ) + ), + ), ); } - Widget settings() { - return Align( - alignment: Alignment.centerLeft, - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - const SizedBox(height: 5), - TextButton.icon( - label: const Text( - 'Disappearing Messages', - style: TextStyle(fontSize: 16) - ), - icon: const Icon(Icons.timer), - style: ButtonStyle( - alignment: Alignment.centerLeft, - foregroundColor: MaterialStateProperty.resolveWith( - (Set states) { - return Theme.of(context).colorScheme.onBackground; - }, - ) - ), - onPressed: () { - print('Disappearing Messages'); - } + Widget usernameHeading() { + return Row( + children: [ + const CustomCircleAvatar( + icon: Icon(Icons.person, size: 40), + imagePath: null, // TODO: Add image here + radius: 30, + ), + const SizedBox(width: 20), + Expanded( + flex: 1, + child: Text( + widget.profile.username, + style: const TextStyle( + fontSize: 25, + fontWeight: FontWeight.w500, ), - ], - ), + ), + ), + IconButton( + onPressed: () => _panelController.open(), + icon: const Icon(Icons.qr_code_2), + ), + ], ); } Widget logout() { - bool isTesting = dotenv.env["ENVIRONMENT"] == 'development'; + bool isTesting = dotenv.env['ENVIRONMENT'] == 'development'; + return Align( alignment: Alignment.centerLeft, child: Column( @@ -131,34 +142,144 @@ class _ProfileState extends State { ); } - @override - Widget build(BuildContext context) { - return Scaffold( - appBar: const CustomTitleBar( - title: Text( - 'Profile', - style: TextStyle( - fontSize: 32, - fontWeight: FontWeight.bold - ) - ), - showBack: false, - backgroundColor: Colors.transparent, + Widget settings() { + return Align( + alignment: Alignment.centerLeft, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + + const SizedBox(height: 5), + + TextButton.icon( + label: const Text( + 'Disappearing Messages', + style: TextStyle(fontSize: 16) + ), + icon: const Icon(Icons.timer), + style: ButtonStyle( + alignment: Alignment.centerLeft, + foregroundColor: MaterialStateProperty.resolveWith( + (Set states) { + return Theme.of(context).colorScheme.onBackground; + }, + ) + ), + onPressed: () { + Navigator.of(context).push( + MaterialPageRoute(builder: (context) => SelectMessageTTL( + widgetTitle: 'Message Expiry', + currentSelected: widget.profile.messageExpiryDefault, + backCallback: (String messageExpiry) async { + widget.profile.messageExpiryDefault = messageExpiry; + + http.post( + await MyProfile.getServerUrl('api/v1/auth/message_expiry'), + headers: { + 'cookie': await getSessionCookie(), + }, + body: jsonEncode({ + 'message_expiry': messageExpiry, + }), + ).then((http.Response response) { + if (response.statusCode == 200) { + return; + } + + showMessage( + 'Could not change your default message expiry, please try again later.', + context, + ); + }); + }, + )) + ); + } + ), + + const SizedBox(height: 5), + + TextButton.icon( + label: const Text( + 'Server URL', + style: TextStyle(fontSize: 16) + ), + icon: const Icon(Icons.dataset_linked_outlined), + style: ButtonStyle( + alignment: Alignment.centerLeft, + foregroundColor: MaterialStateProperty.resolveWith( + (Set states) { + return Theme.of(context).colorScheme.onBackground; + }, + ) + ), + onPressed: () { + Navigator.of(context).push( + MaterialPageRoute(builder: (context) => const ChangeServerUrl()) + ); + } + ), + + const SizedBox(height: 5), + + TextButton.icon( + label: const Text( + 'Change Password', + style: TextStyle(fontSize: 16) + ), + icon: const Icon(Icons.password), + style: ButtonStyle( + alignment: Alignment.centerLeft, + foregroundColor: MaterialStateProperty.resolveWith( + (Set states) { + return Theme.of(context).colorScheme.onBackground; + }, + ) + ), + onPressed: () { + Navigator.of(context).push( + MaterialPageRoute(builder: (context) => ChangePassword( + privateKey: widget.profile.privateKey!, + )) + ); + } + ), + ], ), - body: Padding( - padding: const EdgeInsets.only(top: 16,left: 16,right: 16), - child: Column( - children: [ - usernameHeading(), - const SizedBox(height: 30), - _profileQrCode(), - const SizedBox(height: 30), - settings(), - const SizedBox(height: 30), - logout(), - ], - ) + ); + } + + Widget _profileQrCode() { + String payload = jsonEncode({ + 'i': widget.profile.id, + 'u': widget.profile.username, + 'k': base64.encode( + CryptoUtils.encodeRSAPublicKeyToPem(widget.profile.publicKey!).codeUnits ), + }); + + return Column( + children: [ + Padding( + padding: const EdgeInsets.all(20), + child: QrImage( + backgroundColor: Theme.of(context).colorScheme.primary, + data: payload, + version: QrVersions.auto, + gapless: true, + ), + ), + Align( + alignment: Alignment.centerRight, + child: Padding( + padding: const EdgeInsets.only(right: 20), + child: IconButton( + onPressed: () => _panelController.close(), + icon: const Icon(Icons.arrow_upward), + ), + ), + ), + ] ); } } diff --git a/mobile/pubspec.lock b/mobile/pubspec.lock index 917ab66..128580d 100644 --- a/mobile/pubspec.lock +++ b/mobile/pubspec.lock @@ -322,6 +322,13 @@ packages: description: flutter source: sdk version: "0.0.99" + sliding_up_panel: + dependency: "direct main" + description: + name: sliding_up_panel + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.0+1" source_span: dependency: transitive description: diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index 6007c51..9a4d284 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -24,6 +24,7 @@ dependencies: uuid: ^3.0.6 qr_flutter: ^4.0.0 qr_code_scanner: ^1.0.1 + sliding_up_panel: ^2.0.0+1 dev_dependencies: flutter_test: