eBay for krypto. https://kryptori.lu1.sh
at master 456 lines 13 kB view raw
1use axum::{routing::get, response::Html, Form, response::Redirect, Router, extract::{Query, State}, http::StatusCode}; 2use sqlx::sqlite::SqlitePoolOptions; 3use sqlx::SqlitePool; 4use std::sync::Arc; 5use std::fs; 6use uuid::Uuid; 7use std::env; 8use lettre::{Message, SmtpTransport, Transport, transport::smtp::authentication::Credentials}; 9use serde::Deserialize; 10use askama::Template; 11 12#[derive(Clone)] 13struct AppState { 14 db: SqlitePool, 15} 16 17pub async fn create_tables_if_not_exist(pool: &SqlitePool) -> Result<(), sqlx::Error> { 18 let create_advertisement_table_sql = r#" 19 CREATE TABLE IF NOT EXISTS Advertisement ( 20 id INTEGER PRIMARY KEY AUTOINCREMENT, 21 created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, 22 updated_at TEXT, 23 title TEXT NOT NULL, 24 description TEXT NOT NULL, 25 owner_email TEXT NOT NULL, 26 active INT NOT NULL DEFAULT 0, 27 owner_token TEXT NOT NULL UNIQUE 28 ); 29 "#; 30 31 sqlx::query(create_advertisement_table_sql) 32 .execute(pool) 33 .await?; 34 35 Ok(()) 36} 37 38#[derive(Deserialize)] 39struct CreateAdForm { 40 title: String, 41 description: String, 42 owner_email: String, 43} 44 45async fn create_ad( 46 State(state): State<Arc<AppState>>, 47 Form(input): Form<CreateAdForm>, 48) -> Result<Redirect, (axum::http::StatusCode, String)> { 49 let title = input.title.trim().chars().take(1000).collect::<String>(); 50 let description = input.description.trim().chars().take(1000).collect::<String>(); 51 let owner_email = input.owner_email.trim().chars().take(1000).collect::<String>(); 52 53 if title.is_empty() || description.is_empty() || owner_email.is_empty() { 54 return Err((axum::http::StatusCode::BAD_REQUEST, "Play nice!".into())); 55 } 56 57 let token = Uuid::new_v4().to_string(); 58 59 let insert_result = sqlx::query!( 60 r#" 61 INSERT INTO Advertisement (title, description, owner_email, owner_token) 62 VALUES (?, ?, ?, ?) 63 "#, 64 title, 65 description, 66 owner_email, 67 token, 68 ) 69 .execute(&state.db) 70 .await; 71 72 if let Err(e) = insert_result { 73 eprintln!("DB insert error: {e}"); 74 return Err((axum::http::StatusCode::INTERNAL_SERVER_ERROR, "Could not insert ad.".into())); 75 } 76 77 if let Err(e) = send_email(&owner_email, &title, &description, &token).await { 78 eprintln!("Email error: {e}"); 79 } 80 81 Ok(Redirect::to("/")) 82} 83 84#[derive(Deserialize)] 85struct SendMessageForm { 86 ad_id: i64, 87 user_email: String, 88 message: String, 89} 90 91#[derive(sqlx::FromRow)] 92struct Advertisement { 93 title: String, 94 description: String, 95 owner_email: String, 96} 97 98async fn send_message( 99 State(state): State<Arc<AppState>>, 100 Form(form): Form<SendMessageForm>, 101) -> Result<Redirect, StatusCode> { 102 let ad = sqlx::query_as!( 103 Advertisement, 104 r#" 105 SELECT title, description, owner_email 106 FROM Advertisement 107 WHERE id = ? 108 "#, 109 form.ad_id 110 ) 111 .fetch_optional(&state.db) 112 .await 113 .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; 114 115 let Some(ad) = ad else { 116 return Err(StatusCode::NOT_FOUND); 117 }; 118 119 if let Err(e) = send_interested_email( 120 &ad.owner_email, 121 &ad.title, 122 &ad.description, 123 &form.message, 124 &form.user_email 125 ).await { 126 eprintln!("Email error: {e}"); 127 } 128 129 Ok(Redirect::to("/?message-sent=true")) 130} 131 132async fn send_email( 133 to: &str, 134 title: &str, 135 description: &str, 136 token: &str, 137) -> Result<(), Box<dyn std::error::Error>> { 138 let email = env::var("EMAIL")?; 139 let password = env::var("PASSWORD")?; 140 141 let body = format!( 142 "Thanks for making a new ad!\n\ 143 Title: {title}\n\ 144 Description: {description}\n\ 145 Before it goes live, you must go activate the ad by loading the webpage:\n\ 146 kryptori.lu1.sh/manage-ad?token={token}\n\n\ 147 The token for this ad is: {token}\n\ 148 and that's what you can use to update and delete the ad.", 149 ); 150 151 let msg = Message::builder() 152 .from(email.parse()?) 153 .to(to.parse()?) 154 .subject(format!("New ad created: {title}")) 155 .body(body)?; 156 157 let creds = Credentials::new(email.clone(), password); 158 159 let mailer = SmtpTransport::starttls_relay("smtp.gmail.com")? 160 .credentials(creds) 161 .build(); 162 163 mailer.send(&msg)?; 164 Ok(()) 165} 166 167async fn send_interested_email( 168 to: &str, 169 title: &str, 170 description: &str, 171 user_message: &str, 172 user_email: &str, 173) -> Result<(), Box<dyn std::error::Error>> { 174 let email = env::var("EMAIL")?; 175 let password = env::var("PASSWORD")?; 176 177 let body = format!( 178 "Someone is interested in this ad!\n\ 179 Title: {title}\n\ 180 Description: {description}\n\n\ 181 The user says:\n\n\ 182 {user_message}\n\n\ 183 The user's email is: {user_email}\n\ 184 and now it's up to you to respond to them using their email address.\n\ 185 Good luck!", 186 ); 187 188 let msg = Message::builder() 189 .from(email.parse()?) 190 .to(to.parse()?) 191 .subject(format!("Someone interested: {title}")) 192 .body(body)?; 193 194 let creds = Credentials::new(email.clone(), password); 195 196 let mailer = SmtpTransport::starttls_relay("smtp.gmail.com")? 197 .credentials(creds) 198 .build(); 199 200 mailer.send(&msg)?; 201 Ok(()) 202} 203 204async fn index(State(state): State<Arc<AppState>>) -> Html<String> { 205 let template = fs::read_to_string("pages/index.html").expect("Failed to read template"); 206 207 let ads_html = fetch_advertisements_html(&state.db).await.unwrap_or_else(|e| { 208 eprintln!("DB error: {e}"); 209 "<p>Could not load ads.</p>".to_string() 210 }); 211 212 let rendered = template.replace("{{ ads }}", &ads_html); 213 214 Html(rendered) 215} 216 217async fn fetch_advertisements_html(pool: &SqlitePool) -> Result<String, sqlx::Error> { 218 let ads = sqlx::query!( 219 r#" 220 SELECT id, title, description, created_at, updated_at 221 FROM Advertisement 222 WHERE active = 1 223 ORDER BY created_at DESC 224 "# 225 ) 226 .fetch_all(pool) 227 .await?; 228 229 let mut html = String::from("<div>"); 230 231 for ad in ads { 232 let created = &ad.created_at; 233 let updated_str = match &ad.updated_at { 234 Some(updated) => format!(", updated on {}", updated), 235 None => "-".to_string(), 236 }; 237 238 html.push_str(&format!( 239 r#"<div class="ad-container"> 240 <details> 241 <summary>{title}</summary> 242 <i>Created on {created}{updated}</i> 243 <p style="margin-left: 50px;">{desc}</p> 244 <form action="/send-message" method="post"> 245 <span>Get in touch with the poster!</span> 246 <input type="hidden" id="ad_id" name="ad_id" value="{id}" required> 247 <div style="margin-top: 4px;"> 248 <label for="user_email"> 249 <b>Your email</b><br /> 250 This won't be stored at all...<br /> 251 Check the <a target="_blank" href="https://tangled.sh/@lewis.moe/kryptori">source code</a>... 252 </label><br /> 253 <input type="email" id="user_email" name="user_email" placeholder="abc@xyz.com" required> 254 </div> 255 <div class="input-container"> 256 <label for="message"><b>Message</b></label><br /> 257 <textarea id="message" name="message" style="width: 100%; height: 6rem;" required></textarea> 258 </div> 259 <p>Remember, this will just send an email to the poster... etc</p> 260 <div class="input-container"> 261 <button type="submit">Send Message</button> 262 </div> 263 </form> 264 </details> 265 </div>"#, 266 title = ad.title, 267 created = created, 268 updated = updated_str, 269 desc = ad.description, 270 id = ad.id, 271 )); 272 } 273 274 html.push_str("</div>"); 275 Ok(html) 276} 277 278async fn fetch_advertisement_by_token( 279 pool: &SqlitePool, 280 token: &str 281) -> Result<Option<(String, String, String, Option<String>, String)>, sqlx::Error> { 282 let row = sqlx::query!( 283 r#" 284 SELECT title, description, created_at, updated_at, owner_email 285 FROM Advertisement 286 WHERE owner_token = ? 287 LIMIT 1 288 "#, 289 token 290 ) 291 .fetch_optional(pool) 292 .await?; 293 294 if let Some(ad) = row { 295 sqlx::query!( 296 r#" 297 UPDATE Advertisement 298 SET active = 1 299 WHERE owner_token = ? 300 "#, 301 token 302 ) 303 .execute(pool) 304 .await?; 305 306 Ok(Some(( 307 ad.title, 308 ad.description, 309 ad.created_at, 310 ad.updated_at, 311 ad.owner_email, 312 ))) 313 } else { 314 Ok(None) 315 } 316} 317 318#[derive(Deserialize)] 319struct ManageAdQuery { 320 token: String, 321} 322 323#[derive(Template)] 324#[template(path = "manage_ad.html")] 325struct ManageAdTemplate { 326 title: String, 327 description: String, 328 created_at: String, 329 updated_at: String, 330 owner_email: String, 331 token: String, 332} 333 334fn render_updated_at(updated_at: Option<String>) -> String { 335 updated_at.unwrap_or_else(|| "".to_string()) 336} 337 338async fn manage_ad( 339 State(state): State<Arc<AppState>>, 340 Query(query): Query<ManageAdQuery>, 341) -> Result<Html<String>, Redirect> { 342 let token = &query.token; 343 344 match fetch_advertisement_by_token(&state.db, token).await { 345 Ok(Some((title, description, created_at, updated_at, owner_email))) => { 346 let updated_at_str = render_updated_at(updated_at); 347 let template = ManageAdTemplate { 348 title, 349 description, 350 created_at, 351 updated_at: updated_at_str, 352 owner_email, 353 token: token.to_string(), 354 }; 355 356 let rendered_page = template.render().unwrap(); 357 358 Ok(Html(rendered_page)) 359 } 360 Ok(None) => { 361 Err(Redirect::to("/")) 362 } 363 Err(_) => { 364 Err(Redirect::to("/")) 365 } 366 } 367} 368 369#[derive(Deserialize)] 370struct UpdateAdForm { 371 title: String, 372 description: String, 373 token: String, 374} 375 376#[derive(Deserialize)] 377struct DeleteAdForm { 378 token: String, 379} 380 381async fn update_ad( 382 State(state): State<Arc<AppState>>, 383 Form(form): Form<UpdateAdForm>, 384) -> Result<Redirect, StatusCode> { 385 let title = form.title.trim().to_string(); 386 let description = form.description.trim().to_string(); 387 let token = form.token.trim(); 388 389 if title.is_empty() || description.is_empty() { 390 return Err(StatusCode::BAD_REQUEST); 391 } 392 393 let row = sqlx::query!("SELECT * FROM Advertisement WHERE owner_token = ?", token) 394 .fetch_optional(&state.db) 395 .await 396 .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; 397 398 if row.is_none() { 399 return Err(StatusCode::NOT_FOUND); 400 } 401 402 sqlx::query!( 403 "UPDATE Advertisement SET title = ?, description = ?, updated_at = CURRENT_TIMESTAMP WHERE owner_token = ?", 404 title, description, token 405 ) 406 .execute(&state.db) 407 .await 408 .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; 409 410 Ok(Redirect::to(format!("/manage-ad?token={}&success=true", token).as_str())) 411} 412 413async fn delete_ad( 414 State(state): State<Arc<AppState>>, 415 Form(form): Form<DeleteAdForm>, 416) -> Result<Redirect, StatusCode> { 417 let token = form.token.trim(); 418 419 let rows_deleted = sqlx::query!("DELETE FROM Advertisement WHERE owner_token = ?", token) 420 .execute(&state.db) 421 .await 422 .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; 423 424 if rows_deleted.rows_affected() == 0 { 425 return Err(StatusCode::NOT_FOUND); 426 } 427 428 Ok(Redirect::to("/?delete-success=true")) 429} 430 431#[tokio::main] 432async fn main() -> Result<(), sqlx::Error> { 433 let database_url = env::var("DATABASE_URL").unwrap_or("sqlite://kryptori.db".to_string()); 434 let pool = SqlitePoolOptions::new() 435 .connect(&database_url) 436 .await?; 437 438 create_tables_if_not_exist(&pool).await?; 439 440 let state = Arc::new(AppState { db: pool }); 441 442 let app = Router::new() 443 .route("/", get(index)) 444 .route("/create-ad", axum::routing::post(create_ad)) 445 .route("/manage-ad", axum::routing::get(manage_ad)) 446 .route("/update-ad", axum::routing::post(update_ad)) 447 .route("/delete-ad", axum::routing::post(delete_ad)) 448 .route("/send-message", axum::routing::post(send_message)) 449 .with_state(state); 450 451 let listener = tokio::net::TcpListener::bind("127.0.0.1:8000").await.unwrap(); 452 println!("Listening on {}", listener.local_addr().unwrap()); 453 axum::serve(listener, app).await.unwrap(); 454 455 Ok(()) 456}