forge
ExploreLog in

Tahinli/forge

public
0 watching
★ 0
28.0 KiB
1//! Pages: the repo wiki. `/{owner}/{repo}/wiki` shows `Home`; `/wiki/{page}` shows a page,
2//! `/wiki/{page}/edit` edits it, `/wiki/{page}/history` lists its commits, `/wiki/-/pages` lists all
3//! pages and `/wiki/-/new` creates one. Pages are `<Name>.md` files on the wiki's `main` branch (see
4//! forge-git `wiki.rs`). Reading needs repo Read; editing needs repo Write, and the wiki must be on.
5//! Saves are commits made with plumbing and guarded by the head the editor started from, so a
6//! concurrent edit is shown as a conflict rather than lost. `[[Page]]` links resolve to pages.
7
8use std::path::PathBuf;
9
10use forge_core::AuditAction;
11use forge_core::tz::format_in_tz;
12use forge_git::repo::wiki_path;
13use forge_git::wiki::{self, MAX_PAGE_BYTES, PageEdit, PageRevision, valid_page_name};
14use serde::Deserialize;
15use topcoat::Result;
16use topcoat::context::{Cx, app_context};
17use topcoat::router::content::Form;
18use topcoat::router::error::{SeeOther, forbidden, not_found, see_other};
19use topcoat::router::{page, path_param, route};
20use topcoat::view::{Unescaped, View, component, view};
21
22use crate::audit::{self, Actor, Target};
23use crate::auth::{require_csrf, require_user};
24use crate::i18n::community::{self as g, K};
25use crate::i18n::{self, Lang};
26use crate::pages::repo::repo_head;
27use crate::pages::repo_ctx::{RepoCtx, load_repo};
28use crate::render::markdown::render_markdown;
29use crate::state::AppState;
30use crate::views::{alert, repo_href, shell};
31
32path_param!(owner);
33path_param!(repo);
34path_param!(wiki_page);
35
36/// Longest commit summary accepted for an edit, in characters.
37const MAX_SUMMARY_CHARS: usize = 200;
38
39#[derive(Deserialize)]
40struct EditForm {
41 #[serde(default)]
42 csrf: String,
43 /// Head the editor started from. Empty when the wiki had no pages.
44 #[serde(default)]
45 base: String,
46 /// Page name, used by the new-page form only.
47 #[serde(default)]
48 name: String,
49 #[serde(default)]
50 content: String,
51 #[serde(default)]
52 message: String,
53}
54
55/// Runs a blocking git read or write on the blocking pool.
56async fn blocking<T, F>(f: F) -> Result<T>
57where
58 F: FnOnce() -> forge_git::Result<T> + Send + 'static,
59 T: Send + 'static,
60{
61 Ok(tokio::task::spawn_blocking(f).await??)
62}
63
64/// The wiki directory of the repo, whether or not it exists yet.
65fn wiki_dir(state: &AppState, ctx: &RepoCtx) -> Result<PathBuf> {
66 Ok(wiki_path(&state.config.data_dir, &ctx.repo.id).map_err(forge_core::Error::from)?)
67}
68
69/// 404 unless the repo's wiki is on. The header tab is hidden in the same case.
70async fn ensure_enabled(state: &AppState, ctx: &RepoCtx) -> Result<()> {
71 if state.store.wiki_enabled(&ctx.repo.id).await? {
72 Ok(())
73 } else {
74 Err(not_found().into())
75 }
76}
77
78/// The `[[Page]]` and `[[Label|Page]]` links in `src` become Markdown links to the wiki. A link
79/// whose target is not a valid page name is left as it is.
80pub fn expand_wiki_links(src: &str, owner: &str, repo: &str) -> String {
81 let mut out = String::with_capacity(src.len());
82 let mut rest = src;
83 while let Some(start) = rest.find("[[") {
84 out.push_str(&rest[..start]);
85 let after = &rest[start + 2..];
86 let Some(end) = after.find("]]") else {
87 break;
88 };
89 let inner = &after[..end];
90 let (label, target) = match inner.split_once('|') {
91 Some((label, target)) => (label.trim(), target.trim()),
92 None => (inner.trim(), inner.trim()),
93 };
94 let slug = target.replace(' ', "-");
95 let plain = !label.contains(['[', ']']) && !label.is_empty();
96 if plain && valid_page_name(&slug) {
97 let href = repo_href(owner, repo, "wiki", &slug);
98 out.push_str(&format!("[{label}]({href})"));
99 } else {
100 out.push_str(&rest[start..start + 2 + end + 2]);
101 }
102 rest = &after[end + 2..];
103 }
104 out.push_str(rest);
105 out
106}
107
108/// The page's Markdown as trusted HTML, with wiki links expanded.
109pub fn render_page(src: &str, owner: &str, repo: &str) -> String {
110 render_markdown(&expand_wiki_links(src, owner, repo), None)
111}
112
113/// Commit summary: one line, trimmed, at most [`MAX_SUMMARY_CHARS`] characters. Blank means a default.
114fn summary(raw: &str, page: &str) -> String {
115 let line = raw.lines().next().unwrap_or("").trim();
116 if line.is_empty() {
117 return format!("Edit {page}");
118 }
119 line.chars().take(MAX_SUMMARY_CHARS).collect()
120}
121
122fn page_href(ctx: &RepoCtx, page: &str) -> String {
123 repo_href(&ctx.repo.owner_name, &ctx.repo.name, "wiki", page)
124}
125
126/// A row of the history list.
127pub struct RevisionRow {
128 pub short: String,
129 pub href: String,
130 pub author: String,
131 pub when: String,
132 pub subject: String,
133}
134
135fn revision_rows(ctx: &RepoCtx, revs: &[PageRevision], tz: &str) -> Vec<RevisionRow> {
136 revs.iter()
137 .map(|rev| RevisionRow {
138 short: rev.oid.chars().take(10).collect(),
139 href: repo_href(&ctx.repo.owner_name, &ctx.repo.name, "commit", &rev.oid),
140 author: rev.author.clone(),
141 when: format_in_tz(rev.at, tz),
142 subject: rev.subject.clone(),
143 })
144 .collect()
145}
146
147#[component]
148async fn wiki_nav(lang: Lang, owner: String, repo: String, can_write: bool) -> Result<impl View> {
149 let home = repo_href(&owner, &repo, "wiki", "");
150 let pages = repo_href(&owner, &repo, "wiki", "-/pages");
151 let new = repo_href(&owner, &repo, "wiki", "-/new");
152 Ok(view! {
153 <nav class="wiki-nav" aria-label=(g::t(lang, K::Wiki))>
154 <a href=(home)>(g::t(lang, K::Home))</a>
155 <a href=(pages)>(g::t(lang, K::AllPages))</a>
156 if can_write {
157 <a href=(new)>(g::t(lang, K::NewPage))</a>
158 }
159 </nav>
160 })
161}
162
163#[component]
164async fn wiki_view_body(
165 lang: Lang,
166 owner: String,
167 repo: String,
168 name: String,
169 html: String,
170 can_write: bool,
171) -> Result<impl View> {
172 let edit = repo_href(&owner, &repo, "wiki", &format!("{name}/edit"));
173 let history = repo_href(&owner, &repo, "wiki", &format!("{name}/history"));
174 let title = name.replace('-', " ");
175 Ok(view! {
176 <section class="wiki-page">
177 <header class="wiki-head">
178 <h1>(title.as_str())</h1>
179 <nav class="file-actions" aria-label=(g::t(lang, K::PageNav))>
180 if can_write {
181 <a href=(edit)>(g::t(lang, K::Edit))</a>
182 }
183 <a href=(history)>(g::t(lang, K::History))</a>
184 </nav>
185 </header>
186 <div class="markdown-body">(Unescaped::new_unchecked(html))</div>
187 </section>
188 })
189}
190
191#[component]
192async fn wiki_empty_body(
193 lang: Lang,
194 owner: String,
195 repo: String,
196 can_write: bool,
197) -> Result<impl View> {
198 let new = repo_href(&owner, &repo, "wiki", "-/new");
199 Ok(view! {
200 <section class="wiki-page">
201 <h1>(g::t(lang, K::Wiki))</h1>
202 <p class="notice">(g::t(lang, K::WikiNoPages))</p>
203 if can_write {
204 <p><a href=(new)>(g::t(lang, K::CreateFirstPage))</a></p>
205 }
206 </section>
207 })
208}
209
210#[component]
211async fn wiki_list_body(
212 lang: Lang,
213 owner: String,
214 repo: String,
215 names: Vec<String>,
216 can_write: bool,
217) -> Result<impl View> {
218 let new = repo_href(&owner, &repo, "wiki", "-/new");
219 let rows: Vec<(String, String)> = names
220 .into_iter()
221 .map(|name| {
222 let href = repo_href(&owner, &repo, "wiki", &name);
223 (name.replace('-', " "), href)
224 })
225 .collect();
226 Ok(view! {
227 <section class="wiki-page">
228 <h1>(g::t(lang, K::AllPages))</h1>
229 if can_write {
230 <p><a href=(new)>(g::t(lang, K::NewPage))</a></p>
231 }
232 if rows.is_empty() {
233 <p class="notice">(g::t(lang, K::NoPagesYet))</p>
234 }
235 <ul class="wiki-pages">
236 for (title, href) in rows.into_iter() {
237 <li><a href=(href)>(title.as_str())</a></li>
238 }
239 </ul>
240 </section>
241 })
242}
243
244#[component]
245async fn wiki_history_body(
246 lang: Lang,
247 owner: String,
248 repo: String,
249 name: String,
250 rows: Vec<RevisionRow>,
251) -> Result<impl View> {
252 let back = repo_href(&owner, &repo, "wiki", &name);
253 let title = name.replace('-', " ");
254 Ok(view! {
255 <section class="wiki-page">
256 <header class="wiki-head">
257 <h1>(title.as_str())</h1>
258 <nav class="file-actions" aria-label=(g::t(lang, K::PageNav))><a href=(back)>(g::t(lang, K::BackToPage))</a></nav>
259 </header>
260 if rows.is_empty() {
261 <p class="notice">(g::t(lang, K::NoChangesYet))</p>
262 }
263 <table class="wiki-history">
264 <tbody>
265 for row in rows.into_iter() {
266 <tr>
267 <td><a href=(row.href)><code>(row.short.as_str())</code></a></td>
268 <td>(row.subject.as_str())</td>
269 <td class="muted small">(row.author.as_str())</td>
270 <td class="muted small">(row.when.as_str())</td>
271 </tr>
272 }
273 </tbody>
274 </table>
275 </section>
276 })
277}
278
279#[component]
280async fn wiki_edit_body(
281 lang: Lang,
282 action: String,
283 csrf: String,
284 base: String,
285 name: String,
286 is_new: bool,
287 content: String,
288 error: Option<String>,
289) -> Result<impl View> {
290 let title = if is_new {
291 g::t(lang, K::NewPage).to_owned()
292 } else {
293 name.replace('-', " ")
294 };
295 Ok(view! {
296 <section class="wiki-page">
297 <h1>(title.as_str())</h1>
298 <form method="post" action=(action.as_str()) class="stack wiki-form">
299 if let Some(message) = error {
300 alert(message: message.as_str())
301 }
302 <input type="hidden" name="csrf" value=(csrf.as_str())>
303 <input type="hidden" name="base" value=(base.as_str())>
304 if is_new {
305 <label>
306 <span>(g::t(lang, K::PageName))</span>
307 <input type="text" name="name" value=(name.as_str()) maxlength="100" required=(true) pattern="[A-Za-z0-9_-]+">
308 <small class="muted">(g::t(lang, K::PageNameHint))</small>
309 </label>
310 }
311 <label>
312 <span>(g::t(lang, K::ContentMarkdown))</span>
313 <textarea name="content" rows="24" spellcheck="false">(content.as_str())</textarea>
314 <small class="muted">(g::t(lang, K::WikiLinkHint))</small>
315 </label>
316 <label>
317 <span>(g::t(lang, K::Summary))</span>
318 <input type="text" name="message" maxlength="200" autocomplete="off">
319 </label>
320 <button type="submit" class="primary">(g::t(lang, K::SavePage))</button>
321 </form>
322 </section>
323 })
324}
325
326/// Reads the page `name` and the wiki head in one blocking call.
327async fn load_page(dir: PathBuf, name: String) -> Result<(Option<String>, Option<String>)> {
328 blocking(move || {
329 let head = wiki::head_commit(&dir)?;
330 let text = wiki::read_page(&dir, &name)?;
331 Ok((head, text))
332 })
333 .await
334}
335
336#[page("/{owner}/{repo}/wiki")]
337async fn wiki_home(cx: &Cx) -> Result<impl View> {
338 let owner = path_param::<Owner>(cx);
339 let name = path_param::<Repo>(cx);
340 let ctx = load_repo(cx, owner, name).await?;
341 let state = app_context::<AppState>(cx);
342 ensure_enabled(state, &ctx).await?;
343 let dir = wiki_dir(state, &ctx)?;
344 let (_, home) = load_page(dir, "Home".to_owned()).await?;
345 let can_write = ctx.can_write();
346 let owner_name = ctx.repo.owner_name.clone();
347 let repo_name = ctx.repo.name.clone();
348 let body_html = home.map(|src| render_page(&src, &owner_name, &repo_name));
349 let lang = i18n::lang(cx, ctx.viewer.as_ref().map(|(user, _)| user));
350 let title = format!(
351 "{}/{} \u{b7} {}",
352 ctx.repo.owner_name,
353 ctx.repo.name,
354 g::t(lang, K::Wiki)
355 );
356 let header = ctx.head(state, "wiki").await?;
357 Ok(view! { cx =>
358 shell(title: title.as_str(), viewer: ctx.viewer.as_ref(),
359 repo_head(head: header)
360 wiki_nav(lang: lang, owner: owner_name.clone(), repo: repo_name.clone(), can_write: can_write)
361 if let Some(html) = body_html.clone() {
362 wiki_view_body(lang: lang, owner: owner_name.clone(), repo: repo_name.clone(), name: "Home".to_owned(), html: html, can_write: can_write)
363 }
364 if body_html.is_none() {
365 wiki_empty_body(lang: lang, owner: owner_name.clone(), repo: repo_name.clone(), can_write: can_write)
366 }
367 )
368 })
369}
370
371#[page("/{owner}/{repo}/wiki/-/pages")]
372async fn wiki_pages(cx: &Cx) -> Result<impl View> {
373 let owner = path_param::<Owner>(cx);
374 let name = path_param::<Repo>(cx);
375 let ctx = load_repo(cx, owner, name).await?;
376 let state = app_context::<AppState>(cx);
377 ensure_enabled(state, &ctx).await?;
378 let lang = i18n::lang(cx, ctx.viewer.as_ref().map(|(user, _)| user));
379 let dir = wiki_dir(state, &ctx)?;
380 let names = blocking(move || wiki::list_pages(&dir)).await?;
381 let can_write = ctx.can_write();
382 let owner_name = ctx.repo.owner_name.clone();
383 let repo_name = ctx.repo.name.clone();
384 let title = format!(
385 "{}/{} \u{b7} {}",
386 ctx.repo.owner_name,
387 ctx.repo.name,
388 g::t(lang, K::WikiPages)
389 );
390 let header = ctx.head(state, "wiki").await?;
391 Ok(view! { cx =>
392 shell(title: title.as_str(), viewer: ctx.viewer.as_ref(),
393 repo_head(head: header)
394 wiki_nav(lang: lang, owner: owner_name.clone(), repo: repo_name.clone(), can_write: can_write)
395 wiki_list_body(lang: lang, owner: owner_name.clone(), repo: repo_name.clone(), names: names, can_write: can_write)
396 )
397 })
398}
399
400#[page("/{owner}/{repo}/wiki/{wiki_page}")]
401async fn wiki_page_view(cx: &Cx) -> Result<impl View> {
402 let owner = path_param::<Owner>(cx);
403 let repo_param = path_param::<Repo>(cx);
404 let page_name = path_param::<WikiPage>(cx).to_owned();
405 let ctx = load_repo(cx, owner, repo_param).await?;
406 let state = app_context::<AppState>(cx);
407 ensure_enabled(state, &ctx).await?;
408 if !valid_page_name(&page_name) {
409 return Err(not_found().into());
410 }
411 let dir = wiki_dir(state, &ctx)?;
412 let (_, text) = load_page(dir, page_name.clone()).await?;
413 let Some(text) = text else {
414 return Err(not_found().into());
415 };
416 let owner_name = ctx.repo.owner_name.clone();
417 let repo_name = ctx.repo.name.clone();
418 let html = render_page(&text, &owner_name, &repo_name);
419 let can_write = ctx.can_write();
420 let lang = i18n::lang(cx, ctx.viewer.as_ref().map(|(user, _)| user));
421 let title = format!(
422 "{} \u{b7} {}/{} wiki",
423 page_name.replace('-', " "),
424 ctx.repo.owner_name,
425 ctx.repo.name
426 );
427 let header = ctx.head(state, "wiki").await?;
428 Ok(view! { cx =>
429 shell(title: title.as_str(), viewer: ctx.viewer.as_ref(),
430 repo_head(head: header)
431 wiki_nav(lang: lang, owner: owner_name.clone(), repo: repo_name.clone(), can_write: can_write)
432 wiki_view_body(lang: lang, owner: owner_name.clone(), repo: repo_name.clone(), name: page_name.clone(), html: html, can_write: can_write)
433 )
434 })
435}
436
437#[page("/{owner}/{repo}/wiki/{wiki_page}/history")]
438async fn wiki_page_history(cx: &Cx) -> Result<impl View> {
439 let owner = path_param::<Owner>(cx);
440 let repo_param = path_param::<Repo>(cx);
441 let page_name = path_param::<WikiPage>(cx).to_owned();
442 let ctx = load_repo(cx, owner, repo_param).await?;
443 let state = app_context::<AppState>(cx);
444 ensure_enabled(state, &ctx).await?;
445 if !valid_page_name(&page_name) {
446 return Err(not_found().into());
447 }
448 let dir = wiki_dir(state, &ctx)?;
449 let git = state.git.clone();
450 let revs = wiki::page_history(&git, &dir, &page_name).await?;
451 let tz = ctx.viewer_tz().to_owned();
452 let rows = revision_rows(&ctx, &revs, &tz);
453 let owner_name = ctx.repo.owner_name.clone();
454 let repo_name = ctx.repo.name.clone();
455 let lang = i18n::lang(cx, ctx.viewer.as_ref().map(|(user, _)| user));
456 let title = format!(
457 "{} \u{b7} {}/{} wiki",
458 g::t(lang, K::History),
459 ctx.repo.owner_name,
460 ctx.repo.name
461 );
462 let header = ctx.head(state, "wiki").await?;
463 Ok(view! { cx =>
464 shell(title: title.as_str(), viewer: ctx.viewer.as_ref(),
465 repo_head(head: header)
466 wiki_nav(lang: lang, owner: owner_name.clone(), repo: repo_name.clone(), can_write: ctx.can_write())
467 wiki_history_body(lang: lang, owner: owner_name.clone(), repo: repo_name.clone(), name: page_name.clone(), rows: rows)
468 )
469 })
470}
471
472/// The edit screen for `name` (existing or new). Writers only.
473async fn edit_screen(
474 cx: &Cx,
475 ctx: RepoCtx,
476 name: String,
477 is_new: bool,
478 content: String,
479 error: Option<String>,
480) -> Result<impl View> {
481 let state = app_context::<AppState>(cx);
482 let (user, session) = require_user(cx).await?;
483 let lang = i18n::lang(cx, Some(&user));
484 let dir = wiki_dir(state, &ctx)?;
485 let head = blocking(move || wiki::head_commit(&dir)).await?;
486 let action = if is_new {
487 repo_href(&ctx.repo.owner_name, &ctx.repo.name, "wiki", "-/new")
488 } else {
489 repo_href(
490 &ctx.repo.owner_name,
491 &ctx.repo.name,
492 "wiki",
493 &format!("{name}/edit"),
494 )
495 };
496 let title = format!(
497 "{} \u{b7} {}/{} wiki",
498 g::t(lang, K::Edit),
499 ctx.repo.owner_name,
500 ctx.repo.name
501 );
502 let header = ctx.head(state, "wiki").await?;
503 let owner_name = ctx.repo.owner_name.clone();
504 let repo_name = ctx.repo.name.clone();
505 let csrf = session.csrf_token.clone();
506 let base = head.unwrap_or_default();
507 Ok(view! { cx =>
508 shell(title: title.as_str(), viewer: ctx.viewer.as_ref(),
509 repo_head(head: header)
510 wiki_nav(lang: lang, owner: owner_name.clone(), repo: repo_name.clone(), can_write: true)
511 wiki_edit_body(lang: lang, action: action, csrf: csrf, base: base, name: name.clone(), is_new: is_new, content: content, error: error)
512 )
513 })
514}
515
516#[page("/{owner}/{repo}/wiki/-/new")]
517async fn wiki_new_page(cx: &Cx) -> Result<impl View> {
518 let owner = path_param::<Owner>(cx);
519 let repo_param = path_param::<Repo>(cx);
520 let ctx = load_repo(cx, owner, repo_param).await?;
521 let state = app_context::<AppState>(cx);
522 ensure_enabled(state, &ctx).await?;
523 if !ctx.can_write() {
524 return Err(forbidden().into());
525 }
526 edit_screen(cx, ctx, String::new(), true, String::new(), None).await
527}
528
529#[page("/{owner}/{repo}/wiki/{wiki_page}/edit")]
530async fn wiki_edit_page(cx: &Cx) -> Result<impl View> {
531 let owner = path_param::<Owner>(cx);
532 let repo_param = path_param::<Repo>(cx);
533 let page_name = path_param::<WikiPage>(cx).to_owned();
534 let ctx = load_repo(cx, owner, repo_param).await?;
535 let state = app_context::<AppState>(cx);
536 ensure_enabled(state, &ctx).await?;
537 if !ctx.can_write() {
538 return Err(forbidden().into());
539 }
540 if !valid_page_name(&page_name) {
541 return Err(not_found().into());
542 }
543 let dir = wiki_dir(state, &ctx)?;
544 let (_, text) = load_page(dir, page_name.clone()).await?;
545 edit_screen(cx, ctx, page_name, false, text.unwrap_or_default(), None).await
546}
547
548#[page(POST "/{owner}/{repo}/wiki/-/new")]
549async fn wiki_new_post(cx: &Cx, Form(form): Form<EditForm>) -> Result<impl View> {
550 let owner = path_param::<Owner>(cx);
551 let repo_param = path_param::<Repo>(cx);
552 let ctx = load_repo(cx, owner, repo_param).await?;
553 let state = app_context::<AppState>(cx);
554 ensure_enabled(state, &ctx).await?;
555 if !ctx.can_write() {
556 return Err(forbidden().into());
557 }
558 let (viewer, _) = require_csrf(cx, &form.csrf).await?;
559 let lang = i18n::lang(cx, Some(&viewer));
560 let name = form.name.trim().to_owned();
561 let refused = if !valid_page_name(&name) {
562 Some(g::t(lang, K::PageNameRules).to_owned())
563 } else {
564 let dir = wiki_dir(state, &ctx)?;
565 let (_, existing) = load_page(dir, name.clone()).await?;
566 if existing.is_some() {
567 Some(g::t(lang, K::PageExists).to_owned())
568 } else {
569 None
570 }
571 };
572 let refused = match refused {
573 Some(message) => Some(message),
574 None => match save(cx, &ctx, &form, &name).await? {
575 SaveOutcome::Saved(href) => return Err(see_other(href).into()),
576 SaveOutcome::Refused(message) => Some(message),
577 },
578 };
579 edit_screen(cx, ctx, name, true, form.content.clone(), refused).await
580}
581
582#[page(POST "/{owner}/{repo}/wiki/{wiki_page}/edit")]
583async fn wiki_edit_post(cx: &Cx, Form(form): Form<EditForm>) -> Result<impl View> {
584 let owner = path_param::<Owner>(cx);
585 let repo_param = path_param::<Repo>(cx);
586 let page_name = path_param::<WikiPage>(cx).to_owned();
587 let ctx = load_repo(cx, owner, repo_param).await?;
588 let state = app_context::<AppState>(cx);
589 ensure_enabled(state, &ctx).await?;
590 if !ctx.can_write() {
591 return Err(forbidden().into());
592 }
593 require_csrf(cx, &form.csrf).await?;
594 if !valid_page_name(&page_name) {
595 return Err(not_found().into());
596 }
597 let refused = match save(cx, &ctx, &form, &page_name).await? {
598 SaveOutcome::Saved(href) => return Err(see_other(href).into()),
599 SaveOutcome::Refused(message) => Some(message),
600 };
601 edit_screen(cx, ctx, page_name, false, form.content.clone(), refused).await
602}
603
604/// How a save ended: redirected to the page, or refused with a message for the editor.
605enum SaveOutcome {
606 Saved(String),
607 Refused(String),
608}
609
610/// Writes the page as a commit guarded by the form's `base`. A conflicting or invalid save is
611/// refused with a message, and the caller shows the edit screen again with the typed text.
612async fn save(cx: &Cx, ctx: &RepoCtx, form: &EditForm, name: &str) -> Result<SaveOutcome> {
613 let state = app_context::<AppState>(cx);
614 let (user, _) = require_user(cx).await?;
615 let lang = i18n::lang(cx, Some(&user));
616 if form.content.len() > MAX_PAGE_BYTES {
617 return Ok(SaveOutcome::Refused(g::page_too_large(
618 lang,
619 MAX_PAGE_BYTES,
620 )));
621 }
622 let dir = wiki_dir(state, ctx)?;
623 let base = form.base.trim();
624 let base = (!base.is_empty()).then_some(base.to_owned());
625 let message = summary(&form.message, name);
626 let author_email = user
627 .email
628 .clone()
629 .unwrap_or_else(|| format!("{}@users.noreply.forge", user.username));
630 let edit = PageEdit {
631 name,
632 content: form.content.as_bytes(),
633 message: &message,
634 author_name: &user.username,
635 author_email: &author_email,
636 };
637 let git = state.git.clone();
638 let commit = match wiki::commit_page(&git, &dir, base.as_deref(), &edit).await {
639 Ok(commit) => commit,
640 Err(forge_git::Error::RefMoved(_)) => {
641 return Ok(SaveOutcome::Refused(g::t(lang, K::WikiConflict).to_owned()));
642 }
643 Err(forge_git::Error::InvalidInput(msg)) => return Ok(SaveOutcome::Refused(msg)),
644 Err(e) => return Err(forge_core::Error::from(e).into()),
645 };
646 audit::record(
647 &state.store,
648 Some(cx),
649 Actor::User(&user),
650 AuditAction::WikiPageSaved,
651 Target {
652 kind: "repo",
653 id: &ctx.repo.id,
654 name: &format!("{}/{}", ctx.repo.owner_name, ctx.repo.name),
655 },
656 serde_json::json!({ "page": name, "commit": commit }),
657 )
658 .await;
659 crate::replication::enqueue_repo(state, &ctx.repo.id);
660 tracing::info!(actor_id = %user.id, repo_id = %ctx.repo.id, page = %name, "wiki page saved");
661 Ok(SaveOutcome::Saved(page_href(ctx, name)))
662}
663
664#[component]
665async fn wiki_settings_body(
666 lang: Lang,
667 action: String,
668 csrf: String,
669 enabled: bool,
670) -> Result<impl View> {
671 Ok(view! {
672 <section class="wiki-page">
673 <h1>(g::t(lang, K::WikiSettings))</h1>
674 <form method="post" action=(action.as_str()) class="stack wiki-form">
675 <input type="hidden" name="csrf" value=(csrf.as_str())>
676 <label class="checkbox">
677 <input type="checkbox" name="enabled" value="1" checked=(enabled)>
678 <span>(g::t(lang, K::EnableWiki))</span>
679 </label>
680 <small class="muted">(g::t(lang, K::WikiDisabledHint))</small>
681 <button type="submit" class="primary">(g::t(lang, K::Save))</button>
682 </form>
683 </section>
684 })
685}
686
687#[page("/{owner}/{repo}/settings/wiki")]
688async fn wiki_settings_page(cx: &Cx) -> Result<impl View> {
689 let owner = path_param::<Owner>(cx);
690 let repo_param = path_param::<Repo>(cx);
691 let ctx = load_repo(cx, owner, repo_param).await?;
692 let (user, session) = require_user(cx).await?;
693 if !ctx.can_admin() {
694 return Err(not_found().into());
695 }
696 let lang = i18n::lang(cx, Some(&user));
697 let state = app_context::<AppState>(cx);
698 let enabled = state.store.wiki_enabled(&ctx.repo.id).await?;
699 let title = format!(
700 "{}/{} \u{b7} {}",
701 ctx.repo.owner_name,
702 ctx.repo.name,
703 g::t(lang, K::WikiSettings)
704 );
705 let header = ctx.head(state, "settings").await?;
706 let action = repo_href(&ctx.repo.owner_name, &ctx.repo.name, "settings/wiki", "");
707 let csrf = session.csrf_token.clone();
708 Ok(view! { cx =>
709 shell(title: title.as_str(), viewer: ctx.viewer.as_ref(),
710 repo_head(head: header)
711 wiki_settings_body(lang: lang, action: action, csrf: csrf, enabled: enabled)
712 )
713 })
714}
715
716#[derive(Deserialize)]
717struct WikiSettingsForm {
718 #[serde(default)]
719 csrf: String,
720 #[serde(default)]
721 enabled: Option<String>,
722}
723
724#[route(POST "/{owner}/{repo}/settings/wiki")]
725async fn wiki_settings_post(cx: &Cx, Form(form): Form<WikiSettingsForm>) -> Result<SeeOther> {
726 let owner = path_param::<Owner>(cx);
727 let repo_param = path_param::<Repo>(cx);
728 let ctx = load_repo(cx, owner, repo_param).await?;
729 let (user, _) = require_csrf(cx, &form.csrf).await?;
730 if !ctx.can_admin() {
731 return Err(forbidden().into());
732 }
733 let state = app_context::<AppState>(cx);
734 let enable = form.enabled.is_some();
735 let before = state.store.wiki_enabled(&ctx.repo.id).await?;
736 state.store.set_wiki_enabled(&ctx.repo.id, enable).await?;
737 if before != enable {
738 let action = if enable {
739 AuditAction::WikiEnabled
740 } else {
741 AuditAction::WikiDisabled
742 };
743 audit::record(
744 &state.store,
745 Some(cx),
746 Actor::User(&user),
747 action,
748 Target {
749 kind: "repo",
750 id: &ctx.repo.id,
751 name: &format!("{}/{}", ctx.repo.owner_name, ctx.repo.name),
752 },
753 serde_json::json!({ "enabled": enable }),
754 )
755 .await;
756 }
757 Ok(see_other(repo_href(
758 &ctx.repo.owner_name,
759 &ctx.repo.name,
760 "settings/wiki",
761 "",
762 )))
763}
764
765#[cfg(test)]
766mod tests {
767 use super::*;
768
769 #[test]
770 fn wiki_links_resolve_and_invalid_targets_stay_text() {
771 let out = expand_wiki_links(
772 "See [[Home]] and [[Getting Started|Start]].",
773 "alice",
774 "proj",
775 );
776 assert!(out.contains("[Home](/alice/proj/wiki/Home)"), "{out}");
777 assert!(
778 out.contains("[Getting Started](/alice/proj/wiki/Start)"),
779 "{out}"
780 );
781 let spaced = expand_wiki_links("[[Getting Started]]", "alice", "proj");
782 assert_eq!(
783 spaced,
784 "[Getting Started](/alice/proj/wiki/Getting-Started)"
785 );
786 let bad = expand_wiki_links("[[../etc]] and [[]]", "alice", "proj");
787 assert_eq!(bad, "[[../etc]] and [[]]");
788 }
789
790 #[test]
791 fn summary_defaults_and_is_one_line() {
792 assert_eq!(summary(" ", "Home"), "Edit Home");
793 assert_eq!(summary("first\nsecond", "Home"), "first");
794 }
795}