From f5258d42e496b835b193ed32c0d6b9c78375187e Mon Sep 17 00:00:00 2001 From: Alexandertp Date: Tue, 10 Sep 2024 15:44:50 +0200 Subject: [PATCH] Make image upload work in rust Co-authored-by: Reimar --- API/.gitignore | 1 + Mobile/lib/api.dart | 9 +- Mobile/lib/createreview.dart | 20 +++- Mobile/lib/editprofile.dart | 6 +- Mobile/lib/login.dart | 4 +- Mobile/lib/models.dart | 16 +++ Mobile/lib/profile.dart | 6 +- Mobile/pubspec.yaml | 1 + rust-backend/.env.example | 2 +- rust-backend/Cargo.lock | 98 +++++++++++++++++++ rust-backend/Cargo.toml | 3 +- .../V5__add_image_id_to_reviews_table.sql | 1 + rust-backend/src/main.rs | 20 +++- 13 files changed, 166 insertions(+), 21 deletions(-) create mode 100644 rust-backend/migrations/V5__add_image_id_to_reviews_table.sql diff --git a/API/.gitignore b/API/.gitignore index cddb4f0..3b4781b 100644 --- a/API/.gitignore +++ b/API/.gitignore @@ -1,3 +1,4 @@ database.sqlite3* efbundle +appsettings* diff --git a/Mobile/lib/api.dart b/Mobile/lib/api.dart index 79f3299..e99a012 100644 --- a/Mobile/lib/api.dart +++ b/Mobile/lib/api.dart @@ -1,17 +1,18 @@ import 'dart:io'; - +import 'dart:typed_data'; import 'package:flutter/material.dart'; import 'package:flutter_image_compress/flutter_image_compress.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:http/http.dart' as http; import 'dart:convert'; +import 'package:path/path.dart' as p; enum ApiService { auth, app, } -Future request(BuildContext? context, ApiService service, String method, String path, Object? body) async { +Future request(BuildContext? context, ApiService service, String method, String path, dynamic body) async { final messenger = context != null ? ScaffoldMessenger.of(context) : null; final prefs = await SharedPreferences.getInstance(); @@ -42,7 +43,7 @@ Future request(BuildContext? context, ApiService service, String method response = await function( Uri.parse(host + path), headers: headers, - body: body != null ? jsonEncode(body) : null, + body: body is Uint8List ? body : (body is Object ? jsonEncode(body) : null), ); } } catch (e) { @@ -103,7 +104,7 @@ Future putUser( 'ProfilePicture', // field name matches your backend DTO fileStream, length, - filename: profilePicture.path.split('/').last, + filename: p.basename(profilePicture.path), ); request.files.add(multipartFile); } diff --git a/Mobile/lib/createreview.dart b/Mobile/lib/createreview.dart index b7d6b2e..4b7060b 100644 --- a/Mobile/lib/createreview.dart +++ b/Mobile/lib/createreview.dart @@ -3,8 +3,9 @@ import 'dart:io'; import 'package:flutter/material.dart'; import 'package:image_picker/image_picker.dart'; import 'package:mobile/base/sidemenu.dart'; -import 'models.dart'; +import 'models.dart' as models; import 'api.dart' as api; +import 'package:path/path.dart' as path; class CreateReviewPage extends StatefulWidget { const CreateReviewPage({super.key}); @@ -17,13 +18,13 @@ class CreateReviewPage extends StatefulWidget { class _CreateReviewState extends State { final titleInput = TextEditingController(); final contentInput = TextEditingController(); - Place? place; + models.Place? place; var rating = 0; File? _selectedImage; @override Widget build(BuildContext context) { - place = ModalRoute.of(context)!.settings.arguments as Place; + place = ModalRoute.of(context)!.settings.arguments as models.Place; return SideMenu( selectedIndex: -1, @@ -111,6 +112,16 @@ class _CreateReviewState extends State { } Future _submitReview() async { + models.Image? image; + + if (_selectedImage != null) { + final fileName = path.basename(_selectedImage!.path); + final response = await api.request(context, api.ApiService.app, 'POST', '/images?file_name=$fileName', _selectedImage!.readAsBytesSync()); + if (response == null) return; + + image = models.Image.fromJson(jsonDecode(response)); + } + final response = await api.request(context, api.ApiService.app, 'POST', '/reviews', { 'title': titleInput.text, 'content': contentInput.text, @@ -119,11 +130,12 @@ class _CreateReviewState extends State { 'rating': rating, 'lat': place!.point.latitude, 'lng': place!.point.longitude, + 'image_id': image?.id, }); if (response == null || !mounted) return; - final review = Review.fromJson(jsonDecode(response)); + final review = models.Review.fromJson(jsonDecode(response)); ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Review submitted'))); diff --git a/Mobile/lib/editprofile.dart b/Mobile/lib/editprofile.dart index 38353dd..bc230b7 100644 --- a/Mobile/lib/editprofile.dart +++ b/Mobile/lib/editprofile.dart @@ -2,13 +2,13 @@ import 'dart:io'; import 'package:flutter/material.dart'; import 'package:image_picker/image_picker.dart'; -import 'package:mobile/models.dart'; +import 'package:mobile/models.dart' as models; import 'package:shared_preferences/shared_preferences.dart'; import 'api.dart' as api; import 'base/variables.dart'; class EditProfilePage extends StatefulWidget { - final User? userData; + final models.User? userData; const EditProfilePage({super.key, required this.userData}); @@ -24,7 +24,7 @@ class _ProfilePageState extends State { TextEditingController confirmPasswordInput = TextEditingController(); File? _selectedImage; - set userData(User userData) {} + set userData(models.User userData) {} @override diff --git a/Mobile/lib/login.dart b/Mobile/lib/login.dart index 87e71f2..6c21619 100644 --- a/Mobile/lib/login.dart +++ b/Mobile/lib/login.dart @@ -1,6 +1,6 @@ import 'package:flutter/material.dart'; import 'package:mobile/base/sidemenu.dart'; -import 'package:mobile/models.dart'; +import 'package:mobile/models.dart' as models; import 'package:shared_preferences/shared_preferences.dart'; import 'package:google_fonts/google_fonts.dart'; import 'dart:convert'; @@ -28,7 +28,7 @@ class _LoginPageState extends State { // Assuming token is a JSON string Map json = jsonDecode(response); - Login jsonUser = Login.fromJson(json); + models.Login jsonUser = models.Login.fromJson(json); final prefs = await SharedPreferences.getInstance(); prefs.setString('token', jsonUser.token); diff --git a/Mobile/lib/models.dart b/Mobile/lib/models.dart index 1cd0407..3e3b8a2 100644 --- a/Mobile/lib/models.dart +++ b/Mobile/lib/models.dart @@ -103,6 +103,22 @@ class User { } } +class Image { + int id; + String userId; + String imageUrl; + + Image(this.id, this.userId, this.imageUrl); + + factory Image.fromJson(Map json) { + return Image( + json['id'], + json['user_id'], + json['image_url'], + ); + } +} + class SearchResults { LatLng location; String name; diff --git a/Mobile/lib/profile.dart b/Mobile/lib/profile.dart index 650dce9..6fa34fc 100644 --- a/Mobile/lib/profile.dart +++ b/Mobile/lib/profile.dart @@ -1,7 +1,7 @@ import 'dart:convert'; import 'package:flutter/material.dart'; import 'package:mobile/base/variables.dart'; -import 'package:mobile/models.dart'; +import 'package:mobile/models.dart' as models; import 'package:shared_preferences/shared_preferences.dart'; import 'base/sidemenu.dart'; import 'api.dart' as api; @@ -15,7 +15,7 @@ class ProfilePage extends StatefulWidget { } class _ProfilePageState extends State { - User? userData; + models.User? userData; @override void initState() { @@ -39,7 +39,7 @@ class _ProfilePageState extends State { if (response == null) return; Map json = jsonDecode(response); - User jsonUser = User.fromJson(json); + models.User jsonUser = models.User.fromJson(json); setState(() { userData = jsonUser; diff --git a/Mobile/pubspec.yaml b/Mobile/pubspec.yaml index 789f90a..9b609b5 100644 --- a/Mobile/pubspec.yaml +++ b/Mobile/pubspec.yaml @@ -33,6 +33,7 @@ dependencies: http: ^1.2.1 flutter_map: ^7.0.2 latlong2: ^0.9.1 + path: ^1.9.0 # The following adds the Cupertino Icons font to your application. # Use with the CupertinoIcons class for iOS style icons. diff --git a/rust-backend/.env.example b/rust-backend/.env.example index f4ee778..f574e23 100644 --- a/rust-backend/.env.example +++ b/rust-backend/.env.example @@ -1,7 +1,7 @@ JWT_SECRET=DenHerMåAldrigVæreOffentligKunIDetteDemoProjekt AWS_ACCESS_KEY_ID= AWS_SECRET_ACCESS_KEY= -AWS_SESSION_TOKEN= +AWS_ENDPOINT_URL= AWS_REGION= R2_BUCKET_NAME= R2_BUCKET_URL= diff --git a/rust-backend/Cargo.lock b/rust-backend/Cargo.lock index c474b7c..1f3a795 100644 --- a/rust-backend/Cargo.lock +++ b/rust-backend/Cargo.lock @@ -243,6 +243,55 @@ version = "0.2.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5c6cb57a04249c6480766f7f7cef5467412af1490f8d1e243141daddada3264f" +[[package]] +name = "anstream" +version = "0.6.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64e15c1ab1f89faffbf04a634d5e1962e9074f2741eef6d97f3c4e322426d526" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bec1de6f59aedf83baf9ff929c98f2ad654b97c9510f4e70cf6f661d49fd5b1" + +[[package]] +name = "anstyle-parse" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb47de1e80c2b463c735db5b217a0ddc39d612e7ac9e2e96a5aed1f57616c1cb" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d36fc52c7f6c869915e99412912f22093507da8d9e942ceaf66fe4b7c14422a" +dependencies = [ + "windows-sys 0.52.0", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5bf74e1b6e971609db8ca7a9ce79fd5768ab6ae46441c572e46cf596f59e57f8" +dependencies = [ + "anstyle", + "windows-sys 0.52.0", +] + [[package]] name = "async-trait" version = "0.1.81" @@ -779,6 +828,12 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" +[[package]] +name = "colorchoice" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3fd119d74b830634cea2a0f58bbd0d54540518a14397557951e79340abc28c0" + [[package]] name = "const-oid" version = "0.9.6" @@ -973,6 +1028,29 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "env_filter" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f2c92ceda6ceec50f43169f9ee8424fe2db276791afde7b2cd8bc084cb376ab" +dependencies = [ + "log", + "regex", +] + +[[package]] +name = "env_logger" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13fa619b91fb2381732789fc5de83b45675e882f66623b7d8cb4f643017018d" +dependencies = [ + "anstream", + "anstyle", + "env_filter", + "humantime", + "log", +] + [[package]] name = "equivalent" version = "1.0.1" @@ -1276,6 +1354,12 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" +[[package]] +name = "humantime" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a3a5bfb195931eeb336b2a7b4d761daec841b97f947d34394601737a7bba5e4" + [[package]] name = "hyper" version = "0.14.30" @@ -1361,6 +1445,12 @@ version = "2.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f518f335dce6725a761382244631d86cf0ccb2863413590b31338feb467f9c3" +[[package]] +name = "is_terminal_polyfill" +version = "1.70.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf" + [[package]] name = "itoa" version = "1.0.11" @@ -2172,9 +2262,11 @@ dependencies = [ "actix-utils", "actix-web", "aws-config", + "aws-credential-types", "aws-sdk-s3", "base64 0.22.1", "dotenvy", + "env_logger", "hmac", "refinery", "reqwest", @@ -2539,6 +2631,12 @@ version = "2.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + [[package]] name = "uuid" version = "1.10.0" diff --git a/rust-backend/Cargo.toml b/rust-backend/Cargo.toml index 0a1bf3f..606ac55 100644 --- a/rust-backend/Cargo.toml +++ b/rust-backend/Cargo.toml @@ -4,6 +4,7 @@ version = "0.1.0" edition = "2021" [dependencies] +env_logger = "0.11" base64 = "0.22.1" sha2 = "0.10.8" hmac = "0.12.1" @@ -18,4 +19,4 @@ tokio = { version = "1", features = ["full"] } refinery = { version = "0.8.14", features = ["rusqlite"] } rusqlite = { version = "0.31", features = ["bundled"] } reqwest = { version = "0.11.16", features = ["blocking", "json"] } - +aws-credential-types = { version = "1.2.1", features = ["hardcoded-credentials"] } diff --git a/rust-backend/migrations/V5__add_image_id_to_reviews_table.sql b/rust-backend/migrations/V5__add_image_id_to_reviews_table.sql new file mode 100644 index 0000000..2b2190b --- /dev/null +++ b/rust-backend/migrations/V5__add_image_id_to_reviews_table.sql @@ -0,0 +1 @@ +ALTER TABLE reviews ADD COLUMN image_id INT; diff --git a/rust-backend/src/main.rs b/rust-backend/src/main.rs index 84650dd..612bceb 100644 --- a/rust-backend/src/main.rs +++ b/rust-backend/src/main.rs @@ -8,7 +8,10 @@ use models::{Favorite, Review, Image}; use serde::Deserialize; use actix_web::web::Bytes; use aws_sdk_s3::primitives::ByteStream; +use aws_sdk_s3::config::Region; use rusqlite::types::Null; +use env_logger; +use aws_sdk_s3::config::Credentials; mod embedded { use refinery::embed_migrations; @@ -152,7 +155,7 @@ async fn create_review(auth: AuthorizedUser, data: web::Data, input: we let image_id = match input.image_id { Some(image_id) => image_id.to_string(), None => "NULL".to_string(), - } + }; match db.execute( "INSERT INTO reviews ( @@ -237,7 +240,14 @@ struct CreateImageQuery { async fn create_image(auth: AuthorizedUser, data: web::Data, bytes: Bytes, query: web::Query) -> impl Responder { let db = data.database.lock().unwrap(); let config = aws_config::load_from_env().await; - let client = aws_sdk_s3::Client::new(&config); + + println!("{:?}", config); + + let s3_config = aws_sdk_s3::config::Builder::from(&config) + .force_path_style(true) + .build(); + + let client = aws_sdk_s3::Client::from_conf(s3_config); let bucket_name = std::env::var("R2_BUCKET_NAME").expect("R2_BUCKET_NAME must be provided"); let bucket_url = std::env::var("R2_BUCKET_URL").expect("R2_BUCKET_URL must be provided"); @@ -250,13 +260,14 @@ async fn create_image(auth: AuthorizedUser, data: web::Data, bytes: Byt .await; if response.is_err() { + println!("{:?}", response.unwrap_err()); return HttpResponse::InternalServerError().finish(); } let image_url = format!("{}/{}/{}", bucket_url, bucket_name, query.file_name); match db.execute( - "INSERT INTO images (user_id, image_url)", + "INSERT INTO images (user_id, image_url) VALUES (:user_id, :image_url)", &[ (":user_id", &auth.user_id), (":image_url", &image_url), @@ -273,6 +284,8 @@ async fn create_image(auth: AuthorizedUser, data: web::Data, bytes: Byt #[actix_web::main] async fn main() -> std::io::Result<()> { + env_logger::init(); + let _ = dotenvy::dotenv(); let port = std::env::var("RUST_BACKEND_PORT") @@ -306,6 +319,7 @@ async fn main() -> std::io::Result<()> { .service(reviews) .service(create_review) .service(delete_review) + .service(create_image) }) .bind(("0.0.0.0", port))? .run()