chore(frontend): remove the legacy vanilla-JS frontend and its tooling

The SvelteKit app under /frontend has fully superseded the legacy
vanilla-JS/CSS frontend in /static, which was only ever served by a
debug `cargo run` / `PROFILE=dev` and never shipped to production.
Remove it together with the whole subsystem that existed only to
support it (~54k lines).

Frontend & assets:
- Delete /static (js/, css/, *.html, sw.js, basemaps/, locales symlink).
- Relocate the brand/PWA assets (logo/, favicon.ico, manifest.webmanifest)
  to frontend/static/ so they ship with the SPA. This also fixes the
  favicon, which app.html referenced but was missing from the prod bundle.
- Migrate the Nextcloud login-flow redirects from /nextcloud-error.html
  to the SvelteKit /nextcloud/error route.

Web layer:
- Simplify resolve_static_path: drop the PROFILE=dev branch; always prefer
  the Vite static-dist/ build, fall back to the configured path.
- Resolve i18n locales from the served SPA dir with a frontend/static
  fallback so `just dev` works without a prior build.

Build:
- Prune build.rs from 1262 to ~70 lines (git metadata only); the Rust asset
  pipeline and the OXICLOUD_RUST_ASSETS rollback flag are gone.
- Drop the now-unused build-dependencies (oxc_*, lightningcss).
- Remove the COPY static lines from the Dockerfile (cacher + builder).

Tooling & docs:
- Delete biome.json, jsconfig.json, tools/check-*.py, identifier.sh.
- Remove the legacy front-* justfile recipes; repoint the design-system
  scripts (locales, dead-tokens, brand-drift, token-docs) at the frontend,
  and drop check-contrast/check-headings (coupled to the old token
  taxonomy / multi-page HTML).
