from io import BytesIO from wsgiref.handlers import format_date_time as http_date from my_modules.decoratory.header import login_required from my_modules.functions import get_ip from my_modules.app.setup import LIMITER from my_modules.app.logger import logger from my_modules.expiry import parse_expires from my_modules.filecache import build_etag, cache_control, edge_cache_control, is_fresh from my_modules.filecache.access import browser_max_age, drop_file_cache, edge_max_age, load_blob, load_file from my_modules.textview import build_view_context, wants_text_view from quart_common.web.wide_event import add_wide_event_context from quart import ( Blueprint, request, session, Response, send_file, render_template, make_response, abort, current_app, jsonify, ) side_main_bp = Blueprint("side_main", __name__) def find_file(files: list[dict], file_id: str): for file_data in files: if file_data.get("file_id") == file_id: return file_data return None def request_host() -> str: return (request.headers.get("X-Forwarded-Host") or request.host).split(",")[0].strip() def build_share_url(file_id: str) -> str: scheme = (request.headers.get("X-Forwarded-Proto") or request.scheme or "http").split(",")[0].strip() root_path = request.root_path.rstrip("/") return f"{scheme}://{request_host()}{root_path}/-{file_id}" async def render_text_view(file_id: str, file_data: dict): """Render a pasted text file as HTML so links are clickable and own files resolve.""" root_path = request.root_path.rstrip("/") raw = await load_blob(file_data.get("db_image_url")) async def lookup(linked_file_id: str): return await load_file(linked_file_id) context = await build_view_context( raw=raw, file_name=file_data.get("file_name") or file_id, host=request_host(), root_path=root_path, raw_url=f"{root_path}/-{file_id}?raw=1", lookup=lookup, ) response = await make_response(await render_template("views/webpage/files/text_view.htm", **context)) response.headers["Cache-Control"] = "private, no-store" return response @side_main_bp.route('/') @LIMITER.limit("20 per minute;500 per hour;") async def index(): if session.get("user") is not None: return await render_template("views/webpage/files/upload.htm") return await render_template("views/webpage/index.htm") @side_main_bp.route('/access') @login_required async def access_list(user): add_wide_event_context(nanoshare={"operation": "access_list"}) access_data = await current_app.convex.get_all_access(user_id=user['sub']) return await render_template("views/webpage/access/list.htm", access_logs=access_data) @side_main_bp.route('/files') @login_required async def files_list(user): add_wide_event_context(nanoshare={"operation": "files_list"}) files_data = await current_app.convex.get_files(user_id=user['sub']) return await render_template("views/webpage/files/list.htm", files=files_data ) @side_main_bp.route('/files//info') @login_required async def file_info(file_id, user): add_wide_event_context(nanoshare={"operation": "file_info", "file_id": file_id}) files_data = await current_app.convex.get_files(user_id=user["sub"]) file_data = find_file(files_data, file_id) if not file_data: abort(404) access_data = await current_app.convex.get_file_access(file_id=file_id, user_id=user["sub"]) or [] share_url = build_share_url(file_id) return await render_template( "views/webpage/files/info.htm", file=file_data, accesses=access_data, share_url=share_url, ) @side_main_bp.route("/files//edit") @login_required async def file_edit(file_id, user): add_wide_event_context(nanoshare={"operation": "file_edit", "file_id": file_id}) file_data = await current_app.convex.get_file_informations(file_id=file_id, user_id=user["sub"]) if not file_data: abort(404) share_url = build_share_url(file_id) return await render_template( "views/webpage/files/edit.htm", file=file_data, share_url=share_url, file_id=file_id ) @side_main_bp.put("/api/file/") @login_required async def file_edit_api(file_id, user): add_wide_event_context(nanoshare={"operation": "file_update", "file_id": file_id}) files_data = await current_app.convex.get_files(user_id=user["sub"]) if not find_file(files_data, file_id): return jsonify({"ok": False, "error": "File not found"}), 404 payload = await request.get_json(silent=True) if payload is None: payload = await request.form file_name = str(payload.get("file_name", "")).strip() note = str(payload.get("note", "")).strip() expires_raw = str(payload.get("expires", "")).strip() if not file_name: return jsonify({"ok": False, "error": "Filename is required"}), 400 expires_at = parse_expires(expires_raw) if expires_raw and expires_raw != "never" and expires_at is None: return jsonify({"ok": False, "error": "Invalid expiration value"}), 400 await current_app.convex.update_file( file_id=file_id, file_name=file_name, note=note, expires_at=expires_at, user_id=user["sub"], ) await drop_file_cache(file_id) return jsonify({"ok": True}) @side_main_bp.delete("/api/file/") @login_required async def file_delete_api(file_id, user): add_wide_event_context(nanoshare={"operation": "file_delete", "file_id": file_id}) files_data = await current_app.convex.get_files(user_id=user["sub"]) if not find_file(files_data, file_id): return jsonify({"ok": False, "error": "File not found"}), 404 await current_app.convex.delete_file(file_id=file_id, user_id=user["sub"]) await drop_file_cache(file_id) return jsonify({"ok": True}) @side_main_bp.route("/-") @LIMITER.limit("20 per minute;500 per hour;") async def serve_file(file_id: str): # Quart has already decoded %20; discard prose accidentally pasted after a link. file_id = file_id.split(" ", 1)[0] if not file_id or "/" in file_id: abort(404) add_wide_event_context(nanoshare={"operation": "serve_file", "file_id": file_id}) file_data = await load_file(file_id) disable_logging = False if not file_data: add_wide_event_context(nanoshare={"operation_status": "not_found"}) abort(404) user = session.get('user') if user and user['sub'] == file_data['user_id']: disable_logging = True if file_data.get("expired", None): add_wide_event_context(nanoshare={"operation_status": "expired", "owner_request": disable_logging}) if not disable_logging: await current_app.convex.add_file_access(file_id=file_id, ip_address=get_ip(), user_agent=request.user_agent, status="expired") return Response("This file has expired.", status=410, headers={ "Cache-Control": "no-store", "X-Content-Type-Options": "nosniff", }) file_name = file_data.get("file_name") content_type = file_data.get("content_type") or "application/octet-stream" force_download = request.args.get("download") in {"1", "true", "yes"} if not file_data.get('db_image_url', None): add_wide_event_context(nanoshare={"operation_status": "missing_storage", "owner_request": disable_logging}) if not disable_logging: await current_app.convex.add_file_access(file_id=file_id, ip_address=get_ip(), user_agent=request.user_agent, status="error") abort(404) add_wide_event_context(nanoshare={"operation_status": "served", "owner_request": disable_logging, "content_type": content_type, "download": force_download}) if not disable_logging: await current_app.convex.add_file_access(file_id=file_id, ip_address=get_ip(), user_agent=request.user_agent, status="ok") if wants_text_view( content_type=content_type, accept_header=request.headers.get("Accept", ""), raw_param=request.args.get("raw"), force_download=force_download, ): return await render_text_view(file_id=file_id, file_data=file_data) last_modified = int(file_data["uploaded_at"]) / 1000 etag = build_etag(file_id, file_data, download=force_download) expires_at = file_data.get("expires_at") freshness = { "ETag": etag, "Last-Modified": http_date(last_modified), "Cache-Control": cache_control(expires_at, browser_max_age()), # Cloudflare-only, not proxied downstream: keeps the edge window short so a # deleted or expired file stops being served long before the browser TTL ends. "Cloudflare-CDN-Cache-Control": edge_cache_control(expires_at, edge_max_age()), } # Answered from metadata only, so a revalidation never pulls the blob from Convex. if is_fresh( request.headers.get("If-None-Match"), request.headers.get("If-Modified-Since"), etag, last_modified, ): add_wide_event_context(nanoshare={"cache_status": "not_modified"}) return Response(status=304, headers=freshness) response = await send_file( filename_or_io=BytesIO(await load_blob(file_data["db_image_url"])), mimetype=content_type, as_attachment=force_download, attachment_filename=file_name, conditional=True, last_modified=last_modified, ) response.headers.update(freshness) return response