eBay for krypto. https://kryptori.lu1.sh
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}