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/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..33d2069 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:"message_expiry_default" 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/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/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 01f32c8..ce2ea93 100644 --- a/mobile/lib/main.dart +++ b/mobile/lib/main.dart @@ -50,6 +50,7 @@ class MyApp extends StatelessWidget { 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..07ec14a 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: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; @@ -107,5 +114,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..be227a6 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,154 @@ 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 9522444..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); @@ -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,117 +97,209 @@ class _SignupWidgetState extends State { ); return Center( - child: Form( - 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: SingleChildScrollView( + child: Form( + 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, + ), ), - ), - const SizedBox(height: 30), - TextFormField( - controller: _usernameController, - decoration: InputDecoration( - hintText: 'Username', - enabledBorder: inputBorderStyle, - focusedBorder: inputBorderStyle, + const SizedBox(height: 30), + input( + _usernameController, + 'Username', + false, + (value) { + if (value == null || value.isEmpty) { + return 'Create a username'; + } + return null; + }, ), - 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, + const SizedBox(height: 10), + input( + _passwordController, + 'Password', + true, + (value) { + if (value == null || value.isEmpty) { + return 'Enter a password'; + } + return null; + }, ), - 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: 'Confirm Password', - enabledBorder: inputBorderStyle, - focusedBorder: inputBorderStyle, + 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; + }, ), - 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()) { - return; - } - - 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: 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/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..b069fe6 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'; @@ -94,7 +93,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 index 3b2e6f8..2f77448 100644 --- a/mobile/lib/views/main/profile/change_password.dart +++ b/mobile/lib/views/main/profile/change_password.dart @@ -1,13 +1,13 @@ import 'dart:convert'; import 'dart:typed_data'; -import 'package:flutter_dotenv/flutter_dotenv.dart'; 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'; @@ -155,7 +155,7 @@ class ChangePassword extends StatelessWidget { }); var resp = await http.post( - Uri.parse('${dotenv.env["SERVER_URL"]}api/v1/auth/change_password'), + await MyProfile.getServerUrl('api/v1/auth/change_password'), headers: { 'Content-Type': 'application/json; charset=UTF-8', 'cookie': await getSessionCookie(), 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 b25770d..bedadcf 100644 --- a/mobile/lib/views/main/profile/profile.dart +++ b/mobile/lib/views/main/profile/profile.dart @@ -1,7 +1,5 @@ import 'dart:convert'; -import 'package:Envelope/utils/encryption/crypto_utils.dart'; -import 'package:Envelope/views/main/profile/change_password.dart'; import 'package:flutter/material.dart'; import 'package:flutter_dotenv/flutter_dotenv.dart'; import 'package:qr_flutter/qr_flutter.dart'; @@ -10,7 +8,10 @@ import 'package:sliding_up_panel/sliding_up_panel.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; @@ -178,7 +179,10 @@ class _ProfileState extends State { ) ), onPressed: () { - print('Server URL'); + Navigator.of(context).push( + MaterialPageRoute(builder: (context) => ChangeServerUrl( + )) + ); } ), const SizedBox(height: 5),