Something went wrong. Try again.
Paper Mario mod hosting site - AT Protocol appview starhaven.dev
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830//! `/create` to create a mod listing, `/{handle}/{slug}` to view or edit//! one of their listings.
use std::net::SocketAddr;
use axum::body::Bytes;use axum::extract::{ConnectInfo, Form, FromRequestParts, Path, State};use axum::http::header::CONTENT_TYPE;use axum::http::request::Parts;use axum::http::{HeaderMap, StatusCode};use axum::response::{AppendHeaders, Html, IntoResponse, Redirect, Response};use axum::routing::{get, post};use axum::{Json, Router};use chrono::Utc;use maud::{html, Markup};use serde::{Deserialize, Serialize};
use crate::atproto::id::{AtUri, Did, Handle, Rkey};use crate::atproto::lexicon::mod_listing::{Game, MediaItem, Tags};use crate::atproto::lexicon::mod_star::Stars;use crate::atproto::lexicon::mod_version::external_host;use crate::atproto::lexicon::{ActorProfile, Found, ModListing, ModStar, ModVersion, Record};use crate::atproto::pds;use crate::blobs;use crate::credits::{self, Credited, Row};use crate::deliverables;use crate::error::AppError;use crate::form;use crate::homepage;use crate::markdown;use crate::oauth::session::SessionCookie;use crate::page::{icon, Page};use crate::s3::{ deliverable_accept, deliverable_kinds_message, deliverable_name_allowed, deliverable_starts_right, hosts_deliverables, DELIVERABLE_MAGIC_BYTES, MAX_DELIVERABLE_BYTES,};use crate::state::AppState;use crate::stats;
pub fn router() -> Router<AppState> { Router::new() .route("/create", get(handle_get_create).post(handle_post_create)) .route("/create/presign", post(handle_post_create_presign)) .route("/create/media", post(handle_post_create_media)) .route( "/{handle}/{slug}", get(handle_get_mod).delete(handle_delete_mod), ) .route( "/{handle}/{slug}/versions", get(handle_get_versions).post(handle_post_version), ) .route( "/{handle}/{slug}/versions/presign", post(handle_post_presign), ) .route("/{handle}/{slug}/media", post(handle_post_media)) .route("/{handle}/{slug}/star", post(handle_post_star)) .route( "/{handle}/{slug}/settings", get(handle_get_settings).post(handle_post_settings), )}
/// Which of a mod's pages is being looked at, for the tab strip.#[derive(PartialEq, Eq, Clone, Copy)]enum Tab { Description, Versions, Settings,}
impl Tab { const PUBLIC: &[Tab] = &[Tab::Description, Tab::Versions]; const OWNER: &[Tab] = &[Tab::Description, Tab::Versions, Tab::Settings];
/// Appended to the mod's own URL. fn path(self) -> &'static str { match self { Self::Description => "", Self::Versions => "/versions", Self::Settings => "/settings", } }
fn label(self) -> &'static str { match self { Self::Description => "Description", Self::Versions => "Versions", Self::Settings => "Settings", } }}
/// The tab strip.fn mod_tabs(handle: &Handle, listing: &ModListing, active: Tab, is_owner: bool) -> Markup { let base = format!("/{handle}/{}", listing.slug); let tabs = if is_owner { Tab::OWNER } else { Tab::PUBLIC };
html! { nav.mod-tabs aria-label="Mod sections" { @for tab in tabs { a href={ (base) (tab.path()) } hx-get={ (base) (tab.path()) } hx-select="#mod-details" hx-target="#mod-details" hx-swap="outerHTML" hx-push-url="true" .mod-tab-active[*tab == active] aria-current=[(*tab == active).then_some("page")] { (tab.label()) } } } }}
/// Resolves `/{handle}/{slug}` to the listing it names, or redirects to/// where it went, if it was transferred to someone else.impl FromRequestParts<AppState> for Found<ModListing> { type Rejection = Response;
async fn from_request_parts(parts: &mut Parts, state: &AppState) -> Result<Self, Response> { let Path((handle, slug)) = Path::<(String, String)>::from_request_parts(parts, state) .await .map_err(|_| AppError::BadRequest("missing handle/slug".to_string()).into_response())?;
let handle = Handle::new(&handle) .map_err(|e| AppError::BadRequest(e.to_string()).into_response())?; let did = handle .resolve_did(state) .await .map_err(|err| AppError::Internal(err).into_response())? .ok_or_else(|| AppError::NotFound.into_response())?;
let found = ModListing::find_by_slug(&state.db, &did, &slug) .await .map_err(|err| AppError::Internal(err).into_response())?; let Some((rkey, record)) = found else { return Err(moved(state, parts, &did, &handle, &slug).await); };
let session = Option::<SessionCookie>::from_request_parts(parts, state) .await .map_err(IntoResponse::into_response)?;
Ok(Found::new(did, handle, rkey, record, session, state)) }}
/// A permanent redirect from a transferred mod's old address, and the same/// page of it, to its new one; or a 404 if it wasn't transferred.async fn moved( state: &AppState, parts: &Parts, did: &Did, handle: &Handle, slug: &str,) -> Response { let destination = async { let Some((owner, new_slug)) = crate::transfer::moved_listing(&state.db, did, slug).await? else { return Ok(None); }; let Some(owner) = owner.resolve_handle(state).await? else { return Ok(None); }; let old = format!("/{handle}/{slug}"); let rest = parts.uri.path().strip_prefix(&old).unwrap_or_default(); let query = parts .uri .query() .map(|q| format!("?{q}")) .unwrap_or_default(); anyhow::Ok(Some(format!("/{owner}/{new_slug}{rest}{query}"))) }; match destination.await { Ok(Some(destination)) => Redirect::permanent(&destination).into_response(), Ok(None) => AppError::NotFound.into_response(), Err(err) => AppError::Internal(err).into_response(), }}
async fn handle_get_create( State(state): State<AppState>, session: SessionCookie, page: Page,) -> Result<Response, AppError> { let staff = credits::is_staff(&state, &session.did); // A mod's credits name whose it is, so a new one starts with its owner. let rows = if staff { vec![] } else { let handle = session.did.resolve_handle(&state).await?; vec![Row { value: handle.map_or_else(|| session.did.to_string(), |handle| handle.to_string()), role: String::new(), }] }; Ok(page .body(share_form(&rows, staff)) .title("Share a mod") .render())}
/// Everything a mod needs to be shared, published in one go: its listing and/// its first version. Nothing reaches the author's repo until they publish,/// so leaving halfway leaves nothing behind.////// Not a plain form post: the images and the version's file upload as/// they're chosen and as it's sent, so `<share-form>` drives it, and there is/// nothing useful to fall back to without it.fn share_form(credit_rows: &[Row], staff: bool) -> Markup { let game_options: Vec<(&str, Game)> = Game::ALL.iter().map(|g| (g.as_str(), *g)).collect(); // The file kinds each game's versions can upload, for the games whose // files Star Haven hosts. The rest link to theirs. let accepts: std::collections::HashMap<&str, String> = Game::ALL .iter() .filter(|game| hosts_deliverables(**game)) .map(|game| (game.as_str(), deliverable_accept(*game))) .collect(); let accepts = serde_json::to_string(&accepts).expect("a map of strings serializes");
html! { div.accent-blue.form-page { share-form endpoint="/create" accepts=(accepts) { form novalidate { div.accent-red id="share-summary" role="alert" tabindex="-1" hidden {} h1 { "Share a mod" } noscript { p.error { "Sharing a mod needs JavaScript, because its images and file upload as you add them." } } (form::text_field("title", "Title", "", None, None)) (form::radio_group("game", "Game the mod is for", &game_options, None, None)) (form::text_field("description", "Short description", "", None, None)) (credits_field(credit_rows, staff, None)) (media_gallery_field("/create/media", &[], None))
(download_fields(None))
progress max="100" value="0" hidden {} div.form-actions { button.button type="submit" { "Publish" } } } } } }}
/// The credits field, whose editor sends its rows as JSON for/// [`credits::parse_field`] to read.fn credits_field(rows: &[Row], staff: bool, error: Option<&str>) -> Markup { html! { div.form-item id="credits" { label { "Credits" } div.error id="credits-error" hidden[error.is_none()] { (error.unwrap_or_default()) } noscript { p.error { "Editing the credits needs JavaScript." } } (crate::components::credits_input::render("credits", rows, staff)) } }}
/// What a version's download asks for when its game's files can't be/// uploaded, only linked to.const LINK_ONLY_HINT: &str = "Paste a link to where players download it.";
/// Where a version downloads from: one box to paste a link into or upload/// a file through, which `<download-fields>` drives; or only a link, for a `game`/// whose files Star Haven doesn't host. With no game yet, a file of any/// hosted kind, for `<share-form>` to narrow once one is picked.fn download_fields(game: Option<Game>) -> Markup { if let Some(game) = game.filter(|game| !hosts_deliverables(*game)) { return form::text_field( "external-url", "Download link", "", Some(html! { (LINK_ONLY_HINT) " Star Haven doesn't host files for " (game) " mods yet." }), None, ); }
html! { download-fields.form-item id="download" { label for="external-url" { "Download link" } // Only for a game that can't upload, which `<download-fields>` // learns of once one is picked. div.hint hidden { (LINK_ONLY_HINT) } div.error id="download-error" hidden {} div.download-box { input type="url" id="external-url" name="external-url" placeholder="https://"; div.download-file hidden { span.download-file-name {} button.download-clear type="button" aria-label="Remove the file" { (icon(icondata::LuX)) } } label.button.secondary.download-upload { "Upload…" input.visually-hidden type="file" id="version-file" accept=[game.map(deliverable_accept)]; } } } }}
/// Turns a title into a URL-safe slug: letters and digits lowercased, runs of/// anything else collapsed to a single dash, trimmed of leading and trailing/// dashes, capped at the lexicon's 64-byte limit.fn slugify(title: &str) -> String { let mut slug = String::new(); let mut last_was_dash = false; for c in title.chars() { if c.is_alphanumeric() { slug.extend(c.to_lowercase()); last_was_dash = false; } else if !last_was_dash && !slug.is_empty() { slug.push('-'); last_was_dash = true; } } if slug.ends_with('-') { slug.pop(); } while slug.len() > 64 { slug.pop(); } if slug.ends_with('-') { slug.pop(); } slug}
/// What a mod's first version is called. Sharing asks only for its download,/// since "version" means little until there's a second.const FIRST_VERSION: &str = "1.0";
#[derive(Deserialize)]#[serde(rename_all = "kebab-case")]struct ShareRequest { title: String, #[serde(default)] game: String, #[serde(default)] description: String, /// The credits editor's rows, as JSON. #[serde(default)] credits: String, /// The gallery editor's JSON list of uploaded images. #[serde(default)] media: String, /// The key `handle_post_create_presign` handed out, now that it has been /// filled. #[serde(default)] key: Option<String>, /// A link to a download hosted elsewhere, instead of an uploaded file. #[serde(default)] external_url: String,}
#[derive(Serialize)]struct ShareResponse { /// The new mod's page. location: String,}
async fn handle_post_create( State(state): State<AppState>, session: SessionCookie, Json(request): Json<ShareRequest>,) -> Result<Json<ShareResponse>, AppError> { // Refused before the upload is checked, so its file is discarded here. let key = request.key.as_deref(); let refuse = |message: &'static str| refuse_upload(&state, key, message);
let title = request.title.trim(); let slug = slugify(title); if title.is_empty() { return Err(refuse("Enter a title").await); } else if title.chars().count() > 300 { return Err(refuse("Title must be 300 characters or less").await); } else if slug.is_empty() { return Err(refuse("Title must include a letter or number").await); } let Ok(game) = request.game.parse::<Game>() else { return Err(refuse("Select a game").await); }; let Ok(media) = parse_media_field(&request.media) else { return Err(refuse("The images could not be read - try adding them again").await); }; let credits = match credits::parse_field(&state, &session.did, &request.credits).await { Ok(credits) => credits, Err(message) => return Err(refuse_upload(&state, key, &message).await), }; // A video can't stand in for the image that cards and search show. if !media.iter().any(MediaItem::is_image) { return Err(refuse("Add an image").await); }
// An uploaded file's key already names the listing it was signed for. // A link needs no upload, so its listing is named here. let rkey = match &request.key { Some(key) => { let prefix = format!("{}/", session.did); let Some(rkey) = key .strip_prefix(&prefix) .and_then(|rest| rest.split('/').next()) else { return Err(refuse("The selected file does not belong to this mod").await); }; Rkey::from_verified(rkey) } None => Rkey::new(), }; if ModListing::load_local(&state.db, &session.did, &rkey) .await .map_err(AppError::Internal)? .is_some() { return Err(refuse("The selected file does not belong to this mod").await); }
let download = choose_download(&state, game, request.key.clone(), &request.external_url).await?; check_version(&state, &session.did, &rkey, game, FIRST_VERSION, &download).await?;
let listing = ModListing { slug, title: title.to_string(), description: request.description.trim().to_string(), details: String::new(), game, tags: Tags::default(), license: String::new(), media, credits, created_at: Utc::now(), }; if let Err(err) = listing.update(&state, &session, &rkey).await { download.discard(&state).await; return Err(err.into()); } if let Err(err) = write_version( &state, &session, &session.did, &rkey, FIRST_VERSION, "", download, ) .await { // A mod is only shared with a version, so one that couldn't get its // first goes too. if let Err(err) = ModListing::delete(&state, &session, &rkey) .await .map_err(AppError::from) { eprintln!( "failed to delete {}'s versionless mod {rkey}: {err:?}", session.did ); } return Err(err); }
// We just wrote as this DID, so its handle - if it has a verified one - // is what names the listing's URL. let handle = session.did.resolve_handle(&state).await?.ok_or_else(|| { AppError::Internal(anyhow::anyhow!("session has no resolvable handle")) })?;
Ok(Json(ShareResponse { location: format!("/{handle}/{}", listing.slug), }))}
/// A refusal of a request that came with an upload, which nothing will now/// point at.async fn refuse_upload(state: &AppState, key: Option<&str>, message: &str) -> AppError { if let Some(key) = key { discard(state, key).await; } AppError::BadRequest(message.to_string())}
/// The shell every one of a mod's pages shares: its cover art and card, then/// the tab strip above whatever that tab shows. The card has the mod's/// `stars`, where given.async fn mod_layout( existing: &Found<ModListing>, latest: Option<&(Rkey, ModVersion)>, stars: Option<&Stars>, active: Tab, content: Markup,) -> Result<Markup, AppError> { let listing = &existing.record; let author = ActorProfile::load(&existing.state().db, &existing.did) .await .map_err(AppError::Internal)?; let handle = existing.handle.to_string(); let credited = credits::resolve(&existing.state().db, &listing.credits) .await .map_err(AppError::Internal)?; let totals = if stars.is_some() { Some( stats::totals(&existing.state().db, &existing.did, &existing.rkey) .await .map_err(|err| AppError::Internal(err.into()))?, ) } else { None };
Ok(html! { main.mod-listing.accent-blue id="mod" { (mod_carousel(&existing.did, listing)) div.card { div.mod-summary { h1 { (listing.title) } p.mod-game { "For " (listing.game) } // The credits name whose mod it is, but for a mod with none. @if credited.is_empty() { a.mod-author href={ "/" (handle) } { img src=(author.avatar_url(&existing.did, 128)) alt=""; span.mod-author-names { span.mod-author-name { (author.name(&handle)) } @if !author.display_name.is_empty() { span.mod-author-handle { "@" (handle) } } } } } @match latest { Some((rkey, latest)) => { (download_link(existing, rkey, latest, "button accent-blue download-button", Some(&latest.name))) } None => { p.hint { "This mod has no downloads yet." } } } @if let (Some(stars), Some(totals)) = (stars, &totals) { div.mod-engagement { (star_button(existing, stars)) (totals_list(totals)) } } } @if !credited.is_empty() { div.mod-credits-section { h2.mod-credits-heading { "Credits" } (credits_list(&credited)) } } } section.mod-details id="mod-details" { (mod_tabs(&existing.handle, listing, active, existing.is_owner())) (content) } } })}
/// Everyone credited for a mod, each with what they did.fn credits_list(credited: &[Credited]) -> Markup { html! { ul.mod-credits { @for credit in credited { li.mod-credit { @if let Some(avatar_url) = &credit.avatar_url { img src=(avatar_url) alt=""; } span.mod-credit-names { span.mod-credit-who { @if let Some(handle) = &credit.handle { a.mod-credit-name href={ "/" (handle) } { (credit.name) } } @else { span.mod-credit-name { (credit.name) } } // The handle too, as the author's is, if the name // isn't it. @if let Some(handle) = credit.handle.as_ref().filter(|h| h.as_str() != credit.name) { span.mod-credit-handle { "@" (handle) } } } @if !credit.role.is_empty() { span.mod-credit-role { (credit.role) } } } } } } }}
/// A link to download `version`, labeled with its `name` if given. It goes/// through Star Haven, which counts it, whether the file is here or hosted/// elsewhere, which the link names.fn download_link( existing: &Found<ModListing>, rkey: &Rkey, version: &ModVersion, class: &str, name: Option<&str>,) -> Markup { let href = deliverables::download_path(&existing.did, &existing.rkey, rkey); let Some(host) = external_host(&version.external_url) else { return html! { a class=(class) href=(href) { (icon(icondata::LuDownload)) "Download" @if let Some(name) = name { " " (name) } } }; }; html! { a class=(class) href=(href) target="_blank" rel="nofollow ugc noopener" { (icon(icondata::LuDownload)) "Download on " (host) " " span aria-hidden="true" { "↗" } span.visually-hidden { " (opens in a new tab)" } } }}
fn mod_carousel(did: &Did, listing: &ModListing) -> Markup { let media = &listing.media; if media.is_empty() { return html! { div.mod-image-placeholder { (homepage::image_placeholder(Some(listing.game), &listing.title)) } }; } // A video shows the gallery's first image until it's played. let poster = media.iter().find_map(|item| item.image_url(did)); // How wide the gallery is drawn: the page's full width on a phone, or two // thirds of `.mod-listing`'s. let sizes = "(max-width: 40rem) 100vw, 42rem"; html! { media-carousel { @for (n, item) in media.iter().enumerate() { // Only the first slide is seen when the page loads. @let first = n == 0; @let loading = (!first).then_some("lazy"); @let fetchpriority = first.then_some("high"); @if let Some(id) = item.youtube_id() { div data-youtube=(id) role="group" aria-label=(item.alt().unwrap_or("Video")) { @if let Some(url) = &poster { img src=(url) srcset=(blobs::srcset(url)) sizes=(sizes) loading=[loading] fetchpriority=[fetchpriority] alt=""; } } } @else if let Some(url) = item.image_url(did) { img src=(url) srcset=(blobs::srcset(&url)) sizes=(sizes) loading=[loading] fetchpriority=[fetchpriority] alt=(item.alt().unwrap_or_default()); } } } }}
/// Counts a view of the mod's page, unless whoever's looking already has/// today or is a bot. Logged rather than returned on failure, since the page/// can show regardless.async fn count_view(existing: &Found<ModListing>, headers: &HeaderMap, peer: SocketAddr) { let state = existing.state(); let Some(visitor) = stats::page_visitor(headers, peer) else { return; }; let what = format!("{}/{}", existing.did, existing.rkey); if !state.views_seen.first_today(&visitor, &what) { return; } if let Err(err) = stats::record_view(&state.db, &existing.did, &existing.rkey).await { eprintln!("failed to count a view of {what}: {err:?}"); }}
/// The mod's stars, and whether the account looking starred it.async fn stars(existing: &Found<ModListing>) -> Result<Stars, AppError> { let viewer = existing.session().map(|session| &session.did); ModStar::for_mod(&existing.state().db, &existing.did, &existing.rkey, viewer) .await .map_err(AppError::Internal)}
/// Stars the mod, or unstars it, for an account. Anyone else is asked to log/// in first.fn star_button(existing: &Found<ModListing>, stars: &Stars) -> Markup { let action = format!("/{}/{}/star", existing.handle, existing.record.slug); let starred = stars.mine.is_some(); let label = if starred { "Starred" } else { "Star" }; html! { div.star id="star" { @if existing.session().is_some() { form method="post" action=(action) hx-post=(action) hx-target="#star" hx-swap="outerHTML" { button.star-button type="submit" title=(if starred { "Unstar" } else { "Star" }) aria-pressed=(if starred { "true" } else { "false" }) { (icon(icondata::LuStar)) span.visually-hidden { (label) } span.star-count { (stats::compact(stars.count)) } } } } @else { a.star-button href="/login" title="Star" { (icon(icondata::LuStar)) span.visually-hidden { (label) } span.star-count { (stats::compact(stars.count)) } } } } }}
/// How often a mod was downloaded and its page viewed, as the two sections/// after its stars.fn totals_list(totals: &stats::Totals) -> Markup { html! { div.mod-stat title="Downloads" { (icon(icondata::LuDownload)) span { (stats::compact(totals.downloads)) } span.visually-hidden { "downloads" } } div.mod-stat title="Views" { (icon(icondata::LuEye)) span { (stats::compact(totals.views)) } span.visually-hidden { "views" } } }}
/// `POST /{handle}/{slug}/star` -- stars the mod for the signed-in account,/// or unstars it if they already have.async fn handle_post_star( existing: Found<ModListing>, headers: HeaderMap,) -> Result<Response, AppError> { let Some(session) = existing.session() else { return Ok(Redirect::to("/login").into_response()); }; let state = existing.state(); match stars(&existing).await?.mine { Some(rkey) => ModStar::delete(state, session, &rkey).await?, None => { let star = ModStar { subject: AtUri::new( existing.did.clone(), ModListing::NSID, existing.rkey.clone(), ), created_at: Utc::now(), }; star.create(state, session).await?; } }
if headers.contains_key("hx-request") { let stars = stars(&existing).await?; return Ok(Html(star_button(&existing, &stars).into_string()).into_response()); } Ok(Redirect::to(&format!("/{}/{}", existing.handle, existing.record.slug)).into_response())}
/// `GET /{handle}/{slug}` -- the mod's page, which counts as a view of it,/// unless its owner is the one looking.async fn handle_get_mod( ConnectInfo(peer): ConnectInfo<SocketAddr>, headers: HeaderMap, existing: Found<ModListing>, page: Page,) -> Result<Response, AppError> { if !existing.is_owner() { count_view(&existing, &headers, peer).await; } let latest = ModVersion::latest_for_mod(&existing.state().db, &existing.did, &existing.rkey) .await .map_err(AppError::Internal)?; if page.wants_markdown() { return Ok(page.markdown(mod_markdown(&existing, latest.as_ref()).await?)); } let stars = stars(&existing).await?;
Ok(page .body( mod_layout( &existing, latest.as_ref(), Some(&stars), Tab::Description, html! { div.markdown { (markdown::render(&existing.record.details)) } }, ) .await?, ) .title(&existing.record.title) .render())}
/// The mod's page as Markdown: what it is, who made it, where to download/// it, and its details, as the page sanitizes them. Its links/// are absolute, since whatever reads it has no page to resolve them against.async fn mod_markdown( existing: &Found<ModListing>, latest: Option<&(Rkey, ModVersion)>,) -> Result<String, AppError> { let state = existing.state(); let listing = &existing.record; let base = state.config.external_base(); let credited = credits::resolve(&state.db, &listing.credits) .await .map_err(AppError::Internal)?;
let mut out = format!( "# {}\n\nFor {}", markdown::escape(&listing.title), listing.game ); // The credits name whose mod it is, but for a mod with none. if credited.is_empty() { let author = ActorProfile::load(&state.db, &existing.did) .await .map_err(AppError::Internal)?; let handle = existing.handle.as_str(); out += &format!( ", by [{}]({base}/{handle})", markdown::escape(author.name(handle)) ); } out += "\n\n"; if !listing.description.is_empty() { out += &format!("{}\n\n", markdown::sanitize(&listing.description)); }
match latest { Some((rkey, version)) => { let href = deliverables::download_path(&existing.did, &existing.rkey, rkey); out += &format!( "[Download {}]({base}{href})", markdown::escape(&version.name) ); if let Some(host) = external_host(&version.external_url) { out += &format!(", hosted on {}", markdown::escape(&host)); } } None => out += "This mod has no downloads yet.", } out += &format!( " · [All versions]({base}/{}/{}/versions)\n\n", existing.handle, listing.slug );
if !credited.is_empty() { out += "## Credits\n\n"; for credit in &credited { out += "- "; match &credit.handle { Some(handle) => { out += &format!("[{}]({base}/{handle})", markdown::escape(&credit.name)); } None => out += &markdown::escape(&credit.name), } if !credit.role.is_empty() { out += &format!(": {}", markdown::escape(&credit.role)); } out += "\n"; } out += "\n"; }
if !listing.details.is_empty() { out += &format!("## Details\n\n{}\n", markdown::sanitize(&listing.details)); } Ok(out)}
/// The Versions tab: every version of a mod, newest first.async fn handle_get_versions( existing: Found<ModListing>, page: Page,) -> Result<Response, AppError> { let listing = &existing.record; let versions = ModVersion::find_by_mod(&existing.state().db, &existing.did, &existing.rkey) .await .map_err(AppError::Internal)?; let stars = stars(&existing).await?;
let content = html! { @if existing.is_owner() { (version_form(&existing.handle, listing)) }
@if versions.is_empty() { p { "No versions yet." } } @else { ul.version-list { @for (rkey, version) in &versions { li.version { div.version-header { h2 { (version.name) } (download_link(&existing, rkey, version, "button", None)) } @if !version.description.is_empty() { div.markdown { (markdown::render(&version.description)) } } } } } } };
Ok(page .body( mod_layout( &existing, versions.first(), Some(&stars), Tab::Versions, content, ) .await?, ) .title(format!("Versions of {}", listing.title)) .render())}
/// The owner-only Settings tab.async fn handle_get_settings( existing: Found<ModListing>, page: Page,) -> Result<Response, AppError> { existing.owner_session()?; let latest = ModVersion::latest_for_mod(&existing.state().db, &existing.did, &existing.rkey) .await .map_err(AppError::Internal)?; let transfer = crate::transfer::outgoing(existing.state(), &existing.did, &existing.rkey) .await .map_err(AppError::Internal)?;
Ok(page .body( settings_page( &existing, latest.as_ref(), transfer.as_ref(), &EditFormErrors::default(), ) .await?, ) .title(format!("Settings for {}", existing.record.title)) .render())}
async fn settings_page( existing: &Found<ModListing>, latest: Option<&(Rkey, ModVersion)>, transfer: Option<&crate::transfer::Outgoing>, errors: &EditFormErrors<'_>,) -> Result<Markup, AppError> { let listing = &existing.record; let mut credit_rows = match errors.credits_input { Some(json) => credits::rows_from_json(json), None => credits::rows(&existing.state().db, &listing.credits) .await .map_err(AppError::Internal)?, }; // A mod's credits name whose it is, so one without any starts with its // owner. if credit_rows.is_empty() && !credits::is_staff(existing.state(), &existing.did) { credit_rows.push(Row { value: existing.handle.to_string(), role: String::new(), }); } mod_layout( existing, latest, None, Tab::Settings, match transfer { Some(transfer) if transfer.in_progress => crate::transfer::progress(existing, transfer), _ => html! { (edit_form(existing, &credit_rows, errors)) (crate::transfer::section(existing, transfer)) (delete_form(&existing.handle, listing)) }, }, ) .await}
/// The owner's form for publishing a version: an N64 mod's uploads its BPS/// patch, with a link to one hosted elsewhere tucked away as the exception,/// and any other mod's links to its download, since Star Haven doesn't host/// those yet.////// Not a plain form post: the deliverable goes straight from the browser to/// the object store, so the `<upload-dialog>` component drives the three steps/// and there is nothing useful to fall back to without it.fn version_form(handle: &Handle, listing: &ModListing) -> Markup { let endpoint = format!("/{handle}/{}/versions", listing.slug); let hosted = hosts_deliverables(listing.game); let (title, required) = if hosted { ( "Upload a new version", "version-name:Enter a version,download:Select a file or paste a link", ) } else { ( "Add a new version", "version-name:Enter a version,external-url:Enter a link to the download", ) }; html! { button.button onclick="document.getElementById('version-dialog').showModal()" { (icon(if hosted { icondata::LuUpload } else { icondata::LuLink })) (title) } noscript { p.error { "Publishing a version needs JavaScript." } } upload-dialog id="version-dialog" title=(title) submit="Publish version" endpoint=(endpoint) required=(required) { (form::text_field("version-name", "Version", "", None, None)) (form::textarea_field( "version-description", "What changed (optional)", "", Some(html! { "For example, Added cats." }), None, )) (download_fields(Some(listing.game))) } }}
/// Messages for [`edit_form`]: `general` for a failure not tied to one/// field; `title` and `media` for a failure tied to that field.#[derive(Default)]struct EditFormErrors<'a> { general: Option<&'a str>, title: Option<&'a str>, credits: Option<&'a str>, media: Option<&'a str>, /// The credits editor's rows, when they couldn't be saved, so the author /// gets them back to fix. credits_input: Option<&'a str>,}
impl EditFormErrors<'_> { /// The messages to summarize at the top of the form, each paired with /// the id of the input it's about, if any. fn summary(&self) -> form::ErrorSummary<'_> { [ self.general.map(|e| (None, e)), self.title.map(|e| (Some("title"), e)), self.credits.map(|e| (Some("credits"), e)), self.media.map(|e| (Some("media"), e)), ] .into_iter() .flatten() .collect() }}
/// Takes the whole listing rather than a field each, so what it renders back/// after a rejected save is whatever the caller put on the record.fn edit_form(existing: &Found<ModListing>, credit_rows: &[Row], errors: &EditFormErrors) -> Markup { let listing = &existing.record;
html! { div.centered { form id="edit-form" method="post" action={ "/" (existing.handle) "/" (listing.slug) "/settings" } novalidate { (form::error_summary(&errors.summary())) (form::text_field("title", "Title", &listing.title, None, errors.title)) (form::text_field("description", "Short description", &listing.description, None, None)) (form::textarea_field("details", "Details", &listing.details, None, None)) (form::text_field("license", "License", &listing.license, None, None)) (credits_field( credit_rows, credits::is_staff(existing.state(), &existing.did), errors.credits, )) (media_gallery_field( &format!("/{}/{}/media", existing.handle, listing.slug), &gallery_items(&existing.did, listing), errors.media, )) div.form-actions { button.button type="submit" { "Save" } } } } }}
/// The images already in `did`'s `listing`, for its gallery editor.fn gallery_items(did: &Did, listing: &ModListing) -> Vec<crate::components::media_input::Item> { listing .media .iter() .map(|item| { let (cid, mime_type, size, url) = match item { MediaItem::Image { file, .. } => ( file.inner.ref_.link.to_string(), file.inner.mime_type.clone(), file.inner.size, String::new(), ), MediaItem::YouTube { url, .. } => (String::new(), String::new(), 0, url.clone()), }; crate::components::media_input::Item { src: item.image_url(did).unwrap_or_default(), cid, mime_type, size, url, alt: item.alt().unwrap_or_default().to_string(), } }) .collect()}
/// The gallery editor, uploading each image to `media_endpoint` as it's/// added, with `error` above it if it was refused.fn media_gallery_field( media_endpoint: &str, items: &[crate::components::media_input::Item], error: Option<&str>,) -> Markup { html! { div.form-item id="media" { label { "Media" } div.hint { "4:3. A mod needs at least one image." } div.error id="media-error" hidden[error.is_none()] { (error.unwrap_or_default()) } noscript { p.error { "Editing the media needs JavaScript, because each image is uploaded as soon as it's chosen." } } (crate::components::media_input::render(media_endpoint, "media", items)) } }}
fn delete_form(handle: &Handle, listing: &ModListing) -> Markup { html! { div.centered { div.label { label {"Are you sure you want to delete this mod?"} } button.accent-red hx-delete={ "/" (handle) "/" (listing.slug)} { "Delete this mod forever" } } }}
#[derive(Deserialize)]struct EditListingForm { title: String, description: String, #[serde(default)] details: String, #[serde(default)] license: String, #[serde(default)] credits: String, /// The gallery editor's current order, as JSON - built client side from /// the blob refs `handle_post_media` handed back. Empty for a mod with /// no gallery, or when the editor's JS never ran. #[serde(default)] media: String,}
/// What the gallery editor's JS sends for one image: a blob ref's fields,/// flattened, plus the alt text typed for it.#[derive(Deserialize)]#[serde(rename_all = "camelCase")]struct MediaField { #[serde(default)] cid: String, #[serde(default)] mime_type: String, #[serde(default)] size: u64, /// A YouTube link, in place of the image's `cid`, `mime_type`, and `size`. #[serde(default)] url: String, #[serde(default)] alt: String,}
/// Parses the gallery editor's hidden `media` field into the record's own/// media list. Only fails if the field is malformed JSON, which means the/// editor's JS is broken or its output was tampered with - never something a/// normal save can produce, so this has no per-field error of its own.fn parse_media_field(json: &str) -> Result<Vec<MediaItem>, ()> { if json.trim().is_empty() { return Ok(vec![]); } let items: Vec<MediaField> = serde_json::from_str(json).map_err(|_| ())?; items .into_iter() .map(|item| { let alt = (!item.alt.trim().is_empty()).then_some(item.alt.trim().to_string()); if item.url.is_empty() { Ok(MediaItem::from_blob_ref( item.cid, item.mime_type, item.size, alt, )) } else { MediaItem::from_youtube_url(&item.url, alt).ok_or(()) } }) .collect()}
async fn handle_post_settings( mut existing: Found<ModListing>, page: Page, Form(form): Form<EditListingForm>,) -> Response { if let Err(err) = crate::transfer::ensure_not_transferring(existing.state(), &existing.did, &existing.rkey) .await { return err.into_response(); } // Loaded up front because every rendering below is the whole settings // page, card and all, not a bare form. let latest = match ModVersion::latest_for_mod(&existing.state().db, &existing.did, &existing.rkey).await { Ok(latest) => latest, Err(err) => return AppError::Internal(err).into_response(), }; let transfer = match crate::transfer::outgoing(existing.state(), &existing.did, &existing.rkey).await { Ok(transfer) => transfer, Err(err) => return AppError::Internal(err).into_response(), };
let mut errors = EditFormErrors::default(); let media = match parse_media_field(&form.media) { Ok(media) => media, Err(()) => { errors.general = Some("The media gallery could not be saved - try again"); let body = match settings_page(&existing, latest.as_ref(), transfer.as_ref(), &errors).await { Ok(body) => body, Err(err) => return err.into_response(), }; return page .body(body) .title(format!("Settings for {}", existing.record.title)) .render() .into_response(); } };
// Taken onto the record before validating, so a rejected save shows the // author what they typed rather than what is stored. existing.record.title = form.title.trim().to_string(); existing.record.description = form.description; existing.record.details = form.details; existing.record.license = form.license; existing.record.media = media; let credits_error; match credits::parse_field(existing.state(), &existing.did, &form.credits).await { Ok(credits) => existing.record.credits = credits, Err(message) => { credits_error = message; errors.credits = Some(&credits_error); errors.credits_input = Some(&form.credits); } } if existing.record.title.is_empty() { errors.title = Some("Enter a title"); } else if existing.record.title.chars().count() > 300 { errors.title = Some("Title must be 300 characters or less"); } // A mod is listed by its first image, so it keeps at least one. if !existing.record.media.iter().any(MediaItem::is_image) { errors.media = Some("Add an image"); }
if errors.title.is_some() || errors.credits.is_some() || errors.media.is_some() { let body = match settings_page(&existing, latest.as_ref(), transfer.as_ref(), &errors).await { Ok(body) => body, Err(err) => return err.into_response(), }; return page .body(body) .title(format!("Settings for {}", existing.record.title)) .render() .into_response(); }
match existing.save().await { Ok(()) => { Redirect::to(&format!("/{}/{}", existing.handle, existing.record.slug)).into_response() } Err(err) => { // Internal errors carry technical detail meant for logs, not users. let message = match &err { AppError::Internal(e) => { eprintln!("error saving mod listing: {e:?}"); "Something went wrong on our end. Try again in a moment".to_string() } _ => err.to_string(), }; let errors = EditFormErrors { general: Some(&message), ..Default::default() }; let body = match settings_page(&existing, latest.as_ref(), transfer.as_ref(), &errors).await { Ok(body) => body, Err(err) => return err.into_response(), }; page.body(body) .title(format!("Settings for {}", existing.record.title)) .render() .into_response() } }}
/// How large one gallery image may be. Generous next to a deliverable's/// limit: this is a screenshot, not the mod itself, but still has to fit in/// a single request, since it goes through this appview rather than direct/// to storage.const MAX_MEDIA_BYTES: usize = 10 * 1024 * 1024;
#[derive(Serialize)]#[serde(rename_all = "camelCase")]struct UploadMediaResponse { cid: String, mime_type: String, size: u64,}
/// Uploads one gallery image to the owner's own PDS, returning its blob ref.////// Doesn't touch the listing - the gallery editor collects blob refs client/// side as images are added, and the settings form writes the finished array/// onto the record in one go, the same as every other field.async fn handle_post_media( existing: Found<ModListing>, headers: HeaderMap, bytes: Bytes,) -> Result<Json<UploadMediaResponse>, AppError> { let session = existing.owner_session()?; crate::transfer::ensure_not_transferring(existing.state(), &existing.did, &existing.rkey) .await?; upload_media(existing.state(), session, &headers, bytes).await}
/// Like [`handle_post_media`], for a mod that isn't shared yet, so the/// image goes to the signed-in account's own PDS.async fn handle_post_create_media( State(state): State<AppState>, session: SessionCookie, headers: HeaderMap, bytes: Bytes,) -> Result<Json<UploadMediaResponse>, AppError> { upload_media(&state, &session, &headers, bytes).await}
async fn upload_media( state: &AppState, session: &SessionCookie, headers: &HeaderMap, bytes: Bytes,) -> Result<Json<UploadMediaResponse>, AppError> { let mime_type = headers .get(CONTENT_TYPE) .and_then(|value| value.to_str().ok()) .unwrap_or("application/octet-stream"); if !mime_type.starts_with("image/") { return Err(AppError::BadRequest( "Only images can be added to the gallery".to_string(), )); } if bytes.is_empty() { return Err(AppError::BadRequest( "The selected file is empty".to_string(), )); } if bytes.len() > MAX_MEDIA_BYTES { return Err(AppError::BadRequest(format!( "The selected file must be smaller than {}", human_size(MAX_MEDIA_BYTES as u64) ))); }
let blob = pds::upload_blob(state, session, bytes, mime_type) .await .map_err(AppError::Internal)?;
Ok(Json(UploadMediaResponse { cid: blob.inner.ref_.link, mime_type: blob.inner.mime_type, size: blob.inner.size, }))}
#[derive(Deserialize)]struct PresignRequest { filename: String, size: u64,}
#[derive(Serialize)]struct PresignResponse { url: String, key: String, /// Headers the upload must carry, since the URL's signature covers them. headers: std::collections::HashMap<&'static str, &'static str>,}
/// Hand the owner a URL to upload one deliverable to.////// The key comes back rather than going in: letting the browser name the/// object is what would let one author sign a write over another's.async fn handle_post_presign( existing: Found<ModListing>, Json(request): Json<PresignRequest>,) -> Result<Json<PresignResponse>, AppError> { existing.owner_session()?; crate::transfer::ensure_not_transferring(existing.state(), &existing.did, &existing.rkey) .await?; presign( existing.state(), &existing.did, &existing.rkey, existing.record.game, request, ) .await}
#[derive(Deserialize)]struct CreatePresignRequest { game: String, #[serde(flatten)] file: PresignRequest,}
/// Like [`handle_post_presign`], for a mod that isn't shared yet. The key/// names the listing it will be, which publishing it then writes.async fn handle_post_create_presign( State(state): State<AppState>, session: SessionCookie, Json(request): Json<CreatePresignRequest>,) -> Result<Json<PresignResponse>, AppError> { let game = request .game .parse() .map_err(|_| AppError::BadRequest("Select a game".to_string()))?; presign(&state, &session.did, &Rkey::new(), game, request.file).await}
/// A URL to upload a file for a version of `did`'s listing at `rkey`, a mod/// for `game`.async fn presign( state: &AppState, did: &Did, rkey: &Rkey, game: Game, request: PresignRequest,) -> Result<Json<PresignResponse>, AppError> { if request.size == 0 { return Err(AppError::BadRequest( "The selected file is empty".to_string(), )); } if request.size > MAX_DELIVERABLE_BYTES { return Err(AppError::BadRequest(too_large_message())); } if !deliverable_name_allowed(game, &request.filename) { return Err(AppError::BadRequest( deliverable_kinds_message(game).to_string(), )); }
// The first upload after startup is what creates the bucket, if the // object store was not up when the appview started. state.s3.ensure_bucket().await.map_err(AppError::Internal)?;
// Checked against what the browser says the file weighs, so the upload is // refused before it happens rather than after. What actually arrives is // weighed again in `check_version`. let remaining = remaining_quota(state, did).await?; if request.size > remaining { return Err(AppError::BadRequest(over_quota_message(remaining))); }
let (url, key) = state .s3 .presign_upload(did, rkey, &request.filename) .await .map_err(AppError::Internal)?;
Ok(Json(PresignResponse { url, key, headers: crate::s3::UPLOAD_HEADERS.iter().copied().collect(), }))}
#[derive(Deserialize)]#[serde(rename_all = "kebab-case")]struct CreateReleaseRequest { version_name: String, #[serde(default)] version_description: String, /// The key `handle_post_presign` handed out, now that it has been filled. #[serde(default)] key: Option<String>, /// A link to a download hosted elsewhere, instead of an uploaded file. #[serde(default)] external_url: String,}
async fn handle_post_version( existing: Found<ModListing>, Json(request): Json<CreateReleaseRequest>,) -> Result<Response, AppError> { let session = existing.owner_session()?; crate::transfer::ensure_not_transferring(existing.state(), &existing.did, &existing.rkey) .await?; let state = existing.state(); let game = existing.record.game;
let download = choose_download(state, game, request.key, &request.external_url).await?; check_version( state, &existing.did, &existing.rkey, game, &request.version_name, &download, ) .await?; write_version( state, session, &existing.did, &existing.rkey, &request.version_name, &request.version_description, download, ) .await?;
Ok(StatusCode::NO_CONTENT.into_response())}
/// Where a new version downloads from.enum Download { /// A file uploaded to the object store, under this key. Uploaded(String), /// A link to a download hosted elsewhere. External(String),}
impl Download { /// Drops an uploaded file that won't be published after all. async fn discard(&self, state: &AppState) { if let Self::Uploaded(key) = self { discard(state, key).await; } }}
/// Which of an uploaded file, by its `key`, and a link the version downloads/// from, refusing neither or both.async fn choose_download( state: &AppState, game: Game, key: Option<String>, external_url: &str,) -> Result<Download, AppError> { let external_url = external_url.trim(); match (key, external_url.is_empty()) { (None, false) => Ok(Download::External(external_url.to_string())), (Some(key), true) => Ok(Download::Uploaded(key)), (Some(key), false) => { Err(refuse_upload(state, Some(&key), "Upload a file or link to one, not both").await) } (None, true) if hosts_deliverables(game) => { Err(AppError::BadRequest("Select a file".to_string())) } (None, true) => Err(AppError::BadRequest( "Enter a link to the download".to_string(), )), }}
/// Checks a version of `did`'s listing at `rkey` before anything is/// written: that it has a name, and that its download is one the listing can/// publish. A refused upload is deleted.async fn check_version( state: &AppState, did: &Did, rkey: &Rkey, game: Game, name: &str, download: &Download,) -> Result<(), AppError> { let key = match download { Download::External(url) => { if name.trim().is_empty() { return Err(AppError::BadRequest("Enter a version".to_string())); } if external_host(url).is_none() { return Err(AppError::BadRequest( "Enter a link that starts with https://".to_string(), )); } return Ok(()); } Download::Uploaded(key) => key, }; let refuse = |message: String| async move { discard(state, key).await; AppError::BadRequest(message) };
// The browser tells us which object to publish, so check it is one we // signed for this listing rather than a key pointing anywhere else. let prefix = format!("{did}/{rkey}/"); if !key.starts_with(&prefix) { return Err(AppError::BadRequest( "The selected file does not belong to this mod".to_string(), )); }
// The size checked at presign time was the browser's claim about a file // it had not sent yet. This is the object that actually arrived. let size = state .s3 .size(key) .await .map_err(AppError::Internal)? .ok_or_else(|| { AppError::BadRequest("The selected file could not be uploaded - try again".to_string()) })?; if size > MAX_DELIVERABLE_BYTES { return Err(refuse(too_large_message()).await); } if size == 0 { return Err(refuse("The selected file is empty".to_string()).await); }
// The name was checked before the upload was signed, and the key ends // with it. What the bytes are is only known now. let head = state .s3 .head_bytes(key, DELIVERABLE_MAGIC_BYTES) .await .map_err(AppError::Internal)?; let filename = key.rsplit('/').next().unwrap_or_default(); if !deliverable_starts_right(game, filename, &head) { return Err(refuse(deliverable_kinds_message(game).to_string()).await); }
// Counted after the upload landed, so this covers a browser that // understated the size, and two uploads that each fit on their own. let usage = state.s3.usage(did).await.map_err(AppError::Internal)?; if usage > state.config.s3_user_quota_bytes { let remaining = state .config .s3_user_quota_bytes .saturating_sub(usage - size); return Err(refuse(over_quota_message(remaining)).await); }
if name.trim().is_empty() { return Err(refuse("Enter a version".to_string()).await); } Ok(())}
/// Publishes a version of `did`'s listing at `rkey`, once [`check_version`]/// has passed it.async fn write_version( state: &AppState, session: &SessionCookie, did: &Did, rkey: &Rkey, name: &str, description: &str, download: Download,) -> Result<(), AppError> { // Chosen here rather than by the write, since an uploaded file's record // names its own download URL. let version_rkey = Rkey::new(); let mut version = ModVersion { listing: AtUri::new(did.clone(), ModListing::NSID, rkey.clone()), name: name.trim().to_string(), description: description.trim().to_string(), deliverable: String::new(), external_url: String::new(), created_at: Utc::now(), };
let key = match download { Download::External(url) => { version.external_url = url; version.update(state, session, &version_rkey).await?; return Ok(()); } Download::Uploaded(key) => key, }; version.deliverable = format!( "{}{}", state.config.external_base(), deliverables::download_path(did, rkey, &version_rkey) );
// Recorded before the version is written, so a sweep never takes a file // a published version points at. deliverables::record_checked(&state.db, &key, did, rkey, &version_rkey) .await .map_err(|err| AppError::Internal(err.into()))?;
if let Err(err) = version.update(state, session, &version_rkey).await { // Nothing references the object now and nothing ever will, since the // key was minted for this attempt alone. if let Err(err) = deliverables::forget(&state.db, &key).await { eprintln!("failed to forget unpublished deliverable {}: {err:?}", key); } discard(state, &key).await; return Err(err.into()); } Ok(())}
/// Drop an uploaded object no record ended up pointing at.////// Best effort: a leaked object costs storage, while failing the request the/// user is watching over a failed cleanup costs them the upload.async fn discard(state: &AppState, key: &str) { if let Err(err) = state.s3.delete(key).await { eprintln!("failed to delete orphaned upload {key}: {err:?}"); }}
fn too_large_message() -> String { format!( "The selected file must be smaller than {}", human_size(MAX_DELIVERABLE_BYTES) )}
/// How much more this author may upload before hitting their quota.async fn remaining_quota(state: &AppState, author: &Did) -> Result<u64, AppError> { let usage = state.s3.usage(author).await.map_err(AppError::Internal)?; Ok(state.config.s3_user_quota_bytes.saturating_sub(usage))}
/// Says how much room is left rather than only that there is none, since the/// author's next move is to pick a smaller file.fn over_quota_message(remaining: u64) -> String { format!( "The selected file must be smaller than {}, the upload space you have left", human_size(remaining), )}
/// A byte count in the unit a person would say it in.fn human_size(bytes: u64) -> String { const KB: f64 = 1024.0; const MB: f64 = KB * 1024.0; const GB: f64 = MB * 1024.0;
let bytes = bytes as f64; if bytes >= GB { format!("{:.1}GB", bytes / GB) } else if bytes >= MB { format!("{:.1}MB", bytes / MB) } else { format!("{:.1}KB", bytes / KB) }}
async fn handle_delete_mod(existing: Found<ModListing>) -> Result<Response, AppError> { existing.owner_session()?; crate::transfer::ensure_not_transferring(existing.state(), &existing.did, &existing.rkey) .await?; existing.delete().await?; // The firehose's delete event would get here too, but not if the tap // indexer is down, and a deleted mod's files shouldn't outlive it. crate::deliverables::delete_for_listing(existing.state(), &existing.did, &existing.rkey).await; Ok(AppendHeaders([ ("HX-Replace-Url", "/"), ("HX-Push-Url", "/"), ("HX-Location", "/"), ]) .into_response())}
#[cfg(test)]mod tests { use super::*;
#[test] fn sizes_are_shown_in_the_unit_a_person_would_say() { assert_eq!(human_size(2 * 1024 * 1024 * 1024), "2.0GB"); assert_eq!(human_size(1536 * 1024 * 1024), "1.5GB"); assert_eq!(human_size(42 * 1024 * 1024), "42.0MB"); // A used-up quota reads as no space rather than as "0.0GB". assert_eq!(human_size(4096), "4.0KB"); assert_eq!(human_size(0), "0.0KB"); }}