#2 feature/profile-page

Merged
tovi merged 3 commits from feature/profile-page into develop 2 years ago
  1. +52
    -0
      Backend/Api/Auth/ChangeMessageExpiry.go
  2. +76
    -0
      Backend/Api/Auth/ChangePassword.go
  3. +1
    -0
      Backend/Api/Auth/Check.go
  4. +16
    -7
      Backend/Api/Auth/Login.go
  5. +5
    -4
      Backend/Api/Auth/Signup.go
  6. +3
    -0
      Backend/Api/Routes.go
  7. +14
    -8
      Backend/Database/Init.go
  8. +1
    -1
      Backend/Database/Seeder/MessageSeeder.go
  9. +1
    -0
      Backend/Database/Seeder/Seed.go
  10. +8
    -4
      Backend/Models/Messages.go
  11. +41
    -1
      Backend/Models/Users.go
  12. +6
    -0
      mobile/lib/components/custom_title_bar.dart
  13. +2
    -3
      mobile/lib/components/qr_reader.dart
  14. +108
    -0
      mobile/lib/components/select_message_ttl.dart
  15. +1
    -1
      mobile/lib/components/user_search_result.dart
  16. +2
    -0
      mobile/lib/main.dart
  17. +30
    -2
      mobile/lib/models/my_profile.dart
  18. +5
    -6
      mobile/lib/utils/storage/conversations.dart
  19. +1
    -2
      mobile/lib/utils/storage/friends.dart
  20. +3
    -4
      mobile/lib/utils/storage/messages.dart
  21. +41
    -36
      mobile/lib/views/authentication/login.dart
  22. +154
    -86
      mobile/lib/views/authentication/signup.dart
  23. +5
    -1
      mobile/lib/views/main/conversation/detail.dart
  24. +1
    -1
      mobile/lib/views/main/conversation/list.dart
  25. +1
    -0
      mobile/lib/views/main/conversation/list_item.dart
  26. +2
    -3
      mobile/lib/views/main/friend/add_search.dart
  27. +5
    -6
      mobile/lib/views/main/friend/request_list_item.dart
  28. +3
    -2
      mobile/lib/views/main/home.dart
  29. +180
    -0
      mobile/lib/views/main/profile/change_password.dart
  30. +142
    -0
      mobile/lib/views/main/profile/change_server_url.dart
  31. +192
    -71
      mobile/lib/views/main/profile/profile.dart
  32. +7
    -0
      mobile/pubspec.lock
  33. +1
    -0
      mobile/pubspec.yaml

+ 52
- 0
Backend/Api/Auth/ChangeMessageExpiry.go View File

@ -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)
}

+ 76
- 0
Backend/Api/Auth/ChangePassword.go View File

@ -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)
}

+ 1
- 0
Backend/Api/Auth/Check.go View File

@ -4,6 +4,7 @@ import (
"net/http" "net/http"
) )
// Check is used to check session viability
func Check(w http.ResponseWriter, r *http.Request) { func Check(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
} }

+ 16
- 7
Backend/Api/Auth/Login.go View File