- Repoint docs/DESIGN-SYSTEM.md links; remove 5 superseded docs/plan/*.

Backend dead code:
- Remove the dead `folder_repo` field from FileBlobWriteRepository.
- Remove the deprecated GET /api/folders/{id}/listing endpoint
  (superseded by /resources).

Verified: cargo clippy (all-features/all-targets) clean, cargo test
--workspace 448 passed, cargo fmt clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
DioCrafts
2026-06-21 03:17:34 +02:00
parent 6be3c99580
commit 54639d466a
211 changed files with 171 additions and 53965 deletions
+1 -145
View File
@@ -1,12 +1,10 @@
use axum::{
Json,
body::Body,
extract::{Path, Query, State},
http::{HeaderMap, Response, StatusCode, header},
http::{Response, StatusCode, header},
response::IntoResponse,
};
use std::collections::HashMap;
use std::hash::{Hash, Hasher};
use std::sync::Arc;
use tokio_util::io::ReaderStream;
@@ -18,10 +16,8 @@ use crate::application::dtos::folder_dto::{
CreateFolderDto, FolderDto, FolderResourceItemDto, FolderResourcesDto, FolderResourcesQuery,
ListResourcesOptions, MoveFolderDto, RenameFolderDto,
};
use crate::application::dtos::folder_listing_dto::FolderListingDto;
use crate::application::dtos::grant_dto::{ResourceContentDto, ResourceTypeDto};
use crate::application::dtos::pagination::PaginationRequestDto;
use crate::application::ports::file_ports::FileRetrievalUseCase;
use crate::application::ports::folder_ports::FolderUseCase;
use crate::application::ports::trash_ports::TrashUseCase;
use crate::application::services::folder_service::FolderService;
@@ -138,123 +134,6 @@ impl FolderHandler {
}
}
/// Compute a lightweight ETag from the maximum `modified_at` timestamp
/// and item count. No body buffering required.
fn compute_listing_etag(
folders: &[crate::application::dtos::folder_dto::FolderDto],
files: &[crate::application::dtos::file_dto::FileDto],
favorite_ids: &[String],
shared_ids: &[String],
) -> String {
let max_mod = folders
.iter()
.map(|f| f.modified_at)
.chain(files.iter().map(|f| f.modified_at))
.max()
.unwrap_or(0);
let count = folders.len() + files.len();
let mut hasher = std::collections::hash_map::DefaultHasher::new();
max_mod.hash(&mut hasher);
count.hash(&mut hasher);
// Badge state is part of the representation — fold it in (both slices are
// sorted, so the hash is stable) so a favorite/share change busts the ETag.
favorite_ids.hash(&mut hasher);
shared_ids.hash(&mut hasher);
format!("\"{:x}\"", hasher.finish())
}
/// Returns both sub-folders and files for a given folder in a single
/// response, eliminating the double-fetch the frontend used to make.
///
/// Both queries run concurrently via `tokio::join!`.
/// Supports `If-None-Match` / ETag for conditional responses (304).
pub(super) async fn list_folder_listing_impl(
State(state): State<Arc<GlobalAppState>>,
auth_user: AuthUser,
headers: HeaderMap,
Path(id): Path<String>,
) -> axum::response::Response {
let folder_service = &state.applications.folder_service;
let file_service = &state.applications.file_retrieval_service;
// Run both queries concurrently — no sequential wait.
let (folders_result, files_result) = tokio::join!(
folder_service.list_folders_with_perms(Some(&id), auth_user.id),
file_service.list_files_with_perms(Some(&id), auth_user.id)
);
match (folders_result, files_result) {
(Ok(folders), Ok(files)) => {
// Badge enrichment for this listing: which items the caller has
// favorited / shared. Two batched, index-backed queries (run
// concurrently) replace the client's old per-navigation global
// favorites + outgoing-shares fetches — correct (no 200-item
// ceiling) and scoped to just the items on screen.
let fav_pairs: Vec<(&str, &str)> = folders
.iter()
.map(|f| (f.id.as_str(), "folder"))
.chain(files.iter().map(|f| (f.id.as_str(), "file")))
.collect();
let resource_uuids: Vec<uuid::Uuid> = folders
.iter()
.map(|f| f.id.as_str())
.chain(files.iter().map(|f| f.id.as_str()))
.filter_map(|s| uuid::Uuid::parse_str(s).ok())
.collect();
let (favorited, shared) = tokio::join!(
async {
match &state.favorites_service {
Some(svc) => svc
.favorited_ids(auth_user.id, &fav_pairs)
.await
.unwrap_or_default(),
None => Default::default(),
}
},
state
.authorization
.shared_resource_ids(auth_user.id, &resource_uuids)
);
let mut favorite_ids: Vec<String> = favorited.into_iter().collect();
favorite_ids.sort();
let mut shared_ids: Vec<String> = shared
.unwrap_or_default()
.into_iter()
.map(|u| u.to_string())
.collect();
shared_ids.sort();
let etag = Self::compute_listing_etag(&folders, &files, &favorite_ids, &shared_ids);
// 304 Not Modified if the client already has this version
if let Some(inm) = headers.get(header::IF_NONE_MATCH)
&& let Ok(client_etag) = inm.to_str()
&& client_etag == etag
{
return Response::builder()
.status(StatusCode::NOT_MODIFIED)
.header(header::ETAG, &etag)
.body(Body::empty())
.unwrap()
.into_response();
}
let listing = FolderListingDto {
folders,
files,
favorite_ids,
shared_ids,
};
let mut resp = (StatusCode::OK, Json(listing)).into_response();
resp.headers_mut()
.insert(header::ETAG, header::HeaderValue::from_str(&etag).unwrap());
resp
}
(Err(err), _) | (_, Err(err)) => AppError::from(err).into_response(),
}
}
/// Renames a folder (ownership enforced).
pub(super) async fn rename_folder_impl(
State(service): State<AppState>,
@@ -517,29 +396,6 @@ pub async fn list_root_folders_paginated(
FolderHandler::list_root_folders_paginated_impl(state, auth_user, pagination).await
}
#[deprecated = "Use /api/folders/{id}/resources instead"]
#[utoipa::path(
get,
path = "/api/folders/{id}/listing",
params(("id" = String, Path, description = "Folder ID")),
responses(
(status = 200, description = "Folder listing (sub-folders + files)", body = FolderListingDto),
(status = 304, description = "Not modified"),
(status = 404, description = "Folder not found"),
),
security(("bearerAuth" = [])),
tag = "folders"
)]
#[allow(deprecated)]
pub async fn list_folder_listing(
state: State<Arc<GlobalAppState>>,
auth_user: AuthUser,
headers: HeaderMap,
path: Path<String>,
) -> axum::response::Response {
FolderHandler::list_folder_listing_impl(state, auth_user, headers, path).await
}
#[utoipa::path(
put,
path = "/api/folders/{id}/rename",
-1
View File
@@ -100,7 +100,6 @@ use crate::interfaces::api::handlers::file_handler::MoveFilePayload;
handlers::folder_handler::list_root_folders,
handlers::folder_handler::list_root_folders_paginated,
handlers::folder_handler::list_folder_resources,
handlers::folder_handler::list_folder_listing,
handlers::folder_handler::rename_folder,
handlers::folder_handler::move_folder,
handlers::folder_handler::delete_folder_with_trash,
+2 -10
View File
@@ -64,7 +64,7 @@ use crate::interfaces::api::handlers::file_handler::{
};
#[allow(deprecated)]
use crate::interfaces::api::handlers::folder_handler::{
create_folder, delete_folder_with_trash, download_folder_zip, get_folder, list_folder_listing,
create_folder, delete_folder_with_trash, download_folder_zip, get_folder,
list_folder_resources, list_root_folders, list_root_folders_paginated, move_folder,
rename_folder,
};
@@ -208,21 +208,13 @@ pub fn create_api_routes(app_state: &Arc<AppState>) -> Router<Arc<AppState>> {
.route("/{id}/download", get(download_folder_zip))
.with_state(app_state.clone());
// Combined listing endpoint: returns both sub-folders AND files in one
// response. Needs full AppState because it calls both FolderService
// and FileRetrievalService concurrently.
let folder_listing_router = Router::new()
.route("/{id}/listing", get(list_folder_listing))
.with_state(app_state.clone());
// Create folder operations that use trash (requires full AppState)
let folders_ops_router = Router::new().route("/{id}", delete(delete_folder_with_trash));
// Merge the routers
let folders_router = folders_basic_router
.merge(folders_ops_router)
.merge(folder_zip_router)
.merge(folder_listing_router);
.merge(folder_zip_router);
// Create file routes for basic operations and trash-enabled delete
let basic_file_router = Router::new()
+6 -7
View File
@@ -31,8 +31,7 @@ struct DrivePickerTemplate {
/// The Nextcloud Login Flow v2 "Grant Access" page. Rendered server-side via
/// askama (no template variables — the username/password are collected by the
/// embedded form) instead of `include_str!` so the build no longer depends on
/// the legacy `build.rs` static-asset pipeline / `OUT_DIR`.
/// embedded form); the template is embedded at compile time by the derive macro.
#[derive(Template)]
#[template(path = "nextcloud/login.html")]
struct NextcloudLoginTemplate;
@@ -264,7 +263,7 @@ pub async fn handle_login_submit(
// Flow token vanished (TTL?) between password submit and
// here — extremely unlikely but treat the same as any
// session-expired case.
return axum::response::Redirect::to("/nextcloud-error.html?type=session-expired")
return axum::response::Redirect::to("/nextcloud/error?type=session-expired")
.into_response();
}
return render_drive_picker(&token, &drives);
@@ -365,7 +364,7 @@ async fn complete_flow(
user = %user.username,
"Login Flow v2: complete() returned false — flow token not found"
);
axum::response::Redirect::to("/nextcloud-error.html?type=session-expired").into_response()
axum::response::Redirect::to("/nextcloud/error?type=session-expired").into_response()
}
}
@@ -402,7 +401,7 @@ pub async fn handle_drive_pick(
reason = "no_pending_user",
"👮🏻‍♂️ NC drive pick rejected: flow has no pending user (replay or unknown token)"
);
return axum::response::Redirect::to("/nextcloud-error.html?type=session-expired")
return axum::response::Redirect::to("/nextcloud/error?type=session-expired")
.into_response();
}
};
@@ -516,7 +515,7 @@ pub async fn handle_login_oidc(
// Verify the NC login flow token exists
if !nextcloud.login_flow.flow_exists(&token) {
return axum::response::Redirect::to("/nextcloud-error.html?type=session-expired")
return axum::response::Redirect::to("/nextcloud/error?type=session-expired")
.into_response();
}
@@ -549,7 +548,7 @@ pub async fn handle_login_oidc(
}
fn login_failed_response(_err: DomainError) -> Response {
axum::response::Redirect::to("/nextcloud-error.html?type=invalid-credentials").into_response()
axum::response::Redirect::to("/nextcloud/error?type=invalid-credentials").into_response()
}
fn parse_form(body: &str) -> HashMap<String, String> {
+18 -30
View File
@@ -14,26 +14,19 @@ use tower_http::set_header::SetResponseHeaderLayer;
/// Resolve the directory the SPA is actually served from.
///
/// Release builds prefer the Vite output next to the configured static path —
/// `static-dist/`, or `static/` under `PROFILE=dev` — falling back to the
/// configured path when that build dir is absent. Debug builds always use the
/// configured path. Shared with the CSP layer in `main.rs` so the inline-script
/// hashes are computed from exactly the bytes that get served.
/// Prefers the Vite build output (`static-dist/`) sitting next to the configured
/// static path, falling back to the configured path itself — the container ships
/// the built SPA straight to `OXICLOUD_STATIC_PATH` (default `./static`), so there
/// the fallback is what serves. Shared with the CSP layer in `main.rs` so the
/// inline-script hashes are computed from exactly the bytes that get served.
pub fn resolve_static_path(config: &AppConfig) -> PathBuf {
// `PROFILE=dev` (the `just front-dev`/legacy path) serves the unbuilt source
// dir; normal release serves the Vite output in `static-dist/`.
let is_dev = std::env::var("PROFILE").is_ok_and(|profile| profile == "dev");
let assets_dir = if is_dev { "static" } else { "static-dist" };
if cfg!(not(debug_assertions)) {
let dist = config
.static_path
.parent()
.unwrap_or(Path::new("."))
.join(assets_dir);
if dist.exists() {
return dist;
}
let dist = config
.static_path
.parent()
.unwrap_or(Path::new("."))
.join("static-dist");
if dist.exists() {
return dist;
}
config.static_path.clone()
}
@@ -50,7 +43,6 @@ pub fn resolve_static_path(config: &AppConfig) -> PathBuf {
/// can't leave a stale app pinned in browsers.
pub fn create_web_routes() -> Router<Arc<AppState>> {
let config = AppConfig::from_env();
let is_dev = std::env::var("PROFILE").is_ok_and(|profile| profile == "dev");
let static_path = resolve_static_path(&config);
// SPA fallback: serve the file if it exists, else the app shell.
@@ -59,12 +51,6 @@ pub fn create_web_routes() -> Router<Arc<AppState>> {
// Hashed, immutable assets (SvelteKit emits these under /_app/immutable).
let app_immutable = ServeDir::new(static_path.join("_app").join("immutable"));
let shell_cache = if is_dev {
"max-age=0, no-cache, no-store"
} else {
"no-cache"
};
Router::new()
.nest_service(
"/_app/immutable",
@@ -75,10 +61,12 @@ pub fn create_web_routes() -> Router<Arc<AppState>> {
)
.fallback_service(spa)
.layer(CompressionLayer::new().br(true).gzip(true))
// `if_not_present` so the immutable assets above keep their long cache.
// `if_not_present` so the immutable assets above keep their long cache;
// the shell itself must always revalidate so a deploy can't pin a stale
// app in browsers.
.layer(SetResponseHeaderLayer::if_not_present(
CACHE_CONTROL,
HeaderValue::from_static(shell_cache),
HeaderValue::from_static("no-cache"),
))
}
@@ -148,8 +136,8 @@ pub fn content_security_policy(config: &AppConfig) -> String {
/// each shell is read verbatim and that slice hashed. Scripts carrying a `src`
/// attribute are external (already allowed by `'self'`) and skipped. Only the
/// directory root is scanned — the SPA is client-rendered (SSR/prerender off),
/// so the only inline-script shell is `index.html`; any legacy pages sit beside
/// it. Returns a deduplicated, sorted list; empty when the dir is unreadable
/// so the only inline-script shell is `index.html`. Returns a deduplicated,
/// sorted list; empty when the dir is unreadable
/// (e.g. a Vite dev server serving HTML on its own port instead).
fn inline_script_csp_hashes(static_path: &Path) -> Vec<String> {
let Ok(entries) = std::fs::read_dir(static_path) else {