juiceback/routes/
noscript.rs

1//! HTML endpoints that work without JavaScript.
2//!
3//! Renders server-side HTML for upload confirmation, file listing, and
4//! reporting so the site works even when JS is disabled.
5
6use 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
19/// Minimal HTML escaping for untrusted values rendered into pages.
20pub 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("&lt;"),
26            '>' => out.push_str("&gt;"),
27            '"' => out.push_str("&quot;"),
28            '\'' => out.push_str("&#39;"),
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/// GET /report - native HTML report form (no JS required).
72#[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> &middot; <a href=\"/files.html\">Files</a></p>"
106    );
107    page("Report - Juicebox", &body)
108}
109
110/// POST /report - accept a form report submission and confirm with HTML.
111#[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/// POST /api/feedback - accept a feedback form submission and confirm with HTML.
155#[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
186/// Render an HTML upload confirmation (used by the no-JS upload flow).
187pub 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> &middot; <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
210/// True when the request prefers an HTML response (no-JS form submission).
211pub 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}