Waking up from sleep mode
The website is starting now. This page will open automatically.
from __future__ import annotations import html import mimetypes import os import re import secrets import shutil import uuid from datetime import timedelta from pathlib import Path, PurePosixPath from typing import Any import gradio as gr from fastapi import HTTPException, Request from fastapi.responses import FileResponse, HTMLResponse from starlette.background import BackgroundTask from core import ( BACKEND, PLANS, SETTINGS, STORE, decrypt_file, human_bytes, iso, parse_dt, utcnow, ) SITE_NAME_RE = re.compile( r"^[a-z0-9](?:[a-z0-9-]{0,98}[a-z0-9])?$" ) TEXT_EXTENSIONS = { ".html", ".htm", ".css", ".js", ".mjs", ".json", ".txt", ".md", ".xml", ".svg", ".csv", ".yml", ".yaml", ".toml", ".ini", ".map", } MAX_EDITOR_BYTES = 2 * 1024 * 1024 def _new_public_id() -> str: used = { str(project.get("public_id", "")) for project in STORE.state.get("websites", {}).values() if isinstance(project, dict) } value = secrets.token_hex(6) while value in used: value = secrets.token_hex(6) return value def _new_preview_id() -> str: used = { str(project.get("preview_id", "")) for project in STORE.state.get("websites", {}).values() if isinstance(project, dict) } value = secrets.token_urlsafe(18) while value in used: value = secrets.token_urlsafe(18) return value def ensure_website_state() -> None: changed = False with STORE.lock: if not isinstance(STORE.state.get("websites"), dict): STORE.state["websites"] = {} changed = True for project_id, project in list( STORE.state["websites"].items() ): if not isinstance(project, dict): STORE.state["websites"].pop(project_id, None) changed = True continue project.setdefault("id", project_id) project.setdefault("owner_id", "") project.setdefault("name", "website") project.setdefault("created_at", iso()) project.setdefault("updated_at", iso()) project.setdefault("published", False) project.setdefault("published_at", None) project.setdefault("last_visit_at", None) project.setdefault("waking_until", None) project.setdefault("closed_at", None) project.setdefault("logs", []) if not project.get("public_id"): project["public_id"] = _new_public_id() changed = True if not project.get("preview_id"): project["preview_id"] = _new_preview_id() changed = True if changed: STORE._save_unlocked( "Initialize CloudVault simple website builder" ) ensure_website_state() def _user_and_plan( user_id: str | None, ) -> tuple[dict[str, Any], Any]: user = STORE.get_user(user_id) if not user: raise PermissionError("Login required.") if user.get("role") == "admin": return user, PLANS["admin"] plan = PLANS[user.get("plan", "free")] if plan.key not in {"plus", "pro"}: raise PermissionError( "Web Site Builder is available only for Plus and Pro accounts." ) return user, plan def _site_limit(plan_key: str) -> int: if plan_key in {"pro", "admin"}: return 3 return 1 def _sleep_days(plan_key: str) -> int: if plan_key == "pro": return 7 if plan_key == "plus": return 2 return 36500 def _append_log_unlocked( project: dict[str, Any], level: str, message: str, ) -> None: logs = project.setdefault("logs", []) logs.append( { "time": iso(), "level": str(level or "INFO").upper(), "message": str(message or ""), } ) if len(logs) > 250: del logs[:-250] def validate_site_name(name: str) -> str: clean = (name or "").strip().lower() if not clean: raise ValueError("Enter a website name.") if len(clean) > 100: raise ValueError( "Website names may contain at most 100 characters." ) if not SITE_NAME_RE.fullmatch(clean): raise ValueError( "Use lowercase English letters, numbers and hyphens only. " "The name must start and end with a letter or number." ) return clean def normalize_web_path(path: str) -> str: raw = (path or "").strip().replace("\\", "/").strip("/") if not raw: raise ValueError("Enter a file name.") if len(raw) > 240: raise ValueError("The file path is too long.") parts = PurePosixPath(raw).parts if ( not parts or any(part in {"", ".", ".."} for part in parts) ): raise ValueError("The file path is invalid.") for part in parts: if ( "\x00" in part or any(character in part for character in '<>:"|?*') ): raise ValueError( "The file path contains unsupported characters." ) return "/".join(parts) def _project_files_unlocked( project_id: str, ) -> list[dict[str, Any]]: return [ dict(item) for item in STORE.state.get("files", {}).values() if item.get("website_project_id") == project_id ] def _project_unlocked( user_id: str, project_id: str, ) -> dict[str, Any]: project = STORE.state.get("websites", {}).get(project_id) if ( not project or project.get("owner_id") != user_id ): raise ValueError("Website project not found.") return project def list_user_projects( user_id: str | None, ) -> list[dict[str, Any]]: user, _plan = _user_and_plan(user_id) with STORE.lock: projects = [ dict(project) for project in STORE.state.get("websites", {}).values() if project.get("owner_id") == user["id"] ] projects.sort(key=lambda project: project.get("created_at", "")) return projects def get_user_project( user_id: str | None, project_id: str | None = None, ) -> dict[str, Any] | None: projects = list_user_projects(user_id) if not projects: return None if project_id: selected = next( (project for project in projects if project.get("id") == project_id), None, ) if selected: return selected return projects[0] def _folder_marker_unlocked( project_id: str, folder_path: str, ) -> dict[str, Any] | None: wanted = folder_path.casefold() for item in STORE.state.get("files", {}).values(): if ( item.get("website_project_id") == project_id and item.get("is_folder") and str(item.get("website_path", "")).casefold() == wanted ): return item return None def create_project_folder( user_id: str | None, project_id: str | None, folder_path: str, ) -> dict[str, Any]: user, _plan = _user_and_plan(user_id) if not project_id: raise ValueError("Create your website first.") clean_path = normalize_web_path(folder_path) with STORE.lock: project = _project_unlocked(user["id"], project_id) if _folder_marker_unlocked(project_id, clean_path): raise ValueError("This folder already exists.") if _find_file_unlocked(project_id, clean_path): raise ValueError("A file already uses this name.") marker_id = uuid.uuid4().hex marker = { "id": marker_id, "owner_id": user["id"], "display_name": PurePosixPath(clean_path).name, "remote_path": "", "original_size": 0, "stored_size": 0, "is_folder": True, "content_type": "application/x-directory", "encryption": "none", "created_at": iso(), "uploaded_under_plan": user.get("plan", "free"), "website_project_id": project_id, "website_path": clean_path, "website_file": True, } STORE.state["files"][marker_id] = marker project["updated_at"] = iso() _append_log_unlocked(project, "INFO", f"Created folder {clean_path}.") STORE._save_unlocked("Create CloudVault website folder") return dict(marker) def _directory_entries( user_id: str, project_id: str, directory: str, ) -> list[tuple[str, str]]: clean_dir = (directory or "").strip("/") prefix = f"{clean_dir}/" if clean_dir else "" files = list_project_files(user_id, project_id) folders: set[str] = set() visible_files: list[str] = [] for item in files: path = str(item.get("website_path", "")).strip("/") if not path or not path.startswith(prefix): continue remainder = path[len(prefix):] if not remainder: continue first, sep, rest = remainder.partition("/") if sep: folders.add(first) elif item.get("is_folder"): folders.add(first) else: visible_files.append(path) choices = [ (f"📁 {name}", f"folder:{prefix}{name}".rstrip("/")) for name in sorted(folders, key=str.casefold) ] choices.extend( (f"📄 {PurePosixPath(path).name}", f"file:{path}") for path in sorted(visible_files, key=str.casefold) ) return choices def _parent_directory(directory: str | None) -> str: clean = (directory or "").strip("/") if not clean: return "" parent = str(PurePosixPath(clean).parent) return "" if parent == "." else parent def _join_directory(directory: str | None, name: str) -> str: clean_dir = (directory or "").strip("/") clean_name = normalize_web_path(name) return f"{clean_dir}/{clean_name}".strip("/") def new_file_ui(directory: str | None): prefix = (directory or "").strip("/") suggested = f"{prefix}/".strip("/") if prefix else "" return ( suggested, "", "", "", gr.update(interactive=False), "Enter a file name and write the code.", ) def create_folder_ui( user_id: str | None, project_id: str | None, directory: str | None, folder_name: str, ): try: path = _join_directory(directory, folder_name) create_project_folder(user_id, project_id, path) return ( "", f"✅ Folder created: {path}", *_browser_updates(user_id, project_id, directory), ) except Exception as exc: return ( folder_name, f"❌ {exc}", *_browser_updates(user_id, project_id, directory), ) def _browser_updates( user_id: str | None, project_id: str | None, directory: str | None, ): try: user, _plan = _user_and_plan(user_id) clean_dir = (directory or "").strip("/") if not project_id: return ( clean_dir, gr.update(choices=[], value=None), "Root", ) _project_unlocked(user["id"], project_id) choices = _directory_entries(user["id"], project_id, clean_dir) return ( clean_dir, gr.update(choices=choices, value=None), f"/{clean_dir}" if clean_dir else "/", ) except Exception: return ( "", gr.update(choices=[], value=None), "/", ) def open_entry_ui( user_id: str | None, project_id: str | None, directory: str | None, selected_entry: str | None, ): if not selected_entry: return ( *_browser_updates(user_id, project_id, directory), "", "", "", "", gr.update(interactive=False), "Select a file or folder.", ) if selected_entry.startswith("folder:"): folder = selected_entry.split(":", 1)[1] return ( *_browser_updates(user_id, project_id, folder), "", "", "", "", gr.update(interactive=False), f"📁 Opened /{folder}", ) file_path = selected_entry.split(":", 1)[1] loaded = load_file_ui(user_id, project_id, file_path) return ( *_browser_updates(user_id, project_id, directory), *loaded, ) def back_folder_ui( user_id: str | None, project_id: str | None, directory: str | None, ): parent = _parent_directory(directory) return _browser_updates(user_id, project_id, parent) def delete_entry( user_id: str | None, project_id: str | None, selected_entry: str | None, ) -> bool: user, _plan = _user_and_plan(user_id) if not project_id or not selected_entry: raise ValueError("Select a file or folder first.") kind, path = selected_entry.split(":", 1) clean_path = normalize_web_path(path) remote_paths: list[str] = [] with STORE.lock: project = _project_unlocked(user["id"], project_id) to_remove: list[str] = [] for item_id, item in STORE.state.get("files", {}).items(): if item.get("website_project_id") != project_id: continue item_path = str(item.get("website_path", "")).strip("/") match = ( item_path.casefold() == clean_path.casefold() if kind == "file" else ( item_path.casefold() == clean_path.casefold() or item_path.casefold().startswith(clean_path.casefold() + "/") ) ) if match: to_remove.append(item_id) remote = str(item.get("remote_path", "")) if remote: remote_paths.append(remote) if not to_remove: raise ValueError("The selected item was not found.") for item_id in to_remove: STORE.state["files"].pop(item_id, None) project["updated_at"] = iso() _append_log_unlocked( project, "WARN", f"Deleted {'folder' if kind == 'folder' else 'file'} {clean_path}.", ) STORE._save_unlocked("Delete CloudVault website entry") if remote_paths: BACKEND.delete_many(remote_paths, "Delete CloudVault website entries") return True def delete_entry_ui( user_id: str | None, project_id: str | None, directory: str | None, selected_entry: str | None, ): try: delete_entry(user_id, project_id, selected_entry) return ( *_browser_updates(user_id, project_id, directory), "", "", "", "", gr.update(interactive=False), "✅ Deleted.", ) except Exception as exc: return ( *_browser_updates(user_id, project_id, directory), "", "", "", "", gr.update(interactive=False), f"❌ {exc}", ) def delete_website_ui( user_id: str | None, project_id: str | None, ): try: user, _plan = _user_and_plan(user_id) if not project_id: raise ValueError("Select a website first.") remote_paths: list[str] = [] with STORE.lock: project = _project_unlocked(user["id"], project_id) for item_id, item in list(STORE.state.get("files", {}).items()): if item.get("website_project_id") == project_id: remote = str(item.get("remote_path", "")) if remote: remote_paths.append(remote) STORE.state["files"].pop(item_id, None) STORE.state["websites"].pop(project_id, None) STORE._save_unlocked( f"Delete CloudVault website {project.get('name', project_id)}" ) if remote_paths: BACKEND.delete_many(remote_paths, "Delete CloudVault website project") state = builder_state(user_id, None) return (*state, "✅ Website and all of its files were deleted.") except Exception as exc: state = builder_state(user_id, project_id) return (*state, f"❌ {exc}") def upload_to_directory( user_id: str | None, project_id: str | None, directory: str | None, paths: list[str] | str | None, preserve_tree: bool = False, ) -> int: user, _plan = _user_and_plan(user_id) if not project_id: raise ValueError("Create your website first.") if not paths: return 0 raw = [paths] if isinstance(paths, str) else [p for p in paths if p] readable = [Path(p) for p in raw if Path(p).is_file()] if not readable: raise ValueError("No readable files were selected.") clean_dir = (directory or "").strip("/") common = Path(os.path.commonpath([str(p.parent) for p in readable])) count = 0 for path in readable: if preserve_tree: try: relative = path.relative_to(common) except ValueError: relative = Path(path.name) web_path = f"{clean_dir}/{relative.as_posix()}".strip("/") else: web_path = f"{clean_dir}/{path.name}".strip("/") _save_source_file(user["id"], project_id, web_path, path) count += 1 return count def auto_upload_ui( user_id: str | None, project_id: str | None, directory: str | None, paths, preserve_tree: bool = False, ): try: count = upload_to_directory( user_id, project_id, directory, paths, preserve_tree ) return ( None, f"✅ {count} item(s) uploaded automatically.", *_browser_updates(user_id, project_id, directory), ) except Exception as exc: return ( None, f"❌ {exc}", *_browser_updates(user_id, project_id, directory), ) def _find_file_unlocked( project_id: str, web_path: str, ) -> dict[str, Any] | None: wanted = web_path.casefold() for item in STORE.state.get("files", {}).values(): if ( item.get("website_project_id") == project_id and str( item.get("website_path", "") ).casefold() == wanted ): return item return None def list_project_files( user_id: str | None, project_id: str | None, ) -> list[dict[str, Any]]: user, _plan = _user_and_plan(user_id) if not project_id: return [] with STORE.lock: _project_unlocked(user["id"], project_id) files = _project_files_unlocked(project_id) return sorted( files, key=lambda item: str( item.get("website_path", "") ).casefold(), ) def create_project( user_id: str | None, site_name: str, ) -> dict[str, Any]: user, plan = _user_and_plan(user_id) clean_name = validate_site_name(site_name) with STORE.lock: existing = [ project for project in STORE.state.get( "websites", {}, ).values() if project.get("owner_id") == user["id"] ] if len(existing) >= _site_limit(plan.key): raise ValueError( f"Your {plan.name} package allows {_site_limit(plan.key)} " f"website{'s' if _site_limit(plan.key) != 1 else ''}." ) project_id = uuid.uuid4().hex project = { "id": project_id, "owner_id": user["id"], "name": clean_name, "public_id": _new_public_id(), "preview_id": _new_preview_id(), "created_at": iso(), "updated_at": iso(), "published": False, "published_at": None, "last_visit_at": None, "waking_until": None, "closed_at": None, "logs": [ { "time": iso(), "level": "INFO", "message": ( "Website created. The page is blank until code is saved." ), } ], } STORE.state["websites"][project_id] = project STORE._save_unlocked( "Create CloudVault simple website" ) return dict(project) def _save_source_file( user_id: str, project_id: str, web_path: str, source: Path, content_type: str | None = None, ) -> dict[str, Any]: user, plan = _user_and_plan(user_id) clean_path = normalize_web_path(web_path) if not source.is_file(): raise ValueError( "The selected file could not be read." ) original_size = source.stat().st_size old_remote_path: str | None = None with STORE.lock: project = _project_unlocked( user["id"], project_id, ) existing = _find_file_unlocked( project_id, clean_path, ) old_size = ( int(existing.get("original_size", 0)) if existing else 0 ) user_projected = ( STORE._used_unlocked(user["id"]) - old_size + original_size ) if ( user.get("role") != "admin" and user_projected > plan.quota_bytes ): raise ValueError( "Your CloudVault storage is full. " "Delete files before saving." ) global_projected = ( STORE._used_unlocked() - old_size + original_size ) if ( global_projected > SETTINGS.app_storage_limit_bytes ): raise ValueError( "The CloudVault server storage is full." ) if existing: old_remote_path = str( existing.get("remote_path", "") ) file_id = uuid.uuid4().hex remote_path = ( f"objects/{user['id']}/{file_id}.blob" ) try: # Website files are public assets and do not need # per-request encryption/decryption. BACKEND.upload( source, remote_path, "Save CloudVault website asset", ) with STORE.lock: project = _project_unlocked( user["id"], project_id, ) existing = _find_file_unlocked( project_id, clean_path, ) if existing: STORE.state["files"].pop( existing["id"], None, ) metadata = { "id": file_id, "owner_id": user["id"], "display_name": PurePosixPath( clean_path ).name, "remote_path": remote_path, "original_size": original_size, "stored_size": original_size, "is_folder": False, "content_type": ( content_type or mimetypes.guess_type( clean_path )[0] or "application/octet-stream" ), "encryption": "none", "share_password_hash": None, "share_token": secrets.token_urlsafe(16), "download_count": 0, "last_download_at": None, "created_at": iso(), "uploaded_under_plan": plan.key, "website_project_id": project_id, "website_path": clean_path, "website_file": True, } STORE.state["files"][file_id] = metadata project["updated_at"] = iso() _append_log_unlocked( project, "INFO", ( f"Saved {clean_path} " f"({human_bytes(original_size)})." ), ) if project.get("published"): _append_log_unlocked( project, "INFO", "The published website now uses the latest saved files.", ) STORE._save_unlocked( "Update CloudVault website asset" ) if ( old_remote_path and old_remote_path != remote_path ): BACKEND.delete_many( [old_remote_path], "Replace CloudVault website asset", ) return dict(metadata) except Exception: BACKEND.delete_many( [remote_path], "Rollback failed CloudVault website asset", ) raise def save_text_file( user_id: str | None, project_id: str | None, file_path: str, content: str, ) -> dict[str, Any]: user, _plan = _user_and_plan(user_id) if not project_id: raise ValueError("Create your website first.") clean_path = normalize_web_path( file_path or "index.html" ) temporary = ( SETTINGS.temp_dir / f"website-source-{uuid.uuid4().hex}.txt" ) try: temporary.write_text( content or "", encoding="utf-8", ) return _save_source_file( user["id"], project_id, clean_path, temporary, ( mimetypes.guess_type(clean_path)[0] or "text/html" ), ) finally: temporary.unlink(missing_ok=True) def upload_files( user_id: str | None, project_id: str | None, paths: list[str] | str | None, ) -> int: user, _plan = _user_and_plan(user_id) if not project_id: raise ValueError("Create your website first.") if not paths: raise ValueError("Choose at least one file.") raw_paths = ( [paths] if isinstance(paths, str) else [path for path in paths if path] ) readable = [ Path(path) for path in raw_paths if Path(path).is_file() ] if not readable: raise ValueError( "No readable files were selected." ) count = 0 for path in readable: _save_source_file( user["id"], project_id, path.name, path, ) count += 1 return count def _read_materialized_file( metadata: dict[str, Any], ) -> Path: stored = ( SETTINGS.temp_dir / f"website-stored-{uuid.uuid4().hex}.blob" ) suffix = PurePosixPath( str(metadata.get("website_path", "")) ).suffix plain = ( SETTINGS.temp_dir / f"website-plain-{uuid.uuid4().hex}{suffix}" ) if not BACKEND.download( metadata["remote_path"], stored, ): raise ValueError( "The stored website file could not be downloaded." ) try: if metadata.get("encryption", "none") == "none": shutil.copy2(stored, plain) else: decrypt_file( stored, plain, metadata["owner_id"], metadata["id"], metadata.get("encryption", "none"), ) finally: stored.unlink(missing_ok=True) return plain def read_text_file( user_id: str | None, project_id: str | None, web_path: str | None, ) -> str: user, _plan = _user_and_plan(user_id) if not project_id: raise ValueError("Create your website first.") clean_path = normalize_web_path( web_path or "index.html" ) with STORE.lock: _project_unlocked( user["id"], project_id, ) item = _find_file_unlocked( project_id, clean_path, ) if not item: if clean_path == "index.html": return "" raise ValueError("File not found.") metadata = dict(item) if ( int(metadata.get("original_size", 0)) > MAX_EDITOR_BYTES ): raise ValueError( "This file is too large for the code editor." ) suffix = PurePosixPath(clean_path).suffix.lower() content_type = str( metadata.get("content_type", "") ) if ( suffix not in TEXT_EXTENSIONS and not content_type.startswith("text/") ): raise ValueError( "This file type cannot be edited as text." ) plain = _read_materialized_file(metadata) try: return plain.read_text( encoding="utf-8", errors="replace", ) finally: plain.unlink(missing_ok=True) def delete_project_file( user_id: str | None, project_id: str | None, web_path: str | None, ) -> bool: user, _plan = _user_and_plan(user_id) if not project_id or not web_path: raise ValueError("Select a file first.") clean_path = normalize_web_path(web_path) remote_path: str | None = None with STORE.lock: project = _project_unlocked( user["id"], project_id, ) item = _find_file_unlocked( project_id, clean_path, ) if not item: raise ValueError("File not found.") remote_path = str( item.get("remote_path", "") ) STORE.state["files"].pop( item["id"], None, ) project["updated_at"] = iso() _append_log_unlocked( project, "WARN", f"Deleted {clean_path}.", ) STORE._save_unlocked( "Delete CloudVault website asset" ) if remote_path: BACKEND.delete_many( [remote_path], "Delete CloudVault website asset", ) return True def public_site_url( project: dict[str, Any], ) -> str: base = SETTINGS.public_base_url.rstrip("/") return ( f"{base}/gradio_api/cloudvault-site/" f"{project['public_id']}/" ) def _project_status( project: dict[str, Any], plan_key: str, ) -> tuple[str, str]: if not project.get("published"): return ( "DRAFT", "Saved files are private until Publish Website is pressed.", ) last_visit = parse_dt( project.get("last_visit_at") or project.get("published_at") ) if last_visit: sleeping_after = timedelta( days=_sleep_days(plan_key) ) if utcnow() - last_visit >= sleeping_after: return ( "SLEEPING", ( "The website is sleeping because it has not been visited " f"for {_sleep_days(plan_key)} days." ), ) return ( "PUBLISHED", "The website is online and uses the latest saved files.", ) def _logs_html(project: dict[str, Any]) -> str: logs = list(project.get("logs", [])) if not logs: return ( '
{html.escape(message)}
" "{html.escape(str(exc))}
" "The website is starting now. This page will open automatically.
The owner has unpublished this website.