Jump to…
snowfeat: given a new redesign and emulated terminal homepagerxkspqmsoknz1mo
1//! Repository settings, collaborators, bookmark protection, archive and delete
2//! (spec §7).
3//!
4//! Every handler here starts with `RepoContext::load` — which authorizes *read*
5//! and 404s otherwise — and then calls `require_settings` or `require_delete`.
6//! That ordering matters: a stranger poking at a private repo's settings URL
7//! gets the same 404 they get for the repo itself, and only somebody who can
8//! already see it ever receives a 403 (spec §9).
9
10use axum::extract::{Path as UrlPath, Query, State};
11use axum::response::{IntoResponse, Redirect, Response};
12use axum::Form;
13use df_db::models::RepoRole;
14use serde::Deserialize;
15use uuid::Uuid;
16
17use crate::error::{AppError, AppResult};
18use crate::repo_ctx::RepoContext;
19use crate::routes::settings::{audit, urlencode};
20use crate::state::{AppState, CsrfToken, CurrentUser, Nonce};
21use crate::views::settings as v;
22use crate::views::{self, Chrome};
23
24#[derive(Deserialize, Default)]
25pub struct SettingsQuery {
26 pub tab: Option<String>,
27 pub error: Option<String>,
28 pub notice: Option<String>,
29}
30
31/// `GET /{owner}/{repo}/settings`
32pub async fn show(
33 State(state): State<AppState>,
34 UrlPath((owner, name)): UrlPath<(String, String)>,
35 Query(q): Query<SettingsQuery>,
36 CurrentUser(user): CurrentUser,
37 CsrfToken(csrf): CsrfToken,
38 Nonce(nonce): Nonce,
39) -> AppResult<Response> {
40 let ctx = RepoContext::load(&state, &owner, &name, user.as_deref()).await?;
41 ctx.require_settings()?;
42
43 let collaborators = load_collaborators(&state, ctx.repo.id).await?;
44 let bookmarks = load_bookmarks(&state, &ctx).await?;
45 let size_bytes = state
46 .store
47 .size_bytes(ctx.store_id())
48 .await
49 .unwrap_or(ctx.repo.size_bytes.max(0) as u64);
50
51 let tab = q.tab.as_deref().unwrap_or("general").to_owned();
52
53 let body = maud::html! {
54 (v::repo_settings(v::RepoSettings {
55 ctx: &ctx,
56 csrf: &csrf,
57 tab: &tab,
58 collaborators: &collaborators,
59 bookmarks: &bookmarks,
60 size_bytes,
61 error: q.error.as_deref(),
62 notice: q.notice.as_deref(),
63 }))
64 };
65
66 Ok(views::page_with_bar(
67 Chrome {
68 title: &format!("{}/{} settings", ctx.owner, ctx.repo.name),
69 user: user.as_deref(),
70 csrf: &csrf,
71 nonce: &nonce,
72 },
73 crate::views::repo::header(&ctx, "settings"),
74 body,
75 )
76 .into_response())
77}
78
79// ─── general ─────────────────────────────────────────────────────────────────
80
81#[derive(Deserialize)]
82pub struct General {
83 pub description: Option<String>,
84 pub default_bookmark: Option<String>,
85 /// Present only when the checkbox is ticked.
86 pub private: Option<String>,
87}
88
89/// `POST /{owner}/{repo}/settings/general`
90pub async fn update_general(
91 State(state): State<AppState>,
92 UrlPath((owner, name)): UrlPath<(String, String)>,
93 CurrentUser(user): CurrentUser,
94 Form(form): Form<General>,
95) -> AppResult<Response> {
96 let ctx = RepoContext::load(&state, &owner, &name, user.as_deref()).await?;
97 ctx.require_settings()?;
98 let actor = user.as_ref().ok_or(AppError::Unauthorized)?.id;
99
100 let description: Option<String> = form
101 .description
102 .as_deref()
103 .map(str::trim)
104 .filter(|s| !s.is_empty())
105 .map(|s| s.chars().take(500).collect());
106
107 let bookmark = form
108 .default_bookmark
109 .as_deref()
110 .map(str::trim)
111 .filter(|s| !s.is_empty())
112 .unwrap_or(&ctx.repo.default_bookmark);
113
114 // The same validation the create form uses. A settings form is not a more
115 // trusted input than a create form.
116 if !super::repo::valid_bookmark_name(bookmark) {
117 return Ok(err(&ctx, "general", "That is not a valid bookmark name."));
118 }
119
120 let visibility = if form.private.is_some() { "private" } else { "public" };
121
122 sqlx::query(
123 "UPDATE repos SET description = $2, default_bookmark = $3, visibility = $4::visibility
124 WHERE id = $1",
125 )
126 .bind(ctx.repo.id)
127 .bind(&description)
128 .bind(bookmark)
129 .bind(visibility)
130 .execute(&state.db)
131 .await?;
132
133 // Visibility changes are the ones worth an audit entry — they are how a
134 // private repository becomes public, deliberately or otherwise.
135 if visibility != visibility_str(ctx.repo.visibility) {
136 audit(
137 &state,
138 actor,
139 "repo.visibility_changed",
140 &format!("{}/{} -> {visibility}", ctx.owner, ctx.repo.name),
141 )
142 .await;
143 }
144
145 Ok(ok(&ctx, "general", "Settings saved."))
146}
147
148// ─── collaborators ───────────────────────────────────────────────────────────
149
150#[derive(Deserialize)]
151pub struct Collaborator {
152 pub handle: String,
153 pub role: Option<String>,
154}
155
156/// `POST /{owner}/{repo}/settings/collaborators` — add or update.
157pub async fn upsert_collaborator(
158 State(state): State<AppState>,
159 UrlPath((owner, name)): UrlPath<(String, String)>,
160 CurrentUser(user): CurrentUser,
161 Form(form): Form<Collaborator>,
162) -> AppResult<Response> {
163 let ctx = RepoContext::load(&state, &owner, &name, user.as_deref()).await?;
164 ctx.require_settings()?;
165 let actor = user.as_ref().ok_or(AppError::Unauthorized)?.id;
166
167 let Some(role) = form.role.as_deref().and_then(parse_role) else {
168 return Ok(err(&ctx, "collaborators", "Unknown role."));
169 };
170
171 // Granting a role you do not hold yourself is privilege escalation: a
172 // `maintain` collaborator could otherwise make themselves `admin` by adding
173 // themselves again. Capped at the granter's own effective role.
174 let granter = ctx.access.role().unwrap_or(RepoRole::Read);
175 if role > granter {
176 return Ok(err(
177 &ctx,
178 "collaborators",
179 "You cannot grant a role higher than your own.",
180 ));
181 }
182
183 let target: Option<(Uuid,)> = sqlx::query_as("SELECT id FROM users WHERE handle = $1")
184 .bind(form.handle.trim())
185 .fetch_optional(&state.db)
186 .await?;
187
188 let Some((target_id,)) = target else {
189 return Ok(err(&ctx, "collaborators", "No such user."));
190 };
191
192 // The owner already has admin implicitly, and a collaborator row for them
193 // would be a confusing no-op that also could not be removed meaningfully.
194 if ctx.repo.owner_user_id == Some(target_id) {
195 return Ok(err(
196 &ctx,
197 "collaborators",
198 "The owner already has full access.",
199 ));
200 }
201
202 sqlx::query(
203 "INSERT INTO repo_collaborators (repo_id, user_id, role, added_by)
204 VALUES ($1, $2, $3::repo_role, $4)
205 ON CONFLICT (repo_id, user_id) DO UPDATE SET role = EXCLUDED.role",
206 )
207 .bind(ctx.repo.id)
208 .bind(target_id)
209 .bind(v::role_str(role))
210 .bind(actor)
211 .execute(&state.db)
212 .await?;
213
214 audit(
215 &state,
216 actor,
217 "repo.collaborator_set",
218 &format!("{}/{} {} = {}", ctx.owner, ctx.repo.name, form.handle.trim(), v::role_str(role)),
219 )
220 .await;
221
222 Ok(ok(&ctx, "collaborators", "Collaborator saved."))
223}
224
225/// `POST /{owner}/{repo}/settings/collaborators/remove`
226pub async fn remove_collaborator(
227 State(state): State<AppState>,
228 UrlPath((owner, name)): UrlPath<(String, String)>,
229 CurrentUser(user): CurrentUser,
230 Form(form): Form<Collaborator>,
231) -> AppResult<Response> {
232 let ctx = RepoContext::load(&state, &owner, &name, user.as_deref()).await?;
233 ctx.require_settings()?;
234 let actor = user.as_ref().ok_or(AppError::Unauthorized)?.id;
235
236 sqlx::query(
237 "DELETE FROM repo_collaborators
238 WHERE repo_id = $1
239 AND user_id = (SELECT id FROM users WHERE handle = $2)",
240 )
241 .bind(ctx.repo.id)
242 .bind(form.handle.trim())
243 .execute(&state.db)
244 .await?;
245
246 audit(
247 &state,
248 actor,
249 "repo.collaborator_removed",
250 &format!("{}/{} {}", ctx.owner, ctx.repo.name, form.handle.trim()),
251 )
252 .await;
253
254 Ok(ok(&ctx, "collaborators", "Collaborator removed."))
255}
256
257// ─── bookmarks ───────────────────────────────────────────────────────────────
258
259#[derive(Deserialize)]
260pub struct ProtectBookmark {
261 pub name: String,
262 pub protected: String,
263}
264
265/// `POST /{owner}/{repo}/settings/bookmarks`
266pub async fn protect_bookmark(
267 State(state): State<AppState>,
268 UrlPath((owner, name)): UrlPath<(String, String)>,
269 CurrentUser(user): CurrentUser,
270 Form(form): Form<ProtectBookmark>,
271) -> AppResult<Response> {
272 let ctx = RepoContext::load(&state, &owner, &name, user.as_deref()).await?;
273 ctx.require_settings()?;
274
275 let protected = form.protected == "1";
276
277 sqlx::query("UPDATE bookmarks SET protected = $3 WHERE repo_id = $1 AND name = $2")
278 .bind(ctx.repo.id)
279 .bind(form.name.trim())
280 .bind(protected)
281 .execute(&state.db)
282 .await?;
283
284 Ok(ok(
285 &ctx,
286 "bookmarks",
287 if protected { "Bookmark protected." } else { "Bookmark unprotected." },
288 ))
289}
290
291// ─── danger zone ─────────────────────────────────────────────────────────────
292
293#[derive(Deserialize)]
294pub struct Archive {
295 pub archived: String,
296}
297
298/// `POST /{owner}/{repo}/settings/archive`
299pub async fn archive(
300 State(state): State<AppState>,
301 UrlPath((owner, name)): UrlPath<(String, String)>,
302 CurrentUser(user): CurrentUser,
303 Form(form): Form<Archive>,
304) -> AppResult<Response> {
305 let ctx = RepoContext::load(&state, &owner, &name, user.as_deref()).await?;
306 ctx.require_settings()?;
307 let actor = user.as_ref().ok_or(AppError::Unauthorized)?.id;
308
309 let archived = form.archived == "1";
310 sqlx::query("UPDATE repos SET archived = $2 WHERE id = $1")
311 .bind(ctx.repo.id)
312 .bind(archived)
313 .execute(&state.db)
314 .await?;
315
316 audit(
317 &state,
318 actor,
319 if archived { "repo.archived" } else { "repo.unarchived" },
320 &format!("{}/{}", ctx.owner, ctx.repo.name),
321 )
322 .await;
323
324 Ok(ok(
325 &ctx,
326 "danger",
327 if archived { "Repository archived." } else { "Repository unarchived." },
328 ))
329}
330
331#[derive(Deserialize)]
332pub struct DeleteRepo {
333 pub confirm: String,
334}
335
336/// `POST /{owner}/{repo}/settings/delete`
337pub async fn delete(
338 State(state): State<AppState>,
339 UrlPath((owner, name)): UrlPath<(String, String)>,
340 CurrentUser(user): CurrentUser,
341 Form(form): Form<DeleteRepo>,
342) -> AppResult<Response> {
343 let ctx = RepoContext::load(&state, &owner, &name, user.as_deref()).await?;
344 if !ctx.access.can_delete() {
345 return Err(AppError::Forbidden);
346 }
347 let actor = user.as_ref().ok_or(AppError::Unauthorized)?.id;
348
349 let expected = format!("{}/{}", ctx.owner, ctx.repo.name);
350 if form.confirm.trim() != expected {
351 return Ok(err(&ctx, "danger", "The confirmation did not match."));
352 }
353
354 // Database first: the row is the only thing that makes the repository
355 // reachable, so removing it is what actually revokes access. If the disk
356 // removal then fails, the result is an orphaned directory — recoverable and
357 // invisible — rather than a live row pointing at nothing.
358 sqlx::query("DELETE FROM repos WHERE id = $1")
359 .bind(ctx.repo.id)
360 .execute(&state.db)
361 .await?;
362
363 if let Err(e) = state.store.delete(ctx.store_id()).await {
364 tracing::error!(repo = %ctx.repo.id, "repository row deleted but storage removal failed: {e}");
365 }
366
367 audit(&state, actor, "repo.deleted", &expected).await;
368 tracing::warn!(repo = %ctx.repo.id, %expected, actor = %actor, "repository deleted");
369
370 Ok(Redirect::to("/").into_response())
371}
372
373// ─── loading ─────────────────────────────────────────────────────────────────
374
375async fn load_collaborators(state: &AppState, repo_id: Uuid) -> AppResult<Vec<v::CollaboratorRow>> {
376 let rows: Vec<(String, RepoRole)> = sqlx::query_as(
377 "SELECT u.handle::text, c.role
378 FROM repo_collaborators c JOIN users u ON u.id = c.user_id
379 WHERE c.repo_id = $1
380 ORDER BY u.handle",
381 )
382 .bind(repo_id)
383 .fetch_all(&state.db)
384 .await?;
385
386 Ok(rows
387 .into_iter()
388 .map(|(handle, role)| v::CollaboratorRow { handle, role })
389 .collect())
390}
391
392async fn load_bookmarks(state: &AppState, ctx: &RepoContext) -> AppResult<Vec<v::BookmarkRow>> {
393 let rows: Vec<(String, bool)> =
394 sqlx::query_as("SELECT name, protected FROM bookmarks WHERE repo_id = $1 ORDER BY name")
395 .bind(ctx.repo.id)
396 .fetch_all(&state.db)
397 .await?;
398
399 Ok(rows
400 .into_iter()
401 .map(|(name, protected)| v::BookmarkRow {
402 is_default: name == ctx.repo.default_bookmark,
403 name,
404 protected,
405 })
406 .collect())
407}
408
409// ─── helpers ─────────────────────────────────────────────────────────────────
410
411fn parse_role(s: &str) -> Option<RepoRole> {
412 match s {
413 "read" => Some(RepoRole::Read),
414 "write" => Some(RepoRole::Write),
415 "maintain" => Some(RepoRole::Maintain),
416 "admin" => Some(RepoRole::Admin),
417 _ => None,
418 }
419}
420
421fn visibility_str(v: df_db::models::Visibility) -> &'static str {
422 match v {
423 df_db::models::Visibility::Public => "public",
424 df_db::models::Visibility::Private => "private",
425 }
426}
427
428fn ok(ctx: &RepoContext, tab: &str, msg: &str) -> Response {
429 Redirect::to(&format!(
430 "{}/settings?tab={tab}&notice={}",
431 ctx.base(),
432 urlencode(msg)
433 ))
434 .into_response()
435}
436
437fn err(ctx: &RepoContext, tab: &str, msg: &str) -> Response {
438 Redirect::to(&format!(
439 "{}/settings?tab={tab}&error={}",
440 ctx.base(),
441 urlencode(msg)
442 ))
443 .into_response()
444}

444 lines · Rust