From 2f70967834ca087ac288cc228692627131c84b0e Mon Sep 17 00:00:00 2001 From: G2-Games Date: Mon, 28 Oct 2024 02:45:43 -0500 Subject: [PATCH] Add API documentation page --- src/endpoints.rs | 27 +++++++++++++-- src/main.rs | 12 ++----- src/pages.rs | 87 ++++++++++++++++++++++++++++++++++++++++++------ src/settings.rs | 2 ++ web/main.css | 18 +++++++++- web/request.js | 37 ++++++++++---------- 6 files changed, 142 insertions(+), 41 deletions(-) diff --git a/src/endpoints.rs b/src/endpoints.rs index cf2fdab..08424b2 100644 --- a/src/endpoints.rs +++ b/src/endpoints.rs @@ -42,7 +42,6 @@ pub struct ServerInfo { allowed_durations: Vec, } -/// Look up the [`Mmid`] of a file to find it. #[get("/f/")] pub async fn lookup_mmid(db: &State>>, mmid: &str) -> Option { let mmid: Mmid = mmid.try_into().ok()?; @@ -54,9 +53,31 @@ pub async fn lookup_mmid(db: &State>>, mmid: &str) -> Optio )))) } -/// Look up the [`Mmid`] of a file to find it, along with the name of the file +#[get("/f/?noredir")] +pub async fn lookup_mmid_noredir( + db: &State>>, + settings: &State, + mmid: &str, +) -> Option<(ContentType, File)> { + let mmid: Mmid = mmid.try_into().ok()?; + + let entry = db.read().unwrap().get(&mmid).cloned()?; + + let file = File::open(settings.file_dir.join(entry.hash().to_string())) + .await + .ok()?; + + dbg!(entry.extension()); + + Some(( + ContentType::from_extension(entry.extension()).unwrap_or(ContentType::Binary), + file, + )) +} + #[get("/f//")] -pub async fn lookup_mmid_name(db: &State>>, +pub async fn lookup_mmid_name( + db: &State>>, settings: &State, mmid: &str, name: &str, diff --git a/src/main.rs b/src/main.rs index eedc050..444052c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -12,12 +12,12 @@ use std::{ use chrono::{DateTime, TimeDelta, Utc}; use database::{clean_loop, Database, Mmid, MochiFile}; -use endpoints::{lookup_mmid, lookup_mmid_name, server_info}; +use endpoints::{lookup_mmid, lookup_mmid_name, lookup_mmid_noredir, server_info}; use log::info; use maud::{html, Markup, PreEscaped}; use pages::{api_info, footer, head}; use rocket::{ - data::{Limits, ToByteUnit}, form::Form, fs::{FileServer, Options, TempFile}, get, http::ContentType, post, response::content::{RawCss, RawJavaScript}, routes, serde::{json::Json, Serialize}, tokio, Config, FromForm, State + data::{Limits, ToByteUnit}, form::Form, fs::TempFile, get, http::ContentType, post, response::content::{RawCss, RawJavaScript}, routes, serde::{json::Json, Serialize}, tokio, Config, FromForm, State }; use settings::Settings; use strings::{parse_time_string, to_pretty_time}; @@ -235,16 +235,10 @@ async fn main() { server_info, favicon, lookup_mmid, + lookup_mmid_noredir, lookup_mmid_name, ], ) - .mount( - config.server.root_path.clone() + "/files", - FileServer::new( - config.file_dir.clone(), - Options::Missing | Options::NormalizeDirs, - ), - ) .manage(database) .manage(config) .configure(rocket_config) diff --git a/src/pages.rs b/src/pages.rs index 87c6d5a..985906f 100644 --- a/src/pages.rs +++ b/src/pages.rs @@ -1,5 +1,7 @@ use maud::{html, Markup, DOCTYPE}; -use rocket::get; +use rocket::{get, State}; + +use crate::settings::Settings; pub fn head(page_title: &str) -> Markup { html! { @@ -18,14 +20,16 @@ pub fn footer() -> Markup { p {a href="/" {"Home"}} p {a href="https://github.com/G2-Games/confetti-box" {"Source"}} p {a href="https://g2games.dev/" {"My Website"}} - p {a href="api_info" {"API Info"}} + p {a href="api" {"API Info"}} p {a href="https://ko-fi.com/g2_games" {"Donate"}} } } } -#[get("/api_info")] -pub fn api_info() -> Markup { +#[get("/api")] +pub fn api_info(settings: &State) -> Markup { + let domain = &settings.server.domain; + let root = &settings.server.root_path; html! { (head("Confetti-Box | API")) @@ -35,14 +39,77 @@ pub fn api_info() -> Markup { div style="text-align: left;" { p { - """ - The API for this service can be used by POST ing a form - with an expiration time and file to upload to the upload - endpoint: - """ + "Confetti-Box is designed to be simple to access using its + API. All endpoints are accessed following " + code{"https://"(domain) (root)} ". All responses are encoded + in JSON. MMIDs are a unique identifier for a file returned by + the server after a successful " code{"/upload"} " request." + } + p { + "The following endpoints are supported:" } - pre { "/upload POST duration=\"6h\" fileUpload=(file data)" } + h2 { code {"/upload"} } + pre { r#"POST duration=String fileUpload=Bytes -> JSON"# } + p { + "To upload files, " code{"POST"} " a multipart form + containing the fields " code{"duration"} " and " + code{"fileData"} " to this endpoint. " code{"duration"} + " MUST be a string formatted like " code{"1H"}", where + the number MUST be a valid number and the letter MUST be + one of " b{"S"} "(econd), " b{"M"}"(inute), " b{"H"}"(our), " + b{"D"}"(ay). The " code{"/info"} " endpoint returns valid + durations and maximum file sizes." + } + p { + "Example successful response:" + } + pre { + "{\n\t\"status\": true,\n\t\"response\": \"\",\n\t\"name\": \"1600-1200.jpg\",\n\t\"mmid\": \"xNLF6ogx\",\n\t\"hash\": \"1f12137f2c263d9e6d686e90c687a55d46d064fe6eeda7e4c39158d20ce1f071\",\n\t\"expires\": \"2024-10-28T11:59:25.024373438Z\"\n}" + } + p {"Example failure response:"} + pre { + "{\n\t\"status\": false,\n\t\"response\": \"Duration invalid\",\n}" + } + + hr; + h2 { code {"/info"} } + pre { r#"GET -> JSON"# } + p { + "Returns the capabilities of the server." + } + p {"Example response:"} + pre { + "{\n\t\"max_filesize\": 5000000000,\n\t\"max_duration\": 259200,\n\t\"default_duration\": 21600,\n\t\"allowed_durations\": [\n\t\t3600,\n\t\t21600,\n\t\t86400,\n\t\t172800\n\t]\n}" + } + + hr; + h2 { code {"/f/"} } + pre { r#"GET mmid=MMID -> Redirect"# } + p { + "By default issues a redirect to the full URL for a file. This + behavior can be modified by appending " code{"?noredir"} " to + the end of this request, like " code{"/f/?noredir"} ", + in which case it behaves just like " code{"/f//"} + } + p {"Example default response:"} + pre {"303: /f/xNLF6ogx/1600-1200.jpg"} + + p {"Example modified response:"} + pre {""} + + hr; + h2 { code {"/f//"} } + pre { r#"GET mmid=MMID -> Redirect"# } + p { + "Returns the contents of the file corresponding to the + requested MMID, but with the corresponding filename so as + to preserve it for downloads. Mostly for use by browsers." + } + p {"Example response:"} + pre { + "" + } } hr; diff --git a/src/settings.rs b/src/settings.rs index 33eda2b..6bfd078 100644 --- a/src/settings.rs +++ b/src/settings.rs @@ -95,6 +95,7 @@ impl Settings { #[derive(Deserialize, Serialize, Debug)] #[serde(crate = "rocket::serde")] pub struct ServerSettings { + pub domain: String, pub address: String, pub port: u16, @@ -105,6 +106,7 @@ pub struct ServerSettings { impl Default for ServerSettings { fn default() -> Self { Self { + domain: "example.com".into(), address: "127.0.0.1".into(), root_path: "/".into(), port: 8950, diff --git a/web/main.css b/web/main.css index 3e41a99..ffe7820 100644 --- a/web/main.css +++ b/web/main.css @@ -26,6 +26,10 @@ h1 { font-weight: bolder; } +p { + line-height: 1.5; +} + button p { margin: 0; } @@ -74,9 +78,21 @@ button.main_file_upload { pre { color: white; - background-color: black; + background-color: #161b22; font-size: 11pt; padding: 10px; + overflow: scroll; + tab-size: 4; +} + +p code { + background-color: lightgray; + font-size: 12pt; + padding: 2px; +} + +h2 code { + font-size: 15pt; } #durationBox { diff --git a/web/request.js b/web/request.js index 32a47e9..5f7e4cb 100644 --- a/web/request.js +++ b/web/request.js @@ -1,22 +1,24 @@ +/*jshint esversion: 11 */ + const TOO_LARGE_TEXT = "Too large!"; const ZERO_TEXT = "File is blank!"; const ERROR_TEXT = "Error!"; async function formSubmit() { const form = document.getElementById("uploadForm"); - const files = form.elements["fileUpload"].files; - const duration = form.elements["duration"].value; - const maxSize = form.elements["fileUpload"].dataset.maxFilesize; + const files = form.elements.fileUpload.files; + const duration = form.elements.duration.value; + const maxSize = form.elements.fileUpload.dataset.maxFilesize; await fileSend(files, duration, maxSize); // Reset the form file data since we've successfully submitted it - form.elements["fileUpload"].value = ""; + form.elements.fileUpload.value = ""; } async function dragDropSubmit(evt) { const form = document.getElementById("uploadForm"); - const duration = form.elements["duration"].value; + const duration = form.elements.duration.value; const files = getDroppedFiles(evt); @@ -51,11 +53,11 @@ async function fileSend(files, duration, maxSize) { if (file.size > maxSize) { makeErrored(progressBar, progressText, linkRow, TOO_LARGE_TEXT); console.error("Provided file is too large", file.size, "bytes; max", maxSize, "bytes"); - continue + continue; } else if (file.size == 0) { makeErrored(progressBar, progressText, linkRow, ZERO_TEXT); console.error("Provided file has 0 bytes"); - continue + continue; } const request = new XMLHttpRequest(); @@ -63,11 +65,11 @@ async function fileSend(files, duration, maxSize) { // Set up event listeners request.upload.addEventListener('progress', - (p) => {uploadProgress(p, progressBar, progressText, linkRow)}, false); + (p) => {uploadProgress(p, progressBar, progressText, linkRow);}, false); request.addEventListener('load', - (c) => {uploadComplete(c, progressBar, progressText, linkRow)}, false); + (c) => {uploadComplete(c, progressBar, progressText, linkRow);}, false); request.addEventListener('error', - (e) => {networkErrorHandler(e, progressBar, progressText, linkRow)}, false); + (e) => {networkErrorHandler(e, progressBar, progressText, linkRow);}, false); // Create and send FormData try { @@ -90,7 +92,7 @@ function makeErrored(progressBar, progressText, linkRow, errorMessage) { function makeFinished(progressBar, progressText, linkRow, response) { progressText.textContent = ""; - const name = encodeURIComponent(response.name); + const _name = encodeURIComponent(response.name); const link = progressText.appendChild(document.createElement("a")); link.textContent = response.mmid; link.href = "/f/" + response.mmid; @@ -102,16 +104,16 @@ function makeFinished(progressBar, progressText, linkRow, response) { button.addEventListener('click', function(_e) { const mmid = response.mmid; if (buttonTimeout) { - clearTimeout(buttonTimeout) + clearTimeout(buttonTimeout); } navigator.clipboard.writeText( window.location.protocol + "//" + window.location.host + "/f/" + mmid - ) + ); button.textContent = "✅"; buttonTimeout = setTimeout(function() { button.textContent = "📝"; }, 750); - }) + }); progressBar.style.display = "none"; linkRow.style.background = "#a4ffbb"; @@ -177,10 +179,9 @@ async function initEverything() { for (const b of durationButtons) { b.addEventListener("click", function (_e) { if (this.classList.contains("selected")) { - return + return; } - document.getElementById("uploadForm").elements["duration"].value - = this.dataset.durationSeconds + "s"; + document.getElementById("uploadForm").elements.duration.value = this.dataset.durationSeconds + "s"; let selected = this.parentNode.getElementsByClassName("selected"); selected[0].classList.remove("selected"); this.classList.add("selected"); @@ -192,7 +193,7 @@ async function initEverything() { document.addEventListener("DOMContentLoaded", function(_event) { const form = document.getElementById("uploadForm"); form.addEventListener("submit", formSubmit); - fileButton = document.getElementById("fileButton"); + let fileButton = document.getElementById("fileButton"); document.addEventListener("drop", (e) => {e.preventDefault();}, false); fileButton.addEventListener("dragover", (e) => {e.preventDefault();}, false);