1use axum::{
7 extract::State,
8 http::header,
9 response::{Html, IntoResponse, Response},
10 Form,
11};
12use serde::Deserialize;
13use std::sync::Arc;
14use utoipa::ToSchema;
15
16use crate::error::AppError;
17use crate::state::AppState;
18
19pub fn escape(input: &str) -> String {
21 let mut out = String::with_capacity(input.len());
22 for c in input.chars() {
23 match c {
24 '&' => out.push_str("&"),
25 '<' => out.push_str("<"),
26 '>' => out.push_str(">"),
27 '"' => out.push_str("""),
28 '\'' => out.push_str("'"),
29 _ => out.push(c),
30 }
31 }
32 out
33}
34
35pub(crate) fn page(title: &str, body: &str) -> Html<String> {
36 Html(format!(
37 "<!doctype html><html lang=\"en\"><head><meta charset=\"utf-8\">\
38 <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\
39 <title>{title}</title></head><body class=\"noscript-page\">\
40 <main class=\"upload-card\">{body}</main></body></html>",
41 title = escape(title),
42 body = body
43 ))
44}
45
46fn fmt_bytes(bytes: i64) -> String {
47 const UNITS: [&str; 5] = ["B", "KB", "MB", "GB", "TB"];
48 let mut size = bytes as f64;
49 let mut unit = 0;
50 while size >= 1024.0 && unit < UNITS.len() - 1 {
51 size /= 1024.0;
52 unit += 1;
53 }
54 if unit == 0 {
55 format!("{} {}", bytes, UNITS[unit])
56 } else {
57 format!("{:.1} {}", size, UNITS[unit])
58 }
59}
60
61#[derive(Deserialize, ToSchema)]
62pub struct ReportForm {
63 #[serde(default)]
64 pub file_url: String,
65 #[serde(default)]
66 pub reason: String,
67 #[serde(default)]
68 pub details: String,
69}
70
71#[utoipa::path(
73 get,
74 path = "/api/report",
75 responses(
76 (status = 200, description = "HTML report form", content_type = "text/html"),
77 ),
78 tag = "General",
79)]
80pub async fn report_form_handler() -> Html<String> {
81 let reasons = [
82 ("malware", "Malware or Virus"),
83 ("illegal", "Illegal Content"),
84 ("copyright", "Copyright Infringement"),
85 ("spam", "Spam or Abuse"),
86 ("inappropriate", "Inappropriate Content"),
87 ("other", "Other"),
88 ];
89 let mut options = String::from("<option value=\"\">Select a reason...</option>");
90 for (value, label) in reasons {
91 options.push_str(&format!("<option value=\"{value}\">{label}</option>"));
92 }
93
94 let body = format!(
95 "<h1>Report Content</h1>\
96 <form method=\"POST\" action=\"/api/report\" class=\"report-form\">\
97 <label class=\"form-label\">File URL <span class=\"required\">*</span>\
98 <input class=\"form-input\" type=\"url\" name=\"file_url\" required \
99 placeholder=\"https://example.com/f/abc123\"></label>\
100 <label class=\"form-label\">Reason <span class=\"required\">*</span>\
101 <select class=\"form-select\" name=\"reason\" required>{options}</select></label>\
102 <label class=\"form-label\">Additional Details (Optional)\
103 <textarea class=\"form-textarea\" name=\"details\" rows=\"4\" maxlength=\"500\"></textarea></label>\
104 <button type=\"submit\" class=\"report-btn report-btn--primary\">Submit Report</button></form>\
105 <p><a href=\"/index.html\">Back to upload</a> · <a href=\"/files.html\">Files</a></p>"
106 );
107 page("Report - Juicebox", &body)
108}
109
110#[utoipa::path(
112 post,
113 path = "/api/report",
114 request_body(content_type = "application/x-www-form-urlencoded", content = inline(ReportForm), description = "Report form fields"),
115 responses(
116 (status = 200, description = "HTML confirmation", content_type = "text/html"),
117 ),
118 tag = "General",
119)]
120pub async fn report_submit_handler(
121 State(_state): State<Arc<AppState>>,
122 Form(form): Form<ReportForm>,
123) -> Result<Html<String>, AppError> {
124 let file_url = form.file_url.trim();
125 if file_url.is_empty() || form.reason.trim().is_empty() {
126 return Err(AppError::InvalidMultipart(
127 "file_url and reason are required".into(),
128 ));
129 }
130
131 tracing::info!(
132 "report: url={} reason={} details_len={}",
133 file_url,
134 form.reason,
135 form.details.len()
136 );
137
138 let body = format!(
139 "<h1>Report received</h1><p>Thanks. We logged your report for <code>{url}</code> \
140 (reason: <strong>{reason}</strong>).</p>\
141 <p><a href=\"/index.html\">Back to upload</a></p>",
142 url = escape(file_url),
143 reason = escape(form.reason.trim()),
144 );
145 Ok(page("Report received - Juicebox", &body))
146}
147
148#[derive(Deserialize, ToSchema)]
149pub struct FeedbackForm {
150 #[serde(default)]
151 pub message: String,
152}
153
154#[utoipa::path(
156 post,
157 path = "/api/feedback",
158 request_body(content_type = "application/x-www-form-urlencoded", content = inline(FeedbackForm), description = "Feedback form fields"),
159 responses(
160 (status = 200, description = "HTML confirmation", content_type = "text/html"),
161 ),
162 tag = "General",
163)]
164pub async fn feedback_submit_handler(
165 State(_state): State<Arc<AppState>>,
166 Form(form): Form<FeedbackForm>,
167) -> Result<Html<String>, AppError> {
168 let message = form.message.trim();
169 if message.is_empty() {
170 return Err(AppError::InvalidMultipart("message is required".into()));
171 }
172
173 tracing::info!("feedback: len={}", message.len());
174
175 let body = "<h1>Feedback received</h1><p>Thanks for the feedback!</p>\
176 <p><a href=\"/index.html\">Back to upload</a></p>";
177 Ok(page("Feedback received - Juicebox", body))
178}
179
180#[derive(Deserialize)]
181pub struct DeleteForm {
182 #[serde(default)]
183 pub token: String,
184}
185
186pub fn upload_confirmation_html(
188 public_url: &str,
189 filename: &str,
190 delete_token: &str,
191 file_id: &str,
192) -> Response {
193 let body = format!(
194 "<h1>Upload complete</h1>\
195 <p>File <strong>{name}</strong> is available at:</p>\
196 <p><a href=\"{url}\">{url}</a></p>\
197 <p>Delete token (keep this safe):</p><pre>{token}</pre>\
198 <form method=\"POST\" action=\"/file/{id}/delete\">\
199 <input type=\"hidden\" name=\"token\" value=\"{token}\">\
200 <button type=\"submit\">Delete now</button></form>\
201 <p><a href=\"/index.html\">Upload another</a> · <a href=\"/files.html\">Files</a></p>",
202 name = escape(filename),
203 url = escape(public_url),
204 token = escape(delete_token),
205 id = escape(file_id),
206 );
207 page("Upload complete - Juicebox", &body).into_response()
208}
209
210pub fn wants_html(headers: &axum::http::HeaderMap) -> bool {
212 headers
213 .get(header::ACCEPT)
214 .and_then(|v| v.to_str().ok())
215 .map(|a| a.contains("text/html"))
216 .unwrap_or(false)
217}