@ -1,6 +1,7 @@
package Auth package Auth
import ( import (
"database/sql/driver"
"encoding/json" "encoding/json"
"net/http" "net/http"
"time" "time"
@ -9,7 +10,7 @@ import (
"git.tovijaeschke.xyz/tovi/Envelope/Backend/Models" "git.tovijaeschke.xyz/tovi/Envelope/Backend/Models"
) )
type Credentials struct {
type credentials struct {
Username string `json:"username"` Username string `json:"username"`
Password string `json:"password"` Password string `json:"password"`
} }
@ -21,25 +22,32 @@ type loginResponse struct {
AsymmetricPrivateKey string `json:"asymmetric_private_key"` AsymmetricPrivateKey string `json:"asymmetric_private_key"`
UserID string `json:"user_id"` UserID string `json:"user_id"`
Username string `json:"username"` Username string `json:"username"`
MessageExpiryDefault string `json:"message_expiry_default"`
} }
func makeLoginResponse(w http.ResponseWriter, code int, message, pubKey, privKey string, user Models.User) { func makeLoginResponse(w http.ResponseWriter, code int, message, pubKey, privKey string, user Models.User) {
var ( var (
status string = "error"
returnJson []byte
status = "error"
messageExpiryRaw driver.Value
messageExpiry string
returnJSON []byte
err error err error
) )
if code > 200 && code < 300 {
if code >= 200 && code <= 300 {
status = "success" status = "success"
} }
returnJson, err = json.MarshalIndent(loginResponse{
messageExpiryRaw, _ = user.MessageExpiryDefault.Value()
messageExpiry, _ = messageExpiryRaw.(string)
returnJSON, err = json.MarshalIndent(loginResponse{
Status: status, Status: status,
Message: message, Message: message,
AsymmetricPublicKey: pubKey, AsymmetricPublicKey: pubKey,
AsymmetricPrivateKey: privKey, AsymmetricPrivateKey: privKey,
UserID: user.ID.String(), UserID: user.ID.String(),
Username: user.Username, Username: user.Username,
MessageExpiryDefault: messageExpiry,
}, "", " ") }, "", " ")
if err != nil { if err != nil {
http.Error(w, "Error", http.StatusInternalServerError) http.Error(w, "Error", http.StatusInternalServerError)
@ -48,12 +56,13 @@ func makeLoginResponse(w http.ResponseWriter, code int, message, pubKey, privKey
// Return updated json // Return updated json
w.WriteHeader(code) w.WriteHeader(code)
w.Write(returnJson)
w.Write(returnJSON)
} }
// Login logs the user into the system
func Login(w http.ResponseWriter, r *http.Request) { func Login(w http.ResponseWriter, r *http.Request) {
var ( var (
creds Credentials
creds credentials
userData Models.User userData Models.User
session Models.Session session Models.Session
expiresAt time.Time expiresAt time.Time


+ 5
- 4
Backend/Api/Auth/Signup.go View File

@ -18,15 +18,15 @@ type signupResponse struct {
func makeSignupResponse(w http.ResponseWriter, code int, message string) { func makeSignupResponse(w http.ResponseWriter, code int, message string) {
var ( var (
status string = "error"
returnJson []byte
status = "error"
returnJSON []byte
err error err error
) )
if code > 200 && code < 300 { if code > 200 && code < 300 {
status = "success" status = "success"
} }
returnJson, err = json.MarshalIndent(signupResponse{
returnJSON, err = json.MarshalIndent(signupResponse{
Status: status, Status: status,
Message: message, Message: message,
}, "", " ") }, "", " ")
@ -37,10 +37,11 @@ func makeSignupResponse(w http.ResponseWriter, code int, message string) {
// Return updated json // Return updated json
w.WriteHeader(code) w.WriteHeader(code)
w.Write(returnJson)
w.Write(returnJSON)
} }
// Signup to the platform
func Signup(w http.ResponseWriter, r *http.Request) { func Signup(w http.ResponseWriter, r *http.Request) {
var ( var (
userData Models.User userData Models.User


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

@ -61,6 +61,9 @@ func InitAPIEndpoints(router *mux.Router) {
authAPI.HandleFunc("/check", Auth.Check).Methods("GET") 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("/users", Users.SearchUsers).Methods("GET")
authAPI.HandleFunc("/friend_requests", Friends.EncryptedFriendRequestList).Methods("GET") authAPI.HandleFunc("/friend_requests", Friends.EncryptedFriendRequestList).Methods("GET")


+ 14
- 8
Backend/Database/Init.go View File

@ -10,13 +10,14 @@ import (
) )
const ( 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 var DB *gorm.DB
func GetModels() []interface{} {
func getModels() []interface{} {
return []interface{}{ return []interface{}{
&Models.Session{}, &Models.Session{},
&Models.User{}, &Models.User{},
@ -29,6 +30,7 @@ func GetModels() []interface{} {
} }
} }
// Init initializes the database connection
func Init() { func Init() {
var ( var (
model interface{} model interface{}
@ -37,7 +39,7 @@ func Init() {
log.Println("Initializing database...") 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 { if err != nil {
log.Fatalln(err) log.Fatalln(err)
@ -45,24 +47,28 @@ func Init() {
log.Println("Running AutoMigrate...") 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() { func InitTest() {
var ( var (
model interface{} model interface{}
err error err error
) )
DB, err = gorm.Open(postgres.Open(dbTestUrl), &gorm.Config{})
DB, err = gorm.Open(postgres.Open(dbTestURL), &gorm.Config{})
if err != nil { if err != nil {
log.Fatalln(err) log.Fatalln(err)
} }
for _, model = range GetModels() {
for _, model = range getModels() {
DB.Migrator().DropTable(model) DB.Migrator().DropTable(model)
DB.AutoMigrate(model) DB.AutoMigrate(model)
} }


+ 1
- 1
Backend/Database/Seeder/MessageSeeder.go View File

@ -298,7 +298,7 @@ func SeedMessages() {
panic(err) panic(err)
} }
for i = 0; i <= 20; i++ {
for i = 0; i <= 100; i++ {
err = seedMessage( err = seedMessage(
primaryUser, primaryUser,
secondaryUser, secondaryUser,


+ 1
- 0
Backend/Database/Seeder/Seed.go View File

@ -58,6 +58,7 @@ var (
decodedPrivateKey *rsa.PrivateKey decodedPrivateKey *rsa.PrivateKey
) )
// Seed seeds semi random data for use in testing & development
func Seed() { func Seed() {
var ( var (
block *pem.Block block *pem.Block


+ 8
- 4
Backend/Models/Messages.go View File

@ -1,12 +1,14 @@
package Models package Models
import ( import (
"database/sql"
"time" "time"
"github.com/gofrs/uuid" "github.com/gofrs/uuid"
) )
// TODO: Add support for images
// MessageData holds the content of the message
// encrypted through the Message.SymmetricKey
type MessageData struct { type MessageData struct {
Base Base
Data string `gorm:"not null" json:"data"` // Stored encrypted 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 SymmetricKey string `gorm:"not null" json:"symmetric_key"` // Stored encrypted
} }
// Message holds data pertaining to each users' message
type Message struct { type Message struct {
Base Base
MessageDataID uuid.UUID ` json:"message_data_id"` MessageDataID uuid.UUID ` json:"message_data_id"`
MessageData MessageData ` json:"message_data"` 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"`
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"`
} }

+ 41
- 1
Backend/Models/Users.go View File

@ -1,10 +1,12 @@
package Models package Models
import ( import (
"database/sql/driver"
"gorm.io/gorm" "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 // This stops a unique constraint error
func (u *User) BeforeUpdate(tx *gorm.DB) (err error) { func (u *User) BeforeUpdate(tx *gorm.DB) (err error) {
if !tx.Statement.Changed("Username") { if !tx.Statement.Changed("Username") {
@ -13,6 +15,42 @@ func (u *User) BeforeUpdate(tx *gorm.DB) (err error) {
return nil 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 { type User struct {
Base Base
Username string `gorm:"not null;unique" json:"username"` Username string `gorm:"not null;unique" json:"username"`
@ -20,4 +58,6 @@ type User struct {
ConfirmPassword string `gorm:"-" json:"confirm_password"` ConfirmPassword string `gorm:"-" json:"confirm_password"`
AsymmetricPrivateKey string `gorm:"not null" json:"asymmetric_private_key"` // Stored encrypted AsymmetricPrivateKey string `gorm:"not null" json:"asymmetric_private_key"` // Stored encrypted
AsymmetricPublicKey string `gorm:"not null" json:"asymmetric_public_key"` AsymmetricPublicKey string `gorm:"not null" json:"asymmetric_public_key"`
MessageExpiryDefault MessageExpiry `gorm:"default:no_expiry" json:"-" sql:"type:ENUM('fifteen_min', 'thirty_min', 'one_hour', 'three_hour', 'six_hour', 'twelve_hour', 'one_day', 'three_day')"` // Stored encrypted
} }

+ 6
- 0
mobile/lib/components/custom_title_bar.dart View File

@ -8,12 +8,14 @@ class CustomTitleBar extends StatelessWidget with PreferredSizeWidget {
required this.showBack, required this.showBack,
this.rightHandButton, this.rightHandButton,
this.backgroundColor, this.backgroundColor,
this.beforeBack,
}) : super(key: key); }) : super(key: key);
final Text title; final Text title;
final bool showBack; final bool showBack;
final IconButton? rightHandButton; final IconButton? rightHandButton;
final Color? backgroundColor; final Color? backgroundColor;
final Future<void> Function()? beforeBack;
@override @override
Size get preferredSize => const Size.fromHeight(kToolbarHeight); Size get preferredSize => const Size.fromHeight(kToolbarHeight);
@ -60,6 +62,10 @@ class CustomTitleBar extends StatelessWidget with PreferredSizeWidget {
Widget _backButton(BuildContext context) { Widget _backButton(BuildContext context) {
return IconButton( return IconButton(
onPressed: () { onPressed: () {
if (beforeBack != null) {
beforeBack!().then((dynamic) => Navigator.pop(context));
return;
}
Navigator.pop(context); Navigator.pop(context);
}, },
icon: Icon( icon: Icon(


+ 2
- 3
mobile/lib/components/qr_reader.dart View File

@ -1,9 +1,7 @@
import 'dart:convert'; import 'dart:convert';
import 'dart:io'; import 'dart:io';
import 'package:Envelope/utils/storage/session_cookie.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_dotenv/flutter_dotenv.dart';
import 'package:pointycastle/impl.dart'; import 'package:pointycastle/impl.dart';
import 'package:qr_code_scanner/qr_code_scanner.dart'; import 'package:qr_code_scanner/qr_code_scanner.dart';
import 'package:sqflite/sqflite.dart'; import 'package:sqflite/sqflite.dart';
@ -16,6 +14,7 @@ import '/utils/encryption/aes_helper.dart';
import '/utils/encryption/crypto_utils.dart'; import '/utils/encryption/crypto_utils.dart';
import '/utils/storage/database.dart'; import '/utils/storage/database.dart';
import '/utils/strings.dart'; import '/utils/strings.dart';
import '/utils/storage/session_cookie.dart';
import 'flash_message.dart'; import 'flash_message.dart';
class QrReader extends StatefulWidget { class QrReader extends StatefulWidget {
@ -128,7 +127,7 @@ class _QrReaderState extends State<QrReader> {
]); ]);
var resp = await http.post( 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: <String, String>{ headers: <String, String>{
'Content-Type': 'application/json; charset=UTF-8', 'Content-Type': 'application/json; charset=UTF-8',
'cookie': await getSessionCookie(), 'cookie': await getSessionCookie(),


+ 108
- 0
mobile/lib/components/select_message_ttl.dart View File

@ -0,0 +1,108 @@
import 'package:flutter/material.dart';
import '/components/custom_title_bar.dart';
const Map<String, String> 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<void> Function(String messageExpiry) backCallback;
final String? currentSelected;
@override
_SelectMessageTTLState createState() => _SelectMessageTTLState();
}
class _SelectMessageTTLState extends State<SelectMessageTTL> {
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,
),
),
)
)
],
)
)
);
},
);
}
}

+ 1
- 1
mobile/lib/components/user_search_result.dart View File

@ -119,7 +119,7 @@ class _UserSearchResultState extends State<UserSearchResult>{
}); });
var resp = await http.post( 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: { headers: {
'cookie': await getSessionCookie(), 'cookie': await getSessionCookie(),
}, },


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

@ -49,6 +49,8 @@ class MyApp extends StatelessWidget {
brightness: Brightness.dark, brightness: Brightness.dark,
primaryColor: Colors.orange.shade900, primaryColor: Colors.orange.shade900,
backgroundColor: Colors.grey.shade800, backgroundColor: Colors.grey.shade800,
scaffoldBackgroundColor: Colors.grey[850],
disabledColor: Colors.grey[400],
colorScheme: ColorScheme( colorScheme: ColorScheme(
brightness: Brightness.dark, brightness: Brightness.dark,
primary: Colors.orange.shade900, primary: Colors.orange.shade900,


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

@ -1,9 +1,16 @@
import 'dart:convert'; 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:pointycastle/impl.dart';
import 'package:shared_preferences/shared_preferences.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 { class MyProfile {
String id; String id;
String username; String username;
@ -11,6 +18,7 @@ class MyProfile {
RSAPrivateKey? privateKey; RSAPrivateKey? privateKey;
RSAPublicKey? publicKey; RSAPublicKey? publicKey;
DateTime? loggedInAt; DateTime? loggedInAt;
String messageExpiryDefault = 'no_expiry';
MyProfile({ MyProfile({
required this.id, required this.id,
@ -19,6 +27,7 @@ class MyProfile {
this.privateKey, this.privateKey,
this.publicKey, this.publicKey,
this.loggedInAt, this.loggedInAt,
required this.messageExpiryDefault,
}); });
factory MyProfile._fromJson(Map<String, dynamic> json) { factory MyProfile._fromJson(Map<String, dynamic> json) {
@ -36,6 +45,7 @@ class MyProfile {
privateKey: privateKey, privateKey: privateKey,
publicKey: publicKey, publicKey: publicKey,
loggedInAt: loggedInAt, loggedInAt: loggedInAt,
messageExpiryDefault: json['message_expiry_default']
); );
} }
@ -61,6 +71,7 @@ class MyProfile {
CryptoUtils.encodeRSAPublicKeyToPem(publicKey!) : CryptoUtils.encodeRSAPublicKeyToPem(publicKey!) :
null, null,
'logged_in_at': loggedInAt?.toIso8601String(), 'logged_in_at': loggedInAt?.toIso8601String(),
'message_expiry_default': messageExpiryDefault,
}); });
} }
@ -107,5 +118,22 @@ class MyProfile {
} }
return profile.privateKey!; return profile.privateKey!;
} }
static setServerUrl(String url) async {
final preferences = await SharedPreferences.getInstance();
preferences.setString('server_url', url);
}
static Future<Uri> 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');
}
} }

+ 5
- 6
mobile/lib/utils/storage/conversations.dart View File

@ -1,12 +1,11 @@
import 'dart:convert'; import 'dart:convert';
import 'package:Envelope/components/flash_message.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_dotenv/flutter_dotenv.dart';
import 'package:http/http.dart' as http; import 'package:http/http.dart' as http;
import 'package:pointycastle/export.dart'; import 'package:pointycastle/export.dart';
import 'package:sqflite/sqflite.dart'; import 'package:sqflite/sqflite.dart';
import '/components/flash_message.dart';
import '/models/conversation_users.dart'; import '/models/conversation_users.dart';
import '/models/conversations.dart'; import '/models/conversations.dart';
import '/models/my_profile.dart'; import '/models/my_profile.dart';
@ -20,7 +19,7 @@ Future<void> updateConversation(Conversation conversation, { includeUsers = true
Map<String, dynamic> conversationJson = await conversation.payloadJson(includeUsers: includeUsers); Map<String, dynamic> conversationJson = await conversation.payloadJson(includeUsers: includeUsers);
var x = await http.put( var x = await http.put(
Uri.parse('${dotenv.env["SERVER_URL"]}api/v1/auth/conversations'),
await MyProfile.getServerUrl('api/v1/auth/conversations'),
headers: <String, String>{ headers: <String, String>{
'Content-Type': 'application/json; charset=UTF-8', 'Content-Type': 'application/json; charset=UTF-8',
'cookie': sessionCookie, 'cookie': sessionCookie,
@ -38,7 +37,7 @@ Future<void> updateConversations() async {
// try { // try {
var resp = await http.get( var resp = await http.get(
Uri.parse('${dotenv.env["SERVER_URL"]}api/v1/auth/conversations'),
await MyProfile.getServerUrl('api/v1/auth/conversations'),
headers: { headers: {
'cookie': await getSessionCookie(), 'cookie': await getSessionCookie(),
} }
@ -68,7 +67,7 @@ Future<void> updateConversations() async {
Map<String, String> params = {}; Map<String, String> params = {};
params['conversation_detail_ids'] = conversationsDetailIds.join(','); 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); uri = uri.replace(queryParameters: params);
resp = await http.get( resp = await http.get(
@ -150,7 +149,7 @@ Future<void> uploadConversation(Conversation conversation, BuildContext context)
Map<String, dynamic> conversationJson = await conversation.payloadJson(); Map<String, dynamic> conversationJson = await conversation.payloadJson();
var resp = await http.post( var resp = await http.post(
Uri.parse('${dotenv.env["SERVER_URL"]}api/v1/auth/conversations'),
await MyProfile.getServerUrl('api/v1/auth/conversations'),
headers: <String, String>{ headers: <String, String>{
'Content-Type': 'application/json; charset=UTF-8', 'Content-Type': 'application/json; charset=UTF-8',
'cookie': sessionCookie, 'cookie': sessionCookie,


+ 1
- 2
mobile/lib/utils/storage/friends.dart View File

@ -1,6 +1,5 @@
import 'dart:convert'; import 'dart:convert';
import 'package:flutter_dotenv/flutter_dotenv.dart';
import 'package:http/http.dart' as http; import 'package:http/http.dart' as http;
import 'package:pointycastle/export.dart'; import 'package:pointycastle/export.dart';
import 'package:sqflite/sqflite.dart'; import 'package:sqflite/sqflite.dart';
@ -15,7 +14,7 @@ Future<void> updateFriends() async {
// try { // try {
var resp = await http.get( 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: { headers: {
'cookie': await getSessionCookie(), 'cookie': await getSessionCookie(),
} }


+ 3
- 4
mobile/lib/utils/storage/messages.dart View File

@ -1,6 +1,5 @@
import 'dart:convert'; import 'dart:convert';
import 'package:flutter_dotenv/flutter_dotenv.dart';
import 'package:http/http.dart' as http; import 'package:http/http.dart' as http;
import 'package:sqflite/sqflite.dart'; import 'package:sqflite/sqflite.dart';
import 'package:uuid/uuid.dart'; import 'package:uuid/uuid.dart';
@ -43,9 +42,9 @@ Future<void> sendMessage(Conversation conversation, String data) async {
String sessionCookie = await getSessionCookie(); String sessionCookie = await getSessionCookie();
message.payloadJson(conversation, messageId) message.payloadJson(conversation, messageId)
.then((messageJson) {
.then((messageJson) async {
return http.post( return http.post(
Uri.parse('${dotenv.env["SERVER_URL"]}api/v1/auth/message'),
await MyProfile.getServerUrl('api/v1/auth/message'),
headers: <String, String>{ headers: <String, String>{
'Content-Type': 'application/json; charset=UTF-8', 'Content-Type': 'application/json; charset=UTF-8',
'cookie': sessionCookie, 'cookie': sessionCookie,
@ -75,7 +74,7 @@ Future<void> updateMessageThread(Conversation conversation, {MyProfile? profile}
ConversationUser currentUser = await getConversationUser(conversation, profile.id); ConversationUser currentUser = await getConversationUser(conversation, profile.id);
var resp = await http.get( 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: { headers: {
'cookie': await getSessionCookie(), 'cookie': await getSessionCookie(),
} }


+ 41
- 36
mobile/lib/views/authentication/login.dart View File

@ -1,7 +1,9 @@
import 'dart:convert'; import 'dart:convert';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:http/http.dart' as http; 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 '/models/my_profile.dart';
import '/utils/storage/session_cookie.dart'; import '/utils/storage/session_cookie.dart';
@ -34,31 +36,6 @@ class LoginResponse {
} }
} }
Future<dynamic> login(context, String username, String password) async {
final resp = await http.post(
Uri.parse('${dotenv.env["SERVER_URL"]}api/v1/login'),
headers: <String, String>{
'Content-Type': 'application/json; charset=UTF-8',
},
body: jsonEncode(<String, String>{
'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 { class Login extends StatelessWidget {
const Login({Key? key}) : super(key: key); const Login({Key? key}) : super(key: key);
@ -93,8 +70,8 @@ class LoginWidget extends StatefulWidget {
class _LoginWidgetState extends State<LoginWidget> { class _LoginWidgetState extends State<LoginWidget> {
final _formKey = GlobalKey<FormState>(); final _formKey = GlobalKey<FormState>();
TextEditingController usernameController = TextEditingController();
TextEditingController passwordController = TextEditingController();
final TextEditingController _usernameController = TextEditingController();
final TextEditingController _passwordController = TextEditingController();
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@ -145,7 +122,7 @@ class _LoginWidgetState extends State<LoginWidget> {
), ),
const SizedBox(height: 30), const SizedBox(height: 30),
TextFormField( TextFormField(
controller: usernameController,
controller: _usernameController,
decoration: InputDecoration( decoration: InputDecoration(
hintText: 'Username', hintText: 'Username',
enabledBorder: inputBorderStyle, enabledBorder: inputBorderStyle,
@ -162,7 +139,7 @@ class _LoginWidgetState extends State<LoginWidget> {
), ),
const SizedBox(height: 10), const SizedBox(height: 10),
TextFormField( TextFormField(
controller: passwordController,
controller: _passwordController,
obscureText: true, obscureText: true,
enableSuggestions: false, enableSuggestions: false,
autocorrect: false, autocorrect: false,
@ -189,11 +166,8 @@ class _LoginWidgetState extends State<LoginWidget> {
const SnackBar(content: Text('Processing Data')), const SnackBar(content: Text('Processing Data')),
); );
login(
context,
usernameController.text,
passwordController.text,
).then((val) {
login()
.then((val) {
Navigator. Navigator.
pushNamedAndRemoveUntil( pushNamedAndRemoveUntil(
context, context,
@ -201,7 +175,10 @@ class _LoginWidgetState extends State<LoginWidget> {
ModalRoute.withName('/home'), ModalRoute.withName('/home'),
); );
}).catchError((error) { }).catchError((error) {
print(error); // TODO: Show error on interface
showMessage(
'Could not login to Envelope, please try again later.',
context,
);
}); });
} }
}, },
@ -214,4 +191,32 @@ class _LoginWidgetState extends State<LoginWidget> {
) )
); );
} }
Future<dynamic> login() async {
final resp = await http.post(
await MyProfile.getServerUrl('api/v1/login'),
headers: <String, String>{
'Content-Type': 'application/json; charset=UTF-8',
},
body: jsonEncode(<String, String>{
'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,
);
}
} }

+ 154
- 86
mobile/lib/views/authentication/signup.dart View File

@ -1,44 +1,14 @@
import 'dart:convert'; import 'dart:convert';
import 'dart:typed_data'; 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/material.dart';
import 'package:flutter_dotenv/flutter_dotenv.dart';
import 'package:http/http.dart' as http; import 'package:http/http.dart' as http;
import '/utils/encryption/aes_helper.dart'; import '/utils/encryption/aes_helper.dart';
import '/utils/encryption/crypto_utils.dart'; import '/utils/encryption/crypto_utils.dart';
Future<SignupResponse> 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: <String, String>{
'Content-Type': 'application/json; charset=UTF-8',
},
body: jsonEncode(<String, String>{
'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 { class Signup extends StatelessWidget {
const Signup({Key? key}) : super(key: key); const Signup({Key? key}) : super(key: key);
@ -93,15 +63,12 @@ class SignupWidget extends StatefulWidget {
class _SignupWidgetState extends State<SignupWidget> { class _SignupWidgetState extends State<SignupWidget> {
final _formKey = GlobalKey<FormState>(); final _formKey = GlobalKey<FormState>();
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();
@override
Widget build(BuildContext context) {
const TextStyle inputTextStyle = TextStyle(
fontSize: 18,
);
bool showUrlInput = false;
final OutlineInputBorder inputBorderStyle = OutlineInputBorder( final OutlineInputBorder inputBorderStyle = OutlineInputBorder(
borderRadius: BorderRadius.circular(5), borderRadius: BorderRadius.circular(5),
@ -110,6 +77,13 @@ class _SignupWidgetState extends State<SignupWidget> {
) )
); );
final TextStyle inputTextStyle = const TextStyle(
fontSize: 18,
);
@override
Widget build(BuildContext context) {
final ButtonStyle buttonStyle = ElevatedButton.styleFrom( final ButtonStyle buttonStyle = ElevatedButton.styleFrom(
primary: Theme.of(context).colorScheme.surface, primary: Theme.of(context).colorScheme.surface,
onPrimary: Theme.of(context).colorScheme.onSurface, onPrimary: Theme.of(context).colorScheme.onSurface,
@ -123,6 +97,7 @@ class _SignupWidgetState extends State<SignupWidget> {
); );
return Center( return Center(
child: SingleChildScrollView(
child: Form( child: Form(
key: _formKey, key: _formKey,
child: Center( child: Center(
@ -145,16 +120,11 @@ class _SignupWidgetState extends State<SignupWidget> {
), ),
), ),
const SizedBox(height: 30), 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) {
input(
_usernameController,
'Username',
false,
(value) {
if (value == null || value.isEmpty) { if (value == null || value.isEmpty) {
return 'Create a username'; return 'Create a username';
} }
@ -162,19 +132,11 @@ class _SignupWidgetState extends State<SignupWidget> {
}, },
), ),
const SizedBox(height: 10), 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) {
input(
_passwordController,
'Password',
true,
(value) {
if (value == null || value.isEmpty) { if (value == null || value.isEmpty) {
return 'Enter a password'; return 'Enter a password';
} }
@ -182,48 +144,40 @@ class _SignupWidgetState extends State<SignupWidget> {
}, },
), ),
const SizedBox(height: 10), 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) {
input(
_passwordConfirmController,
'Confirm Password',
true,
(value) {
if (value == null || value.isEmpty) { if (value == null || value.isEmpty) {
return 'Confirm your password'; return 'Confirm your password';
} }
if (value != passwordController.text) {
if (value != _passwordController.text) {
return 'Passwords do not match'; return 'Passwords do not match';
} }
return null; return null;
}, },
), ),
const SizedBox(height: 15), const SizedBox(height: 15),
serverUrl(),
const SizedBox(height: 15),
ElevatedButton( ElevatedButton(
style: buttonStyle, style: buttonStyle,
onPressed: () { onPressed: () {
if (_formKey.currentState!.validate()) {
if (!_formKey.currentState!.validate()) {
return;
}
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Processing Data')), const SnackBar(content: Text('Processing Data')),
); );
signUp(
context,
usernameController.text,
passwordController.text,
passwordConfirmController.text
).then((value) {
signUp()
.then((dynamic) {
Navigator.of(context).popUntil((route) => route.isFirst); Navigator.of(context).popUntil((route) => route.isFirst);
}).catchError((error) { }).catchError((error) {
print(error); // TODO: Show error on interface
showMessage('Failed to signup to Envelope, please try again later', context);
}); });
}
}, },
child: const Text('Submit'), child: const Text('Submit'),
), ),
@ -232,6 +186,120 @@ class _SignupWidgetState extends State<SignupWidget> {
) )
) )
) )
)
);
}
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<SignupResponse> 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: <String, String>{
'Content-Type': 'application/json; charset=UTF-8',
},
body: jsonEncode(<String, String>{
'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;
}
} }

+ 5
- 1
mobile/lib/views/main/conversation/detail.dart View File

@ -22,7 +22,11 @@ class ConversationDetail extends StatefulWidget{
class _ConversationDetailState extends State<ConversationDetail> { class _ConversationDetailState extends State<ConversationDetail> {
List<Message> messages = []; List<Message> messages = [];
MyProfile profile = MyProfile(id: '', username: '');
MyProfile profile = MyProfile(
id: '',
username: '',
messageExpiryDefault: 'no_expiry',
);
TextEditingController msgController = TextEditingController(); TextEditingController msgController = TextEditingController();


+ 1
- 1
mobile/lib/views/main/conversation/list.dart View File

@ -47,7 +47,7 @@ class _ConversationListState extends State<ConversationList> {
children: <Widget>[ children: <Widget>[
TextField( TextField(
decoration: const InputDecoration( decoration: const InputDecoration(
hintText: "Search...",
hintText: 'Search...',
prefixIcon: Icon( prefixIcon: Icon(
Icons.search, Icons.search,
size: 20 size: 20


+ 1
- 0
mobile/lib/views/main/conversation/list_item.dart View File

@ -33,6 +33,7 @@ class _ConversationListItemState extends State<ConversationListItem> {
); );
})).then(onGoBack) : null; })).then(onGoBack) : null;
}, },
child: Container( child: Container(
padding: const EdgeInsets.only(left: 16,right: 0,top: 10,bottom: 10), padding: const EdgeInsets.only(left: 16,right: 0,top: 10,bottom: 10),
child: !loaded ? null : Row( child: !loaded ? null : Row(


+ 2
- 3
mobile/lib/views/main/friend/add_search.dart View File

@ -2,12 +2,11 @@ import 'dart:convert';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:http/http.dart' as http; import 'package:http/http.dart' as http;
import 'package:flutter_dotenv/flutter_dotenv.dart';
import '/utils/storage/session_cookie.dart'; import '/utils/storage/session_cookie.dart';
import '/components/user_search_result.dart'; import '/components/user_search_result.dart';
import '/data_models/user_search.dart'; import '/data_models/user_search.dart';
import '/models/my_profile.dart';
class FriendAddSearch extends StatefulWidget { class FriendAddSearch extends StatefulWidget {
const FriendAddSearch({ const FriendAddSearch({
@ -123,7 +122,7 @@ class _FriendAddSearchState extends State<FriendAddSearch> {
Map<String, String> params = {}; Map<String, String> params = {};
params['username'] = searchController.text; 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); uri = uri.replace(queryParameters: params);
var resp = await http.get( var resp = await http.get(


+ 5
- 6
mobile/lib/views/main/friend/request_list_item.dart View File

@ -1,16 +1,15 @@
import 'dart:convert'; import 'dart:convert';
import 'dart:typed_data'; 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/material.dart';
import 'package:flutter_dotenv/flutter_dotenv.dart';
import 'package:http/http.dart' as http; import 'package:http/http.dart' as http;
import '/components/flash_message.dart';
import '/components/custom_circle_avatar.dart'; import '/components/custom_circle_avatar.dart';
import '/models/friends.dart'; import '/models/friends.dart';
import '/utils/storage/session_cookie.dart';
import '/models/my_profile.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/aes_helper.dart';
import '/utils/encryption/crypto_utils.dart'; import '/utils/encryption/crypto_utils.dart';
import '/utils/strings.dart'; import '/utils/strings.dart';
@ -122,7 +121,7 @@ class _FriendRequestListItemState extends State<FriendRequestListItem> {
}); });
var resp = await http.post( 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: { headers: {
'cookie': await getSessionCookie(), 'cookie': await getSessionCookie(),
}, },
@ -153,7 +152,7 @@ class _FriendRequestListItemState extends State<FriendRequestListItem> {
Future<void> rejectFriendRequest() async { Future<void> rejectFriendRequest() async {
var resp = await http.delete( 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: { headers: {
'cookie': await getSessionCookie(), 'cookie': await getSessionCookie(),
}, },


+ 3
- 2
mobile/lib/views/main/home.dart View File

@ -1,5 +1,4 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_dotenv/flutter_dotenv.dart';
import 'package:http/http.dart' as http; import 'package:http/http.dart' as http;
import '/models/conversations.dart'; import '/models/conversations.dart';
@ -27,6 +26,7 @@ class _HomeState extends State<Home> {
MyProfile profile = MyProfile( MyProfile profile = MyProfile(
id: '', id: '',
username: '', username: '',
messageExpiryDefault: 'no_expiry',
); );
bool isLoading = true; bool isLoading = true;
@ -38,6 +38,7 @@ class _HomeState extends State<Home> {
profile: MyProfile( profile: MyProfile(
id: '', id: '',
username: '', username: '',
messageExpiryDefault: 'no_expiry',
) )
), ),
]; ];
@ -94,7 +95,7 @@ class _HomeState extends State<Home> {
int statusCode = 200; int statusCode = 200;
try { try {
var resp = await http.get( var resp = await http.get(
Uri.parse('${dotenv.env["SERVER_URL"]}api/v1/auth/check'),
await MyProfile.getServerUrl('api/v1/auth/check'),
headers: { headers: {
'cookie': await getSessionCookie(), 'cookie': await getSessionCookie(),
} }


+ 180
- 0
mobile/lib/views/main/profile/change_password.dart View File

@ -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<FormState>();
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<void> _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: <String, String>{
'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,
);
}
}
}

+ 142
- 0
mobile/lib/views/main/profile/change_server_url.dart View File

@ -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<ChangeServerUrl> createState() => _ChangeServerUrl();
}
class _ChangeServerUrl extends State<ChangeServerUrl> {
final _formKey = GlobalKey<FormState>();
final TextEditingController _serverUrlController = TextEditingController();
bool invalidCurrentPassword = false;
@override
void initState() {
setUrl();
super.initState();
}
Future<void> 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<void> changeUrl() async {
MyProfile.setServerUrl(_serverUrlController.text);
deleteDb();
MyProfile.logout();
Navigator.pushNamedAndRemoveUntil(context, '/landing', ModalRoute.withName('/landing'));
}
}

+ 192
- 71
mobile/lib/views/main/profile/profile.dart View File

@ -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/material.dart';
import 'package:flutter_dotenv/flutter_dotenv.dart'; import 'package:flutter_dotenv/flutter_dotenv.dart';
import 'package:qr_flutter/qr_flutter.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_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 { class Profile extends StatefulWidget {
final MyProfile profile; final MyProfile profile;
@ -18,6 +29,51 @@ class Profile extends StatefulWidget {
} }
class _ProfileState extends State<Profile> { class _ProfileState extends State<Profile> {
final PanelController _panelController = PanelController();
@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,
),
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: <Widget>[
usernameHeading(),
const SizedBox(height: 30),
settings(),
const SizedBox(height: 30),
logout(),
],
)
),
),
);
}
Widget usernameHeading() { Widget usernameHeading() {
return Row( return Row(
children: <Widget> [ children: <Widget> [
@ -27,37 +83,62 @@ class _ProfileState extends State<Profile> {
radius: 30, radius: 30,
), ),
const SizedBox(width: 20), const SizedBox(width: 20),
Text(
Expanded(
flex: 1,
child: Text(
widget.profile.username, widget.profile.username,
style: const TextStyle( style: const TextStyle(
fontSize: 25, fontSize: 25,
fontWeight: FontWeight.w500, 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(),
),
IconButton(
onPressed: () => _panelController.open(),
icon: const Icon(Icons.qr_code_2),
),
], ],
); );
} }
Widget _profileQrCode() {
return Container(
child: QrImage(
data: 'This is a simple QR code',
version: QrVersions.auto,
size: 130,
gapless: true,
Widget logout() {
bool isTesting = dotenv.env['ENVIRONMENT'] == 'development';
return Align(
alignment: Alignment.centerLeft,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
TextButton.icon(
label: const Text(
'Logout',
style: TextStyle(fontSize: 16)
),
icon: const Icon(Icons.exit_to_app),
style: const ButtonStyle(
alignment: Alignment.centerLeft,
),
onPressed: () {
deleteDb();
MyProfile.logout();
Navigator.pushNamedAndRemoveUntil(context, '/landing', ModalRoute.withName('/landing'));
}
),
isTesting ? TextButton.icon(
label: const Text(
'Delete Database',
style: TextStyle(fontSize: 16)
),
icon: const Icon(Icons.delete_forever),
style: const ButtonStyle(
alignment: Alignment.centerLeft,
),
onPressed: () {
deleteDb();
}
) : const SizedBox.shrink(),
],
), ),
width: 130,
height: 130,
color: Theme.of(context).colorScheme.onPrimary,
); );
} }
@ -67,7 +148,9 @@ class _ProfileState extends State<Profile> {
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
const SizedBox(height: 5), const SizedBox(height: 5),
TextButton.icon( TextButton.icon(
label: const Text( label: const Text(
'Disappearing Messages', 'Disappearing Messages',
@ -83,82 +166,120 @@ class _ProfileState extends State<Profile> {
) )
), ),
onPressed: () { onPressed: () {
print('Disappearing Messages');
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),
Widget logout() {
bool isTesting = dotenv.env["ENVIRONMENT"] == 'development';
return Align(
alignment: Alignment.centerLeft,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
TextButton.icon( TextButton.icon(
label: const Text( label: const Text(
'Logout',
'Server URL',
style: TextStyle(fontSize: 16) style: TextStyle(fontSize: 16)
), ),
icon: const Icon(Icons.exit_to_app),
style: const ButtonStyle(
icon: const Icon(Icons.dataset_linked_outlined),
style: ButtonStyle(
alignment: Alignment.centerLeft, alignment: Alignment.centerLeft,
foregroundColor: MaterialStateProperty.resolveWith<Color>(
(Set<MaterialState> states) {
return Theme.of(context).colorScheme.onBackground;
},
)
), ),
onPressed: () { onPressed: () {
deleteDb();
MyProfile.logout();
Navigator.pushNamedAndRemoveUntil(context, '/landing', ModalRoute.withName('/landing'));
Navigator.of(context).push(
MaterialPageRoute(builder: (context) => const ChangeServerUrl())
);
} }
), ),
isTesting ? TextButton.icon(
const SizedBox(height: 5),
TextButton.icon(
label: const Text( label: const Text(
'Delete Database',
'Change Password',
style: TextStyle(fontSize: 16) style: TextStyle(fontSize: 16)
), ),
icon: const Icon(Icons.delete_forever),
style: const ButtonStyle(
icon: const Icon(Icons.password),
style: ButtonStyle(
alignment: Alignment.centerLeft, alignment: Alignment.centerLeft,
foregroundColor: MaterialStateProperty.resolveWith<Color>(
(Set<MaterialState> states) {
return Theme.of(context).colorScheme.onBackground;
},
)
), ),
onPressed: () { onPressed: () {
deleteDb();
Navigator.of(context).push(
MaterialPageRoute(builder: (context) => ChangePassword(
privateKey: widget.profile.privateKey!,
))
);
} }
) : const SizedBox.shrink(),
),
], ],
), ),
); );
} }
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: const CustomTitleBar(
title: Text(
'Profile',
style: TextStyle(
fontSize: 32,
fontWeight: FontWeight.bold
)
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),
), ),
showBack: false,
backgroundColor: Colors.transparent,
), ),
body: Padding(
padding: const EdgeInsets.only(top: 16,left: 16,right: 16),
child: Column(
children: <Widget>[
usernameHeading(),
const SizedBox(height: 30),
_profileQrCode(),
const SizedBox(height: 30),
settings(),
const SizedBox(height: 30),
logout(),
],
)
), ),
]
); );
} }
} }

+ 7
- 0
mobile/pubspec.lock View File

@ -322,6 +322,13 @@ packages:
description: flutter description: flutter
source: sdk source: sdk
version: "0.0.99" 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: source_span:
dependency: transitive dependency: transitive
description: description:


+ 1
- 0
mobile/pubspec.yaml View File

@ -24,6 +24,7 @@ dependencies:
uuid: ^3.0.6 uuid: ^3.0.6
qr_flutter: ^4.0.0 qr_flutter: ^4.0.0
qr_code_scanner: ^1.0.1 qr_code_scanner: ^1.0.1
sliding_up_panel: ^2.0.0+1
dev_dependencies: dev_dependencies:
flutter_test: flutter_test:


Loading…
Cancel
Save