forge
ExploreLog in

Tahinli/forge

public
0 watching
★ 0
47.7 KiB
1//! Pages: releases (`/{owner}/{repo}/releases`). Anyone who can read the repo sees its published
2//! releases and downloads their assets. Drafts, and every change (create, edit, delete, asset
3//! upload and delete), need Write; anyone else gets a 404 for drafts and a refusal for changes.
4//!
5//! A release names a git tag. When the tag does not exist yet it is created as a lightweight tag
6//! at the head of the chosen branch, through `after_ref_update`. Deleting a release keeps the tag.
7//! Asset bytes are streamed to and from the blob store (see `blobs.rs`). Downloads are served as
8//! attachments with `nosniff`, and each completed start counts one download.
9
10use forge_core::releases::{
11 MAX_BODY_CHARS, MAX_TITLE_CHARS, NewAsset, NewRelease, Release, ReleaseAsset, ReleaseEdit,
12 clean_content_type, valid_asset_name,
13};
14use forge_core::webhooks::WebhookEvent;
15use forge_core::{Action, Denied, Repo as CoreRepo, User, authorize};
16use forge_git::browse::RefKind;
17use forge_git::tags::{create_lightweight_tag, valid_tag_name};
18use futures_util::TryStreamExt;
19use http::header::{CONTENT_DISPOSITION, CONTENT_LENGTH, CONTENT_TYPE};
20use http_body::Frame;
21use http_body_util::StreamBody;
22use serde::Deserialize;
23use topcoat::Result;
24use topcoat::context::{Cx, app_context};
25use topcoat::router::content::Form;
26use topcoat::router::error::{SeeOther, forbidden, not_found, see_other};
27use topcoat::router::request::headers;
28use topcoat::router::response::Response;
29use topcoat::router::{Body, HeaderName, HeaderValue, StatusCode, page, path_param, route};
30use topcoat::view::{Unescaped, View, component, view};
31
32use crate::auth::require_csrf;
33use crate::blobs::{BlobWriter, asset_key};
34use crate::i18n::{
35 self, Key, Lang,
36 releases_ci::{self as g, K},
37};
38use crate::pages::refs::load_refs;
39use crate::pages::repo::repo_head;
40use crate::pages::repo_ctx::{RepoCtx, RepoHead, load_repo};
41use crate::pages::repo_mirrors::stamp_in_tz;
42use crate::post_update::after_ref_update;
43use crate::render::markdown::render_markdown;
44use crate::state::AppState;
45use crate::views::{alert, enc, shell, user_facing_message};
46use crate::webhooks::emit::{Sender, emit};
47
48path_param!(owner);
49path_param!(repo);
50path_param!(release_id);
51path_param!(asset_id);
52path_param!(tag);
53path_param!(name);
54
55/// Multipart framing allowed on top of the file itself when checking `Content-Length`.
56const MULTIPART_OVERHEAD: u64 = 64 * 1024;
57/// The all-zero object id that means "no ref yet" in ref updates.
58fn zero_oid(len: usize) -> String {
59 "0".repeat(len)
60}
61
62/// Notice words (the only text a `?notice=` query can show), their text and whether they are errors.
63const NOTICES: [(&str, K, bool); 11] = [
64 ("saved", K::RelSaved, false),
65 ("deleted", K::RelDeleted, false),
66 ("asset-uploaded", K::RelAssetUploaded, false),
67 ("asset-deleted", K::RelAssetDeleted, false),
68 ("asset-too-large", K::RelAssetTooLarge, true),
69 ("asset-exists", K::RelAssetExists, true),
70 ("asset-name", K::RelAssetName, true),
71 ("asset-invalid", K::RelAssetInvalid, true),
72 ("confirm", K::RelConfirm, true),
73 ("invalid", K::RelInvalid, true),
74 ("asset-empty", K::RelAssetEmpty, true),
75];
76
77/// The viewer, if signed in.
78fn viewer_user(ctx: &RepoCtx) -> Option<&User> {
79 ctx.viewer.as_ref().map(|(user, _)| user)
80}
81
82/// Whether the viewer may change releases of this repo. Archived repos refuse.
83fn can_write(ctx: &RepoCtx) -> bool {
84 authorize(
85 viewer_user(ctx),
86 &ctx.repo,
87 ctx.role,
88 ctx.org_role,
89 Action::Write,
90 )
91 .is_ok()
92}
93
94/// Write access, or the refusal that matches why it is missing.
95fn require_write(ctx: &RepoCtx) -> Result<()> {
96 match authorize(
97 viewer_user(ctx),
98 &ctx.repo,
99 ctx.role,
100 ctx.org_role,
101 Action::Write,
102 ) {
103 Ok(()) => Ok(()),
104 Err(Denied::NotFound) => Err(not_found().into()),
105 Err(Denied::Forbidden) => Err(forbidden().into()),
106 }
107}
108
109fn title_of(ctx: &RepoCtx) -> String {
110 format!("{}/{}", ctx.repo.owner_name, ctx.repo.name)
111}
112
113fn releases_href(title: &str) -> String {
114 format!("/{title}/releases")
115}
116
117fn release_edit_href(title: &str, id: &str) -> String {
118 format!("/{title}/releases/{}/edit", enc(id))
119}
120
121fn tag_href(title: &str, tag: &str) -> String {
122 format!("/{title}/releases/tag/{}", enc(tag))
123}
124
125fn download_href(title: &str, tag: &str, name: &str) -> String {
126 format!("/{title}/releases/download/{}/{}", enc(tag), enc(name))
127}
128
129/// The notice from `?notice=<word>`. Anything else is ignored.
130fn notice_from_query(cx: &Cx, lang: Lang) -> Option<(&'static str, bool)> {
131 let query = topcoat::router::request::uri(cx).query()?;
132 let word = form_urlencoded::parse(query.as_bytes())
133 .find(|(key, _)| key == "notice")
134 .map(|(_, value)| value.into_owned())?;
135 NOTICES
136 .iter()
137 .find(|(name, _, _)| *name == word)
138 .map(|(_, key, is_error)| (g::t(lang, *key), *is_error))
139}
140
141fn notice_redirect(href: &str, word: &str) -> SeeOther {
142 see_other(format!("{href}?notice={word}"))
143}
144
145/// A byte count for people: "512 B", "1.5 KiB", "3.2 MiB".
146pub fn size_text(bytes: u64) -> String {
147 const KIB: f64 = 1024.0;
148 let b = bytes as f64;
149 if bytes < 1024 {
150 format!("{bytes} B")
151 } else if b < KIB * KIB {
152 format!("{:.1} KiB", b / KIB)
153 } else if b < KIB * KIB * KIB {
154 format!("{:.1} MiB", b / (KIB * KIB))
155 } else {
156 format!("{:.1} GiB", b / (KIB * KIB * KIB))
157 }
158}
159
160/// One asset as the pages show it.
161pub struct AssetLink {
162 pub name: String,
163 pub href: String,
164 pub size: String,
165 pub downloads: u64,
166 pub sha256: String,
167}
168
169fn asset_link(title: &str, tag: &str, asset: &ReleaseAsset) -> AssetLink {
170 AssetLink {
171 name: asset.name.clone(),
172 href: download_href(title, tag, &asset.name),
173 size: size_text(asset.size),
174 downloads: asset.download_count,
175 sha256: asset.sha256.clone(),
176 }
177}
178
179/// One release card on the list page.
180pub struct ReleaseCard {
181 pub title: String,
182 pub tag: String,
183 pub href: String,
184 pub edit_href: String,
185 pub body: String,
186 pub when: String,
187 pub author: String,
188 pub draft: bool,
189 pub prerelease: bool,
190 pub latest: bool,
191 pub assets: Vec<AssetLink>,
192}
193
194/// The "latest release" summary shown on the repo home page.
195pub struct LatestRelease {
196 pub title: String,
197 pub tag: String,
198 pub href: String,
199 pub when: String,
200 pub assets: Vec<AssetLink>,
201}
202
203fn card_of(
204 ctx: &RepoCtx,
205 release: &Release,
206 assets: &[ReleaseAsset],
207 latest_id: Option<&str>,
208) -> ReleaseCard {
209 let title = title_of(ctx);
210 let when = release
211 .published_at
212 .as_deref()
213 .unwrap_or(&release.created_at);
214 ReleaseCard {
215 title: release.title.clone(),
216 tag: release.tag.clone(),
217 href: tag_href(&title, &release.tag),
218 edit_href: release_edit_href(&title, &release.id),
219 body: release.body.clone(),
220 when: stamp_in_tz(when, ctx.viewer_tz()),
221 author: release.author_name.clone(),
222 draft: release.draft,
223 prerelease: release.prerelease,
224 latest: latest_id == Some(release.id.as_str()),
225 assets: assets
226 .iter()
227 .map(|asset| asset_link(&title, &release.tag, asset))
228 .collect(),
229 }
230}
231
232/// The data behind the repo home page's "Latest release" card, or `None` when there is none.
233pub async fn latest_release_for_home(
234 state: &AppState,
235 ctx: &RepoCtx,
236) -> Result<Option<LatestRelease>> {
237 let Some(release) = state.store.latest_release(&ctx.repo.id).await? else {
238 return Ok(None);
239 };
240 let title = title_of(ctx);
241 let assets = state.store.list_release_assets(&release.id).await?;
242 let when = release
243 .published_at
244 .as_deref()
245 .unwrap_or(&release.created_at);
246 Ok(Some(LatestRelease {
247 title: release.title.clone(),
248 tag: release.tag.clone(),
249 href: tag_href(&title, &release.tag),
250 when: stamp_in_tz(when, ctx.viewer_tz()),
251 assets: assets
252 .iter()
253 .map(|asset| asset_link(&title, &release.tag, asset))
254 .collect(),
255 }))
256}
257
258/// Values a release form shows, and its error.
259#[derive(Default)]
260struct FormDraft {
261 tag: String,
262 target: String,
263 title: String,
264 body: String,
265 prerelease: bool,
266 draft: bool,
267 error: Option<String>,
268}
269
270/// The new-release form fields.
271#[derive(Deserialize)]
272struct CreateForm {
273 #[serde(default)]
274 csrf: String,
275 #[serde(default)]
276 tag: String,
277 #[serde(default)]
278 target: String,
279 #[serde(default)]
280 title: String,
281 #[serde(default)]
282 body: String,
283 #[serde(default)]
284 prerelease: Option<String>,
285 #[serde(default)]
286 draft: Option<String>,
287}
288
289/// The edit-release form fields.
290#[derive(Deserialize)]
291struct EditForm {
292 #[serde(default)]
293 csrf: String,
294 #[serde(default)]
295 title: String,
296 #[serde(default)]
297 body: String,
298 #[serde(default)]
299 prerelease: Option<String>,
300 #[serde(default)]
301 draft: Option<String>,
302}
303
304#[derive(Deserialize)]
305struct CsrfForm {
306 #[serde(default)]
307 csrf: String,
308 #[serde(default)]
309 confirm: Option<String>,
310}
311
312/// The branch names of the repo, default branch first, for the target selector.
313fn branch_choices(refs: &[forge_git::browse::RefInfo], default_branch: &str) -> Vec<String> {
314 let mut names: Vec<String> = refs
315 .iter()
316 .filter(|r| r.kind == RefKind::Branch)
317 .map(|r| r.name.clone())
318 .collect();
319 names.sort();
320 if let Some(pos) = names.iter().position(|n| n == default_branch) {
321 let default = names.remove(pos);
322 names.insert(0, default);
323 }
324 names
325}
326
327/// Checks the typed fields of a release. The message is shown to the user.
328fn check_title_body(lang: Lang, title: &str, body: &str) -> std::result::Result<(), String> {
329 if title.is_empty() || title.chars().count() > MAX_TITLE_CHARS {
330 return Err(g::title_too_long(lang, MAX_TITLE_CHARS));
331 }
332 if body.chars().count() > MAX_BODY_CHARS {
333 return Err(g::body_too_long(lang, MAX_BODY_CHARS));
334 }
335 Ok(())
336}
337
338/// Release-form list of branches and the form itself, shared by the new and edit pages.
339#[component]
340async fn release_form(
341 action: String,
342 csrf: String,
343 lang: Lang,
344 is_new: bool,
345 draft: FormDraft,
346 branches: Vec<String>,
347 submit: &'static str,
348) -> Result<impl View> {
349 let tag_hint = g::t(lang, K::TagHint);
350 Ok(view! {
351 <form method="post" action=(action.as_str()) class="stack release-form">
352 if let Some(message) = draft.error {
353 alert(message: message.as_str())
354 }
355 <input type="hidden" name="csrf" value=(csrf.as_str())>
356 if is_new {
357 <label>
358 <span>(g::t(lang, K::Tag))</span>
359 <input type="text" name="tag" value=(draft.tag.as_str()) required="required" maxlength="100" autocomplete="off" spellcheck="false">
360 </label>
361 <p class="muted small">(tag_hint)</p>
362 <label>
363 <span>(g::t(lang, K::TargetBranch))</span>
364 <select name="target">
365 for branch in branches.iter() {
366 <option value=(branch.as_str()) selected=(branch.as_str() == draft.target.as_str())>(branch.as_str())</option>
367 }
368 </select>
369 </label>
370 } else {
371 <p>(g::t(lang, K::Tag)) ": " <code>(draft.tag.as_str())</code></p>
372 }
373 <label>
374 <span>(g::t(lang, K::Title))</span>
375 <input type="text" name="title" value=(draft.title.as_str()) required="required" maxlength="200">
376 </label>
377 <label>
378 <span>(g::t(lang, K::ReleaseNotes))</span>
379 <textarea name="body" rows="10">(draft.body.as_str())</textarea>
380 </label>
381 <label class="inline">
382 <input type="checkbox" name="prerelease" value="on" checked=(draft.prerelease)>
383 <span>(g::t(lang, K::PrereleaseHelp))</span>
384 </label>
385 <label class="inline">
386 <input type="checkbox" name="draft" value="on" checked=(draft.draft)>
387 <span>(g::t(lang, K::DraftHelp))</span>
388 </label>
389 <div>
390 <button type="submit" class="primary">(submit)</button>
391 </div>
392 </form>
393 })
394}
395
396/// The assets table on the edit page. `delete_actions` holds one form action per asset.
397#[component]
398async fn asset_rows(
399 assets: Vec<AssetLink>,
400 delete_actions: Vec<String>,
401 csrf: String,
402 lang: Lang,
403) -> Result<impl View> {
404 Ok(view! {
405 if assets.is_empty() {
406 <p class="muted">(g::t(lang, K::NoAssetsYet))</p>
407 } else {
408 <div class="table-wrap">
409 <table class="table release-assets">
410 <thead>
411 <tr>
412 <th>(g::t(lang, K::File))</th>
413 <th>(g::t(lang, K::Size))</th>
414 <th>(g::t(lang, K::Downloads))</th>
415 <th>"SHA-256"</th>
416 <th></th>
417 </tr>
418 </thead>
419 <tbody>
420 for (asset, action) in assets.iter().zip(delete_actions.iter()) {
421 <tr>
422 <td><a href=(asset.href.as_str())>(asset.name.as_str())</a></td>
423 <td>(asset.size.as_str())</td>
424 <td>(asset.downloads)</td>
425 <td><code class="small">(asset.sha256.as_str())</code></td>
426 <td>
427 <form method="post" action=(action.as_str()) class="inline-form">
428 <input type="hidden" name="csrf" value=(csrf.as_str())>
429 <button type="submit">(i18n::t(lang, Key::Delete))</button>
430 </form>
431 </td>
432 </tr>
433 }
434 </tbody>
435 </table>
436 </div>
437 }
438 })
439}
440
441/// The "Latest release" card on the repo home page.
442#[component]
443pub async fn latest_card(release: LatestRelease, lang: Lang) -> Result<impl View> {
444 Ok(view! {
445 <section class="card latest-release">
446 <h2>(g::t(lang, K::LatestReleaseHeading))</h2>
447 <p>
448 <a href=(release.href.as_str())>(release.title.as_str())</a>
449 " \u{b7} " <code>(release.tag.as_str())</code>
450 </p>
451 <p class="muted small">(release.when.as_str())</p>
452 if !release.assets.is_empty() {
453 <ul class="release-asset-list">
454 for asset in release.assets.iter() {
455 <li><a href=(asset.href.as_str())>(asset.name.as_str())</a></li>
456 }
457 </ul>
458 }
459 </section>
460 })
461}
462
463/// The list page body.
464#[component]
465async fn list_body(
466 head: crate::pages::repo_ctx::RepoHead,
467 lang: Lang,
468 notice: Option<(&'static str, bool)>,
469 new_href: Option<String>,
470 cards: Vec<ReleaseCard>,
471) -> Result<impl View> {
472 Ok(view! {
473 repo_head(head: head)
474 if let Some((text, is_error)) = notice {
475 if is_error {
476 alert(message: text)
477 } else {
478 <section class="card notice" role="status">(text)</section>
479 }
480 }
481 <section class="card">
482 <div class="release-list-head">
483 <h2>(g::t(lang, K::Releases))</h2>
484 if let Some(href) = new_href {
485 <a class="button" href=(href.as_str())>(g::t(lang, K::NewRelease))</a>
486 }
487 </div>
488 if cards.is_empty() {
489 <p class="muted">(g::t(lang, K::NoReleasesYet))</p>
490 }
491 for card in cards.iter() {
492 <article class="release-card">
493 <h3>
494 <a href=(card.href.as_str())>(card.title.as_str())</a>
495 if card.latest {
496 " " <span class="badge latest">(g::t(lang, K::Latest))</span>
497 }
498 if card.prerelease {
499 " " <span class="badge">(g::t(lang, K::PreRelease))</span>
500 }
501 if card.draft {
502 " " <span class="badge draft">(g::t(lang, K::Draft))</span>
503 }
504 </h3>
505 <p class="muted small">
506 <code>(card.tag.as_str())</code>
507 " \u{b7} " (card.when.as_str())
508 if !card.author.is_empty() {
509 " \u{b7} " (card.author.as_str())
510 }
511 </p>
512 if !card.body.is_empty() {
513 <div class="markdown-body">(Unescaped::new_unchecked(render_markdown(&card.body, None)))</div>
514 }
515 if !card.assets.is_empty() {
516 <ul class="release-asset-list">
517 for asset in card.assets.iter() {
518 <li><a href=(asset.href.as_str())>(asset.name.as_str())</a> " (" (asset.size.as_str()) ", " (g::download_count(lang, asset.downloads)) ")"</li>
519 }
520 </ul>
521 }
522 <p><a href=(card.edit_href.as_str())>(g::t(lang, K::Edit))</a></p>
523 </article>
524 }
525 </section>
526 })
527}
528
529/// Lists the repo's releases, newest first. Drafts appear only for writers.
530#[page("/{owner}/{repo}/releases")]
531async fn releases_list_page(cx: &Cx) -> Result<impl View> {
532 let owner = path_param::<Owner>(cx);
533 let name = path_param::<Repo>(cx);
534 let ctx = load_repo(cx, owner, name).await?;
535 let lang = i18n::lang(cx, viewer_user(&ctx));
536 let state = app_context::<AppState>(cx);
537 let writer = can_write(&ctx);
538 let latest_id = state
539 .store
540 .latest_release(&ctx.repo.id)
541 .await?
542 .map(|release| release.id);
543 let mut cards = Vec::new();
544 for release in state.store.list_releases(&ctx.repo.id).await? {
545 if release.draft && !writer {
546 continue;
547 }
548 let assets = state.store.list_release_assets(&release.id).await?;
549 cards.push(card_of(&ctx, &release, &assets, latest_id.as_deref()));
550 }
551 let title = format!("{} \u{b7} {}", title_of(&ctx), g::t(lang, K::Releases));
552 let new_href = writer.then(|| format!("{}/new", releases_href(&title_of(&ctx))));
553 let notice = notice_from_query(cx, lang);
554 let head = ctx.head(state, "releases").await?;
555 Ok(view! {
556 shell(title: title.as_str(), viewer: ctx.viewer.as_ref(),
557 list_body(head: head, lang: lang, notice: notice, new_href: new_href, cards: cards))
558 })
559}
560
561/// The branch choices and frame data of the new-release form.
562async fn new_form_data(state: &AppState, ctx: &RepoCtx) -> Result<(RepoHead, Vec<String>)> {
563 let refs = load_refs(ctx.path.clone()).await?;
564 let branches = branch_choices(&refs, &ctx.repo.default_branch);
565 let head = ctx.head(state, "releases").await?;
566 Ok((head, branches))
567}
568
569/// The new-release form body, below the repo header.
570#[component]
571async fn new_form_body(
572 head: RepoHead,
573 lang: Lang,
574 action: String,
575 csrf: String,
576 draft: FormDraft,
577 branches: Vec<String>,
578) -> Result<impl View> {
579 Ok(view! {
580 repo_head(head: head)
581 <section class="card">
582 <h2>(g::t(lang, K::NewRelease))</h2>
583 release_form(action: action, csrf: csrf, lang: lang, is_new: true, draft: draft, branches: branches, submit: g::t(lang, K::SaveRelease))
584 </section>
585 })
586}
587
588/// The new-release form.
589#[page("/{owner}/{repo}/releases/new")]
590async fn release_new_page(cx: &Cx) -> Result<impl View> {
591 let owner = path_param::<Owner>(cx);
592 let name = path_param::<Repo>(cx);
593 let ctx = load_repo(cx, owner, name).await?;
594 require_write(&ctx)?;
595 let lang = i18n::lang(cx, viewer_user(&ctx));
596 let state = app_context::<AppState>(cx);
597 let csrf = match crate::auth::current_user(cx).await? {
598 Some((_, session)) => session.csrf_token.clone(),
599 None => String::new(),
600 };
601 let draft = FormDraft {
602 target: ctx.repo.default_branch.clone(),
603 draft: false,
604 ..FormDraft::default()
605 };
606 let (head, branches) = new_form_data(state, &ctx).await?;
607 let title = format!("{} \u{b7} {}", title_of(&ctx), g::t(lang, K::NewRelease));
608 let action = format!("{}/new", releases_href(&title_of(&ctx)));
609 Ok(view! {
610 shell(title: title.as_str(), viewer: ctx.viewer.as_ref(),
611 new_form_body(head: head, lang: lang, action: action, csrf: csrf, draft: draft, branches: branches))
612 })
613}
614
615/// Creates a release. The tag is created at the target branch's head when it does not exist.
616#[page(POST "/{owner}/{repo}/releases/new")]
617async fn release_create(cx: &Cx, Form(form): Form<CreateForm>) -> Result<impl View> {
618 let owner = path_param::<Owner>(cx);
619 let name = path_param::<Repo>(cx);
620 let ctx = load_repo(cx, owner, name).await?;
621 let (user, session) = require_csrf(cx, &form.csrf).await?;
622 require_write(&ctx)?;
623 let lang = i18n::lang(cx, viewer_user(&ctx));
624 let state = app_context::<AppState>(cx);
625 let draft = FormDraft {
626 tag: form.tag.trim().to_owned(),
627 target: form.target.trim().to_owned(),
628 title: form.title.trim().to_owned(),
629 body: form.body.trim_end().to_owned(),
630 prerelease: form.prerelease.is_some(),
631 draft: form.draft.is_some(),
632 error: None,
633 };
634 match create_checked(state, &ctx, &user, &draft, lang).await? {
635 Ok(release) => {
636 if !release.draft {
637 emit_release(state, &ctx.repo, &user, "published", &release).await;
638 }
639 let title = title_of(&ctx);
640 Err(see_other(tag_href(&title, &release.tag)).into())
641 }
642 Err(message) => {
643 let csrf = session.csrf_token.clone();
644 let draft = FormDraft {
645 error: Some(message),
646 ..draft
647 };
648 let (head, branches) = new_form_data(state, &ctx).await?;
649 let title = format!("{} \u{b7} {}", title_of(&ctx), g::t(lang, K::NewRelease));
650 let action = format!("{}/new", releases_href(&title_of(&ctx)));
651 Ok(view! {
652 shell(title: title.as_str(), viewer: ctx.viewer.as_ref(),
653 new_form_body(head: head, lang: lang, action: action, csrf: csrf, draft: draft, branches: branches))
654 })
655 }
656 }
657}
658
659/// Checks the new-release fields, makes the tag when it is missing, and stores the release. A
660/// refusal the form shows is `Err(message)`; storage failures are errors.
661async fn create_checked(
662 state: &AppState,
663 ctx: &RepoCtx,
664 user: &User,
665 draft: &FormDraft,
666 lang: Lang,
667) -> Result<std::result::Result<Release, String>> {
668 let tag_name = draft.tag.as_str();
669 if !valid_tag_name(tag_name) {
670 return Ok(Err(g::t(lang, K::RelErrTagName).to_owned()));
671 }
672 if let Err(message) = check_title_body(lang, &draft.title, &draft.body) {
673 return Ok(Err(message));
674 }
675 if state
676 .store
677 .release_by_tag(&ctx.repo.id, tag_name)
678 .await?
679 .is_some()
680 {
681 return Ok(Err(g::t(lang, K::RelErrReleaseExists).to_owned()));
682 }
683 let refs = load_refs(ctx.path.clone()).await?;
684 let existing = refs
685 .iter()
686 .find(|r| r.kind == RefKind::Tag && r.name == tag_name)
687 .map(|r| r.target.clone());
688 let target_oid = match existing {
689 Some(oid) => oid,
690 None => {
691 let branch_oid = refs
692 .iter()
693 .find(|r| r.kind == RefKind::Branch && r.name == draft.target)
694 .map(|r| r.target.clone());
695 let Some(branch_oid) = branch_oid else {
696 return Ok(Err(g::t(lang, K::RelErrChooseBranch).to_owned()));
697 };
698 create_lightweight_tag(&state.git, &ctx.path, tag_name, &branch_oid).await?;
699 let refname = format!("refs/tags/{tag_name}");
700 let zero = zero_oid(branch_oid.len());
701 if let Err(e) =
702 after_ref_update(state, &ctx.repo, &user.id, &refname, &zero, &branch_oid).await
703 {
704 tracing::error!(error = %e, repo_id = %ctx.repo.id, "releases: post-update for new tag failed");
705 }
706 branch_oid
707 }
708 };
709 let created = state
710 .store
711 .create_release(&NewRelease {
712 repo_id: &ctx.repo.id,
713 tag: tag_name,
714 target_oid: &target_oid,
715 title: &draft.title,
716 body: &draft.body,
717 draft: draft.draft,
718 prerelease: draft.prerelease,
719 author_id: &user.id,
720 })
721 .await;
722 match created {
723 Ok(release) => Ok(Ok(release)),
724 Err(e) => match user_facing_message(&e) {
725 Some(message) => Ok(Err(message)),
726 None => Err(e.into()),
727 },
728 }
729}
730
731/// The webhook payload of a release event, sent to every hook that subscribes to `release`.
732async fn emit_release(
733 state: &AppState,
734 repo: &CoreRepo,
735 user: &User,
736 action: &str,
737 release: &Release,
738) {
739 let payload = serde_json::json!({
740 "release": {
741 "id": release.id,
742 "tag": release.tag,
743 "target_oid": release.target_oid,
744 "name": release.title,
745 "draft": release.draft,
746 "prerelease": release.prerelease,
747 "published_at": release.published_at,
748 }
749 });
750 emit(
751 state,
752 repo,
753 &Sender::of(user),
754 WebhookEvent::Release,
755 action,
756 payload,
757 )
758 .await;
759}
760
761/// The public view of one release: notes, assets and the source archive links.
762#[page("/{owner}/{repo}/releases/tag/{tag}")]
763async fn release_tag_page(cx: &Cx) -> Result<impl View> {
764 let owner = path_param::<Owner>(cx);
765 let name = path_param::<Repo>(cx);
766 let tag_param = path_param::<Tag>(cx).to_owned();
767 let ctx = load_repo(cx, owner, name).await?;
768 let lang = i18n::lang(cx, viewer_user(&ctx));
769 let state = app_context::<AppState>(cx);
770 let writer = can_write(&ctx);
771 let Some(release) = state.store.release_by_tag(&ctx.repo.id, &tag_param).await? else {
772 return Err(not_found().into());
773 };
774 if release.draft && !writer {
775 return Err(not_found().into());
776 }
777 let title = title_of(&ctx);
778 let assets = state.store.list_release_assets(&release.id).await?;
779 let links: Vec<AssetLink> = assets
780 .iter()
781 .map(|asset| asset_link(&title, &release.tag, asset))
782 .collect();
783 let when = release
784 .published_at
785 .as_deref()
786 .unwrap_or(&release.created_at);
787 let when = stamp_in_tz(when, ctx.viewer_tz());
788 let body_html = render_markdown(&release.body, None);
789 let page_title = format!("{title} \u{b7} {}", release.title);
790 let archive_tar = format!("/{title}/archive/{}.tar.gz", enc(&release.tag));
791 let archive_zip = format!("/{title}/archive/{}.zip", enc(&release.tag));
792 let edit_href = release_edit_href(&title, &release.id);
793 let all_href = releases_href(&title);
794 let card = ReleaseCard {
795 title: release.title.clone(),
796 tag: release.tag.clone(),
797 href: tag_href(&title, &release.tag),
798 edit_href: edit_href.clone(),
799 body: String::new(),
800 when,
801 author: release.author_name.clone(),
802 draft: release.draft,
803 prerelease: release.prerelease,
804 latest: false,
805 assets: links,
806 };
807 let head = ctx.head(state, "releases").await?;
808 let notice = notice_from_query(cx, lang);
809 let delete_href = format!("/{title}/releases/{}/delete", enc(&release.id));
810 let csrf = match crate::auth::current_user(cx).await? {
811 Some((_, session)) => session.csrf_token.clone(),
812 None => String::new(),
813 };
814 Ok(view! {
815 shell(title: page_title.as_str(), viewer: ctx.viewer.as_ref(),
816 repo_head(head: head)
817 if let Some((text, is_error)) = notice {
818 if is_error {
819 alert(message: text)
820 } else {
821 <section class="card notice" role="status">(text)</section>
822 }
823 }
824 <section class="card">
825 <p class="muted small">
826 <a href=(all_href.as_str())>(g::t(lang, K::AllReleases))</a>
827 </p>
828 <h2>
829 (card.title.as_str())
830 if card.prerelease {
831 " " <span class="badge">(g::t(lang, K::PreRelease))</span>
832 }
833 if card.draft {
834 " " <span class="badge draft">(g::t(lang, K::Draft))</span>
835 }
836 </h2>
837 <p class="muted small">
838 <code>(card.tag.as_str())</code>
839 " \u{b7} " (card.when.as_str())
840 if !card.author.is_empty() {
841 " \u{b7} " (card.author.as_str())
842 }
843 </p>
844 if !body_html.is_empty() {
845 <div class="markdown-body">(Unescaped::new_unchecked(body_html))</div>
846 }
847 <h3>(g::t(lang, K::Assets))</h3>
848 if card.assets.is_empty() {
849 <p class="muted">(g::t(lang, K::NoAssets))</p>
850 } else {
851 <ul class="release-asset-list">
852 for asset in card.assets.iter() {
853 <li>
854 <a href=(asset.href.as_str())>(asset.name.as_str())</a>
855 " (" (asset.size.as_str()) ", " (g::download_count(lang, asset.downloads)) ")"
856 <div class="muted small"><code>(asset.sha256.as_str())</code></div>
857 </li>
858 }
859 </ul>
860 }
861 <h3>(g::t(lang, K::SourceCode))</h3>
862 <ul>
863 <li><a href=(archive_tar.as_str())>"tar.gz"</a></li>
864 <li><a href=(archive_zip.as_str())>"zip"</a></li>
865 </ul>
866 if writer {
867 <p class="release-actions">
868 <a href=(edit_href.as_str())>(g::t(lang, K::Edit))</a>
869 <form method="post" action=(delete_href.as_str()) class="inline-form">
870 <input type="hidden" name="csrf" value=(csrf.as_str())>
871 <label class="inline">
872 <input type="checkbox" name="confirm" value="on">
873 <span>(g::t(lang, K::Confirm))</span>
874 </label>
875 <button type="submit">(g::t(lang, K::DeleteRelease))</button>
876 </form>
877 </p>
878 }
879 </section>
880 )
881 })
882}
883
884/// The edit page: the release fields, its assets, and the upload form.
885#[page("/{owner}/{repo}/releases/{release_id}/edit")]
886async fn release_edit_page(cx: &Cx) -> Result<impl View> {
887 let owner = path_param::<Owner>(cx);
888 let name = path_param::<Repo>(cx);
889 let id = path_param::<ReleaseId>(cx).to_owned();
890 let ctx = load_repo(cx, owner, name).await?;
891 require_write(&ctx)?;
892 let lang = i18n::lang(cx, viewer_user(&ctx));
893 let state = app_context::<AppState>(cx);
894 let Some(release) = state.store.release(&id).await? else {
895 return Err(not_found().into());
896 };
897 if release.repo_id != ctx.repo.id {
898 return Err(not_found().into());
899 }
900 let title = title_of(&ctx);
901 let assets = state.store.list_release_assets(&release.id).await?;
902 let delete_actions: Vec<String> = assets
903 .iter()
904 .map(|a| {
905 format!(
906 "/{}/releases/{}/assets/{}/delete",
907 title_of(&ctx),
908 enc(&release.id),
909 enc(&a.id)
910 )
911 })
912 .collect();
913 let links: Vec<AssetLink> = assets
914 .iter()
915 .map(|asset| asset_link(&title, &release.tag, asset))
916 .collect();
917 let csrf = match crate::auth::current_user(cx).await? {
918 Some((_, session)) => session.csrf_token.clone(),
919 None => String::new(),
920 };
921 let draft = FormDraft {
922 tag: release.tag.clone(),
923 title: release.title.clone(),
924 body: release.body.clone(),
925 prerelease: release.prerelease,
926 draft: release.draft,
927 ..FormDraft::default()
928 };
929 let refs = load_refs(ctx.path.clone()).await?;
930 let branches = branch_choices(&refs, &ctx.repo.default_branch);
931 let head = ctx.head(state, "releases").await?;
932 let page_title = format!("{title} \u{b7} {}", g::edit_title(lang, &release.tag));
933 let notice = notice_from_query(cx, lang);
934 let action = release_edit_href(&title, &release.id);
935 let assets_base = format!("/{title}/releases/{}/assets", enc(&release.id));
936 let upload_action = assets_base.clone();
937 let upload_hint = g::upload_hint(lang, state.config.releases.max_asset_mb);
938 let delete_href = format!("/{title}/releases/{}/delete", enc(&release.id));
939 let view_tag = tag_href(&title, &release.tag);
940 Ok(view! {
941 shell(title: page_title.as_str(), viewer: ctx.viewer.as_ref(),
942 repo_head(head: head)
943 if let Some((text, is_error)) = notice {
944 if is_error {
945 alert(message: text)
946 } else {
947 <section class="card notice" role="status">(text)</section>
948 }
949 }
950 <section class="card">
951 <p class="muted small"><a href=(view_tag.as_str())>(g::t(lang, K::BackToRelease))</a></p>
952 <h2>(g::t(lang, K::EditRelease))</h2>
953 release_form(action: action.clone(), csrf: csrf.clone(), lang: lang, is_new: false, draft: draft, branches: branches, submit: g::t(lang, K::SaveRelease))
954 </section>
955 <section class="card">
956 <h2>(g::t(lang, K::Assets))</h2>
957 <p class="muted small">(upload_hint.as_str())</p>
958 asset_rows(assets: links, delete_actions: delete_actions, csrf: csrf.clone(), lang: lang)
959 <form method="post" action=(upload_action.as_str()) enctype="multipart/form-data" class="stack">
960 <input type="hidden" name="csrf" value=(csrf.as_str())>
961 <label>
962 <span>(g::t(lang, K::File))</span>
963 <input type="file" name="file" required="required">
964 </label>
965 <div>
966 <button type="submit">(g::t(lang, K::UploadAsset))</button>
967 </div>
968 </form>
969 </section>
970 <section class="card">
971 <h2>(g::t(lang, K::DeleteRelease))</h2>
972 <p class="muted small">(g::t(lang, K::DeleteReleaseHelp))</p>
973 <form method="post" action=(delete_href.as_str()) class="stack">
974 <input type="hidden" name="csrf" value=(csrf.as_str())>
975 <label class="inline">
976 <input type="checkbox" name="confirm" value="on">
977 <span>(g::t(lang, K::YesDeleteRelease))</span>
978 </label>
979 <div>
980 <button type="submit">(g::t(lang, K::DeleteRelease))</button>
981 </div>
982 </form>
983 </section>
984 )
985 })
986}
987
988/// Saves the edited fields. A draft that becomes published is announced as `published`; any other
989/// change to a published release is `updated`.
990#[route(POST "/{owner}/{repo}/releases/{release_id}/edit")]
991async fn release_update(cx: &Cx, Form(form): Form<EditForm>) -> Result<Response> {
992 let owner = path_param::<Owner>(cx);
993 let name = path_param::<Repo>(cx);
994 let id = path_param::<ReleaseId>(cx).to_owned();
995 let ctx = load_repo(cx, owner, name).await?;
996 let (user, _) = require_csrf(cx, &form.csrf).await?;
997 require_write(&ctx)?;
998 let state = app_context::<AppState>(cx);
999 let Some(before) = state.store.release(&id).await? else {
1000 return Err(not_found().into());
1001 };
1002 if before.repo_id != ctx.repo.id {
1003 return Err(not_found().into());
1004 }
1005 let title_text = form.title.trim().to_owned();
1006 let body = form.body.trim_end().to_owned();
1007 let lang = i18n::lang(cx, viewer_user(&ctx));
1008 if check_title_body(lang, &title_text, &body).is_err() {
1009 return Err(notice_redirect(&release_edit_href(&title_of(&ctx), &id), "invalid").into());
1010 }
1011 let edit = ReleaseEdit {
1012 title: &title_text,
1013 body: &body,
1014 draft: form.draft.is_some(),
1015 prerelease: form.prerelease.is_some(),
1016 };
1017 if !state.store.update_release(&id, &edit).await? {
1018 return Err(not_found().into());
1019 }
1020 let Some(after) = state.store.release(&id).await? else {
1021 return Err(not_found().into());
1022 };
1023 let action = match (before.draft, after.draft) {
1024 (true, false) => Some("published"),
1025 (true, true) => None,
1026 (false, _) => Some("updated"),
1027 };
1028 if let Some(action) = action {
1029 emit_release(state, &ctx.repo, &user, action, &after).await;
1030 }
1031 let href = release_edit_href(&title_of(&ctx), &id);
1032 Err(notice_redirect(&href, "saved").into())
1033}
1034
1035/// Deletes a release and its assets, then their blobs. The git tag is kept. Requires the confirm box.
1036#[route(POST "/{owner}/{repo}/releases/{release_id}/delete")]
1037async fn release_delete(cx: &Cx, Form(form): Form<CsrfForm>) -> Result<Response> {
1038 let owner = path_param::<Owner>(cx);
1039 let name = path_param::<Repo>(cx);
1040 let id = path_param::<ReleaseId>(cx).to_owned();
1041 let ctx = load_repo(cx, owner, name).await?;
1042 let (user, _) = require_csrf(cx, &form.csrf).await?;
1043 require_write(&ctx)?;
1044 let title = title_of(&ctx);
1045 if form.confirm.is_none() {
1046 let href = release_edit_href(&title, &id);
1047 return Err(notice_redirect(&href, "confirm").into());
1048 }
1049 let state = app_context::<AppState>(cx);
1050 let Some(release) = state.store.release(&id).await? else {
1051 return Err(not_found().into());
1052 };
1053 if release.repo_id != ctx.repo.id {
1054 return Err(not_found().into());
1055 }
1056 let Some(asset_ids) = state.store.delete_release(&id).await? else {
1057 return Err(not_found().into());
1058 };
1059 for asset_id in &asset_ids {
1060 if let Err(e) = state.blobs.delete(&asset_key(asset_id)).await {
1061 tracing::error!(error = %e, asset_id, "releases: could not delete an asset blob");
1062 }
1063 }
1064 if !release.draft {
1065 emit_release(state, &ctx.repo, &user, "deleted", &release).await;
1066 }
1067 Err(notice_redirect(&releases_href(&title), "deleted").into())
1068}
1069
1070/// Deletes one asset row and its blob.
1071#[route(POST "/{owner}/{repo}/releases/{release_id}/assets/{asset_id}/delete")]
1072async fn asset_delete(cx: &Cx, Form(form): Form<CsrfForm>) -> Result<Response> {
1073 let owner = path_param::<Owner>(cx);
1074 let name = path_param::<Repo>(cx);
1075 let release = path_param::<ReleaseId>(cx).to_owned();
1076 let asset = path_param::<AssetId>(cx).to_owned();
1077 let ctx = load_repo(cx, owner, name).await?;
1078 require_csrf(cx, &form.csrf).await?;
1079 require_write(&ctx)?;
1080 let state = app_context::<AppState>(cx);
1081 let Some(row) = state.store.release_asset_by_id(&asset).await? else {
1082 return Err(not_found().into());
1083 };
1084 if row.release_id != release {
1085 return Err(not_found().into());
1086 }
1087 let href = release_edit_href(&title_of(&ctx), &release);
1088 state.store.delete_release_asset(&row.id).await?;
1089 if let Err(e) = state.blobs.delete(&asset_key(&row.id)).await {
1090 tracing::error!(error = %e, asset_id = %row.id, "releases: could not delete an asset blob");
1091 }
1092 Err(notice_redirect(&href, "asset-deleted").into())
1093}
1094
1095/// Why a streamed upload stopped early.
1096enum UploadFailure {
1097 TooLarge,
1098 Unreadable,
1099 Storage(forge_core::Error),
1100}
1101
1102/// Copies one multipart field into the blob writer, enforcing the writer's size limit.
1103async fn copy_field(
1104 field: &mut multer::Field<'_>,
1105 writer: &mut BlobWriter,
1106) -> std::result::Result<(), UploadFailure> {
1107 loop {
1108 match field.chunk().await {
1109 Ok(Some(chunk)) => match writer.push(&chunk).await {
1110 Ok(()) => {}
1111 Err(forge_core::Error::Invalid(_)) => return Err(UploadFailure::TooLarge),
1112 Err(e) => return Err(UploadFailure::Storage(e)),
1113 },
1114 Ok(None) => return Ok(()),
1115 Err(_) => return Err(UploadFailure::Unreadable),
1116 }
1117 }
1118}
1119
1120/// Uploads one asset. The body is read as a stream: the `csrf` field must come first, then the
1121/// `file` field. The file goes to a temporary blob while its sha256 is computed, and is
1122/// published under `assets/<id>` only when it is complete and the row is stored.
1123#[route(POST "/{owner}/{repo}/releases/{release_id}/assets")]
1124async fn asset_upload(cx: &Cx, body: Body) -> Result<Response> {
1125 let owner = path_param::<Owner>(cx);
1126 let name = path_param::<Repo>(cx);
1127 let id = path_param::<ReleaseId>(cx).to_owned();
1128 let ctx = load_repo(cx, owner, name).await?;
1129 let state = app_context::<AppState>(cx);
1130 let href = release_edit_href(&title_of(&ctx), &id);
1131 let max_bytes = state
1132 .config
1133 .releases
1134 .max_asset_mb
1135 .saturating_mul(1024 * 1024);
1136
1137 let declared = headers(cx)
1138 .get(CONTENT_LENGTH)
1139 .and_then(|v| v.to_str().ok())
1140 .and_then(|v| v.parse::<u64>().ok());
1141 if declared.is_some_and(|len| len > max_bytes.saturating_add(MULTIPART_OVERHEAD)) {
1142 return Err(notice_redirect(&href, "asset-too-large").into());
1143 }
1144 let content_type = headers(cx)
1145 .get(CONTENT_TYPE)
1146 .and_then(|v| v.to_str().ok())
1147 .unwrap_or("")
1148 .to_owned();
1149 let Ok(boundary) = multer::parse_boundary(&content_type) else {
1150 return Err(notice_redirect(&href, "asset-invalid").into());
1151 };
1152 let release = match state.store.release(&id).await? {
1153 Some(release) if release.repo_id == ctx.repo.id => release,
1154 _ => return Err(not_found().into()),
1155 };
1156
1157 let mut multipart = multer::Multipart::new(body.into_data_stream(), boundary);
1158 let mut uploader: Option<User> = None;
1159 let mut outcome: &'static str = "asset-empty";
1160 loop {
1161 let mut field = match multipart.next_field().await {
1162 Ok(Some(field)) => field,
1163 Ok(None) => break,
1164 Err(_) => {
1165 outcome = "asset-invalid";
1166 break;
1167 }
1168 };
1169 match field.name() {
1170 Some("csrf") => {
1171 let token = field.text().await.unwrap_or_default();
1172 let (user, _) = require_csrf(cx, &token).await?;
1173 require_write(&ctx)?;
1174 uploader = Some(user);
1175 }
1176 Some("file") => {
1177 let Some(user) = uploader.as_ref() else {
1178 return Err(forbidden().into());
1179 };
1180 outcome = store_upload(state, &release.id, user, &mut field, max_bytes).await?;
1181 break;
1182 }
1183 _ => {
1184 let _ = field.bytes().await;
1185 }
1186 }
1187 }
1188 Err(notice_redirect(&href, outcome).into())
1189}
1190
1191/// Streams one `file` field into a blob and adds its asset row. Returns the notice word for the
1192/// outcome. Storage failures are errors; everything the client can cause is a notice.
1193async fn store_upload(
1194 state: &AppState,
1195 release_id: &str,
1196 user: &User,
1197 field: &mut multer::Field<'_>,
1198 max_bytes: u64,
1199) -> Result<&'static str> {
1200 let file_name = field.file_name().map(str::to_owned).unwrap_or_default();
1201 if file_name.is_empty() {
1202 return Ok("asset-empty");
1203 }
1204 if !valid_asset_name(&file_name) {
1205 return Ok("asset-name");
1206 }
1207 if state
1208 .store
1209 .release_asset(release_id, &file_name)
1210 .await?
1211 .is_some()
1212 {
1213 return Ok("asset-exists");
1214 }
1215 let mime = field.content_type().map(|m| m.to_string());
1216 let content_type = clean_content_type(mime.as_deref().and_then(|m| m.split(';').next()));
1217 let asset_id = ulid::Ulid::generate().to_string();
1218 let key = asset_key(&asset_id);
1219 let mut writer = state.blobs.begin(&key, max_bytes).await?;
1220 match copy_field(field, &mut writer).await {
1221 Ok(()) => {}
1222 Err(UploadFailure::TooLarge) => {
1223 writer.abort().await;
1224 return Ok("asset-too-large");
1225 }
1226 Err(UploadFailure::Unreadable) => {
1227 writer.abort().await;
1228 return Ok("asset-invalid");
1229 }
1230 Err(UploadFailure::Storage(e)) => {
1231 writer.abort().await;
1232 return Err(e.into());
1233 }
1234 }
1235 let (size, sha256) = writer.finish().await?;
1236 let added = state
1237 .store
1238 .add_asset(&NewAsset {
1239 id: &asset_id,
1240 release_id,
1241 name: &file_name,
1242 size,
1243 sha256: &sha256,
1244 content_type: &content_type,
1245 uploader_id: &user.id,
1246 })
1247 .await;
1248 match added {
1249 Ok(_) => Ok("asset-uploaded"),
1250 Err(e) => {
1251 if let Err(del) = state.blobs.delete(&key).await {
1252 tracing::error!(error = %del, "releases: could not remove an unlinked blob");
1253 }
1254 match user_facing_message(&e) {
1255 Some(_) => Ok("asset-exists"),
1256 None => Err(e.into()),
1257 }
1258 }
1259 }
1260}
1261
1262/// Streams a release asset as an attachment and counts the download. Drafts are visible only to
1263/// people who can write; anyone else gets a 404.
1264#[route(GET "/{owner}/{repo}/releases/download/{tag}/{name}")]
1265async fn asset_download(cx: &Cx) -> Result<Response> {
1266 let owner = path_param::<Owner>(cx);
1267 let repo_name = path_param::<Repo>(cx);
1268 let tag_param = path_param::<Tag>(cx).to_owned();
1269 let file_name = path_param::<Name>(cx).to_owned();
1270 let ctx = load_repo(cx, owner, repo_name).await?;
1271 let state = app_context::<AppState>(cx);
1272 let Some(release) = state.store.release_by_tag(&ctx.repo.id, &tag_param).await? else {
1273 return Err(not_found().into());
1274 };
1275 if release.draft && !can_write(&ctx) {
1276 return Err(not_found().into());
1277 }
1278 let Some(asset) = state.store.release_asset(&release.id, &file_name).await? else {
1279 return Err(not_found().into());
1280 };
1281 let Some(read) = state.blobs.read(&asset_key(&asset.id)).await? else {
1282 tracing::error!(asset_id = %asset.id, "releases: asset row without a blob");
1283 return Err(not_found().into());
1284 };
1285 if let Err(e) = state.store.count_asset_download(&asset.id).await {
1286 tracing::error!(error = %e, asset_id = %asset.id, "releases: could not count a download");
1287 }
1288 let chunks = read.stream.map_ok(Frame::data);
1289 let mut res = Response::new(Body::new(StreamBody::new(chunks)));
1290 *res.status_mut() = StatusCode::OK;
1291 let headers = res.headers_mut();
1292 if let Ok(value) = HeaderValue::from_str(&asset.content_type) {
1293 headers.insert(CONTENT_TYPE, value);
1294 }
1295 headers.insert(CONTENT_LENGTH, HeaderValue::from(read.size));
1296 if let Ok(value) = HeaderValue::from_str(&format!("attachment; filename=\"{}\"", asset.name)) {
1297 headers.insert(CONTENT_DISPOSITION, value);
1298 }
1299 headers.insert(
1300 HeaderName::from_static("x-content-type-options"),
1301 HeaderValue::from_static("nosniff"),
1302 );
1303 headers.insert(
1304 HeaderName::from_static("cache-control"),
1305 HeaderValue::from_static("private, no-cache"),
1306 );
1307 Ok(res)
1308}