Repository: vastsa/FileCodeBox Branch: master Commit: 1cc901d2b9ec Files: 85 Total size: 1.2 MB Directory structure: gitextract_50fi8_rw/ ├── .dockerignore ├── .gitattributes ├── .github/ │ ├── ISSUE_TEMPLATE/ │ │ ├── bug_report.md │ │ ├── custom.md │ │ └── feature_request.md │ └── workflows/ │ ├── docker-image.yml │ └── docs.yml ├── .gitignore ├── Dockerfile ├── LICENSE ├── SECURITY.md ├── apps/ │ ├── __init__.py │ ├── admin/ │ │ ├── __init__.py │ │ ├── dependencies.py │ │ ├── schemas.py │ │ ├── services.py │ │ └── views.py │ └── base/ │ ├── __init__.py │ ├── dependencies.py │ ├── migrations/ │ │ ├── migrations_001.py │ │ ├── migrations_002.py │ │ ├── migrations_003.py │ │ ├── migrations_004.py │ │ └── migrations_005.py │ ├── models.py │ ├── schemas.py │ ├── utils.py │ └── views.py ├── core/ │ ├── __init__.py │ ├── config.py │ ├── database.py │ ├── logger.py │ ├── response.py │ ├── settings.py │ ├── storage.py │ ├── tasks.py │ └── utils.py ├── docker-compose.yml ├── docs/ │ ├── .vitepress/ │ │ ├── cache/ │ │ │ └── deps/ │ │ │ ├── _metadata.json │ │ │ ├── chunk-CQOUZRMK.js │ │ │ ├── chunk-KT7LHMJ2.js │ │ │ ├── package.json │ │ │ ├── vitepress___@vue_devtools-api.js │ │ │ ├── vitepress___@vueuse_core.js │ │ │ ├── vitepress___@vueuse_integrations_useFocusTrap.js │ │ │ ├── vitepress___mark__js_src_vanilla__js.js │ │ │ ├── vitepress___minisearch.js │ │ │ └── vue.js │ │ ├── config.mts │ │ └── theme/ │ │ ├── custom.css │ │ └── index.ts │ ├── api/ │ │ ├── index.md │ │ └── presign-upload.md │ ├── changelog.md │ ├── contributing.md │ ├── en/ │ │ ├── api/ │ │ │ └── index.md │ │ ├── changelog.md │ │ ├── contributing.md │ │ ├── guide/ │ │ │ ├── configuration.md │ │ │ ├── getting-started.md │ │ │ ├── introduction.md │ │ │ ├── management.md │ │ │ ├── security.md │ │ │ ├── share.md │ │ │ ├── storage.md │ │ │ └── upload.md │ │ ├── index.md │ │ └── showcase.md │ ├── guide/ │ │ ├── configuration.md │ │ ├── getting-started.md │ │ ├── introduction.md │ │ ├── management.md │ │ ├── security.md │ │ ├── share.md │ │ ├── storage-onedrive.md │ │ ├── storage-opendal.md │ │ ├── storage.md │ │ └── upload.md │ ├── index.md │ ├── package.json │ └── showcase.md ├── main.py ├── readme.md ├── readme_en.md └── requirements.txt ================================================ FILE CONTENTS ================================================ ================================================ FILE: .dockerignore ================================================ # Git .git .gitignore .gitattributes # IDE .idea .vscode *.swp *.swo # Documentation *.md docs/ LICENSE # Development .venv __pycache__ *.pyc *.pyo .pytest_cache .coverage htmlcov/ # macOS .DS_Store ._* # Frontend source (will be built from GitHub) fcb-fronted/ themes/ # Test files test_*.py tests/ # Data (should be mounted as volume) data/ # GitHub .github/ # Docker Dockerfile docker-compose.yml .dockerignore # Claude .claude/ ================================================ FILE: .gitattributes ================================================ *.js linguist-language=python *.css linguist-language=python *.html linguist-language=python ================================================ FILE: .github/ISSUE_TEMPLATE/bug_report.md ================================================ --- name: Bug report about: Create a report to help us improve title: '' labels: '' assignees: '' --- **Describe the bug** A clear and concise description of what the bug is. **To Reproduce** Steps to reproduce the behavior: 1. Go to '...' 2. Click on '....' 3. Scroll down to '....' 4. See error **Expected behavior** A clear and concise description of what you expected to happen. **Screenshots** If applicable, add screenshots to help explain your problem. **Desktop (please complete the following information):** - OS: [e.g. iOS] - Browser [e.g. chrome, safari] - Version [e.g. 22] **Smartphone (please complete the following information):** - Device: [e.g. iPhone6] - OS: [e.g. iOS8.1] - Browser [e.g. stock browser, safari] - Version [e.g. 22] **Additional context** Add any other context about the problem here. ================================================ FILE: .github/ISSUE_TEMPLATE/custom.md ================================================ --- name: Custom issue template about: Describe this issue template's purpose here. title: '' labels: '' assignees: '' --- ================================================ FILE: .github/ISSUE_TEMPLATE/feature_request.md ================================================ --- name: Feature request about: Suggest an idea for this project title: '' labels: '' assignees: '' --- **Is your feature request related to a problem? Please describe.** A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] **Describe the solution you'd like** A clear and concise description of what you want to happen. **Describe alternatives you've considered** A clear and concise description of any alternative solutions or features you've considered. **Additional context** Add any other context or screenshots about the feature request here. ================================================ FILE: .github/workflows/docker-image.yml ================================================ name: Build and push Docker image on: workflow_dispatch: push: branches: - master - dev tags: - 'v*' env: REGISTRY_IMAGE: ${{ secrets.DOCKER_USERNAME }}/filecodebox jobs: buildx: runs-on: ubuntu-latest timeout-minutes: 60 steps: - name: Checkout uses: actions/checkout@v4 with: submodules: true - name: Extract metadata id: meta uses: docker/metadata-action@v5 with: images: ${{ env.REGISTRY_IMAGE }} tags: | type=ref,event=branch type=ref,event=pr type=semver,pattern={{version}} type=semver,pattern={{major}}.{{minor}} type=raw,value=dev,enable=${{ github.ref == 'refs/heads/dev' }} type=raw,value=beta,enable={{is_default_branch}} type=raw,value=latest,enable={{is_default_branch}} - name: Set up QEMU uses: docker/setup-qemu-action@v3 - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 with: driver-opts: | image=moby/buildkit:latest network=host - name: Login to DockerHub uses: docker/login-action@v3 with: username: ${{ secrets.DOCKER_USERNAME }} password: ${{ secrets.DOCKER_PASSWORD }} - name: Build and push uses: docker/build-push-action@v6 with: context: . platforms: linux/amd64,linux/arm64 push: true tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} cache-from: type=gha cache-to: type=gha,mode=max provenance: false ================================================ FILE: .github/workflows/docs.yml ================================================ # 构建 VitePress 站点并将其部署到 GitHub Pages 的示例工作流程 # name: Deploy VitePress site to Pages on: # 在针对 `main` 分支的推送上运行。如果你 # 使用 `master` 分支作为默认分支,请将其更改为 `master` push: branches: [ master ] # 允许你从 Actions 选项卡手动运行此工作流程 workflow_dispatch: # 设置 GITHUB_TOKEN 的权限,以允许部署到 GitHub Pages permissions: contents: read pages: write id-token: write # 只允许同时进行一次部署,跳过正在运行和最新队列之间的运行队列 # 但是,不要取消正在进行的运行,因为我们希望允许这些生产部署完成 concurrency: group: pages cancel-in-progress: false jobs: # 构建工作 build: runs-on: ubuntu-latest steps: - name: Checkout uses: actions/checkout@v4 with: fetch-depth: 0 # 如果未启用 lastUpdated,则不需要 - uses: pnpm/action-setup@v3 with: version: 9 # - uses: oven-sh/setup-bun@v1 # 如果使用 Bun,请取消注释 - name: Setup Node uses: actions/setup-node@v4 with: node-version: 20 cache: 'pnpm' cache-dependency-path: 'docs/pnpm-lock.yaml' - name: Setup Pages uses: actions/configure-pages@v4 - name: Install dependencies working-directory: docs run: pnpm install - name: Build with VitePress working-directory: docs run: pnpm run docs:build - name: Upload artifact uses: actions/upload-pages-artifact@v3 with: path: docs/.vitepress/dist # 部署工作 deploy: environment: name: github-pages url: ${{ steps.deployment.outputs.page_url }} needs: build runs-on: ubuntu-latest name: Deploy steps: - name: Deploy to GitHub Pages id: deployment uses: actions/deploy-pages@v4 ================================================ FILE: .gitignore ================================================ media/ logs/ .idea /data __pycache__/ *.py[cod] *$py.class # Created by .ignore support plugin (hsz.mobi) ### Python template # Byte-compiled / optimized / DLL files database.db # C extensions *.so *.env # Distribution / packaging .Python build/ develop-eggs/ downloads/ eggs/ .eggs/ lib/ lib64/ parts/ sdist/ var/ wheels/ share/python-wheels/ *.egg-info/ .installed.cfg *.egg MANIFEST .vite/ # PyInstaller # Usually these files are written by a python script from a template # before PyInstaller builds the exe, so as to inject date/other infos into it. *.manifest *.spec # Installer logs pip-log.txt pip-delete-this-directory.txt # Unit test.pdf / coverage reports htmlcov/ .tox/ .nox/ .coverage .coverage.* .cache nosetests.xml coverage.xml *.cover *.py,cover .hypothesis/ .pytest_cache/ cover/ # Translations *.mo *.pot # Django stuff: *.log local_settings.py db.sqlite3 db.sqlite3-journal # Flask stuff: instance/ .webassets-cache # Scrapy stuff: .scrapy # Sphinx documentation docs/_build/ # PyBuilder .pybuilder/ target/ *.db ./filecodebox.db-shm ./filecodebox.db-wal # Jupyter Notebook .ipynb_checkpoints # IPython profile_default/ ipython_config.py # pyenv # For a library or package, you might want to ignore these files since the code is # intended to run in multiple environments; otherwise, check them in: # .python-version # pipenv # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. # However, in case of collaboration, if having platform-specific dependencies or dependencies # having no cross-platform support, pipenv may install dependencies that don't work, or not # install all needed dependencies. #Pipfile.lock # PEP 582; used by e.g. github.com/David-OConnor/pyflow __pypackages__/ # Celery stuff celerybeat-schedule celerybeat.pid # SageMath parsed files *.sage.py # Environments data/.env .venv env/ venv/ ENV/ env.bak/ venv.bak/ # Spyder project settings .spyderproject .spyproject # Rope project settings .ropeproject # mkdocs documentation /site # mypy .mypy_cache/ .dmypy.json dmypy.json # Pyre type checker .pyre/ # pytype static type analyzer .pytype/ # Cython debug symbols cython_debug/ # Project .vscode .DS_Store for_test.py .html /evaluate/temp.py /evaluation/back.json data/.env .backup/ /cloc-1.64.exe # Ignore node_modules node_modules/ AGENTS.md dist/ # Frontend themes (built from GitHub during Docker build) themes/ ================================================ FILE: Dockerfile ================================================ # 第一阶段:构建前端主题 FROM node:20-alpine AS frontend-builder RUN apk add --no-cache git python3 make g++ WORKDIR /build # 克隆并构建 2024 主题 RUN git clone --depth 1 https://github.com/vastsa/FileCodeBoxFronted.git /build/fronted-2024 && \ cd /build/fronted-2024 && \ npm install && \ npm run build # 克隆并构建 2023 主题 RUN git clone --depth 1 https://github.com/vastsa/FileCodeBoxFronted2023.git /build/fronted-2023 && \ cd /build/fronted-2023 && \ npm install --legacy-peer-deps && \ npm run build # 第二阶段:构建最终镜像 FROM python:3.12-slim-bookworm LABEL author="Lan" LABEL email="xzu@live.com" WORKDIR /app # 复制项目文件(通过 .dockerignore 排除不必要的文件) COPY . . # 设置时区 RUN ln -sf /usr/share/zoneinfo/Asia/Shanghai /etc/localtime && \ echo 'Asia/Shanghai' > /etc/timezone # 从构建阶段复制编译好的前端主题 COPY --from=frontend-builder /build/fronted-2024/dist ./themes/2024 COPY --from=frontend-builder /build/fronted-2023/dist ./themes/2023 # 安装 Python 依赖 RUN pip install --no-cache-dir -r requirements.txt # 环境变量配置 ENV HOST="0.0.0.0" \ PORT=12345 \ WORKERS=1 \ LOG_LEVEL="info" EXPOSE 12345 # 生产环境启动命令 CMD uvicorn main:app \ --host $HOST \ --port $PORT \ --workers $WORKERS \ --log-level $LOG_LEVEL \ --proxy-headers \ --forwarded-allow-ips "*" ================================================ FILE: LICENSE ================================================ GNU LESSER GENERAL PUBLIC LICENSE Version 3, 29 June 2007 Copyright (C) 2007 Free Software Foundation, Inc. Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed. This version of the GNU Lesser General Public License incorporates the terms and conditions of version 3 of the GNU General Public License, supplemented by the additional permissions listed below. 0. Additional Definitions. As used herein, "this License" refers to version 3 of the GNU Lesser General Public License, and the "GNU GPL" refers to version 3 of the GNU General Public License. "The Library" refers to a covered work governed by this License, other than an Application or a Combined Work as defined below. An "Application" is any work that makes use of an interface provided by the Library, but which is not otherwise based on the Library. Defining a subclass of a class defined by the Library is deemed a mode of using an interface provided by the Library. A "Combined Work" is a work produced by combining or linking an Application with the Library. The particular version of the Library with which the Combined Work was made is also called the "Linked Version". The "Minimal Corresponding Source" for a Combined Work means the Corresponding Source for the Combined Work, excluding any source code for portions of the Combined Work that, considered in isolation, are based on the Application, and not on the Linked Version. The "Corresponding Application Code" for a Combined Work means the object code and/or source code for the Application, including any data and utility programs needed for reproducing the Combined Work from the Application, but excluding the System Libraries of the Combined Work. 1. Exception to Section 3 of the GNU GPL. You may convey a covered work under sections 3 and 4 of this License without being bound by section 3 of the GNU GPL. 2. Conveying Modified Versions. If you modify a copy of the Library, and, in your modifications, a facility refers to a function or data to be supplied by an Application that uses the facility (other than as an argument passed when the facility is invoked), then you may convey a copy of the modified version: a) under this License, provided that you make a good faith effort to ensure that, in the event an Application does not supply the function or data, the facility still operates, and performs whatever part of its purpose remains meaningful, or b) under the GNU GPL, with none of the additional permissions of this License applicable to that copy. 3. Object Code Incorporating Material from Library Header Files. The object code form of an Application may incorporate material from a header file that is part of the Library. You may convey such object code under terms of your choice, provided that, if the incorporated material is not limited to numerical parameters, data structure layouts and accessors, or small macros, inline functions and templates (ten or fewer lines in length), you do both of the following: a) Give prominent notice with each copy of the object code that the Library is used in it and that the Library and its use are covered by this License. b) Accompany the object code with a copy of the GNU GPL and this license document. 4. Combined Works. You may convey a Combined Work under terms of your choice that, taken together, effectively do not restrict modification of the portions of the Library contained in the Combined Work and reverse engineering for debugging such modifications, if you also do each of the following: a) Give prominent notice with each copy of the Combined Work that the Library is used in it and that the Library and its use are covered by this License. b) Accompany the Combined Work with a copy of the GNU GPL and this license document. c) For a Combined Work that displays copyright notices during execution, include the copyright notice for the Library among these notices, as well as a reference directing the user to the copies of the GNU GPL and this license document. d) Do one of the following: 0) Convey the Minimal Corresponding Source under the terms of this License, and the Corresponding Application Code in a form suitable for, and under terms that permit, the user to recombine or relink the Application with a modified version of the Linked Version to produce a modified Combined Work, in the manner specified by section 6 of the GNU GPL for conveying Corresponding Source. 1) Use a suitable shared library mechanism for linking with the Library. A suitable mechanism is one that (a) uses at run time a copy of the Library already present on the user's computer system, and (b) will operate properly with a modified version of the Library that is interface-compatible with the Linked Version. e) Provide Installation Information, but only if you would otherwise be required to provide such information under section 6 of the GNU GPL, and only to the extent that such information is necessary to install and execute a modified version of the Combined Work produced by recombining or relinking the Application with a modified version of the Linked Version. (If you use option 4d0, the Installation Information must accompany the Minimal Corresponding Source and Corresponding Application Code. If you use option 4d1, you must provide the Installation Information in the manner specified by section 6 of the GNU GPL for conveying Corresponding Source.) 5. Combined Libraries. You may place library facilities that are a work based on the Library side by side in a single library together with other library facilities that are not Applications and are not covered by this License, and convey such a combined library under terms of your choice, if you do both of the following: a) Accompany the combined library with a copy of the same work based on the Library, uncombined with any other library facilities, conveyed under the terms of this License. b) Give prominent notice with the combined library that part of it is a work based on the Library, and explaining where to find the accompanying uncombined form of the same work. 6. Revised Versions of the GNU Lesser General Public License. The Free Software Foundation may publish revised and/or new versions of the GNU Lesser General Public License from time to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new problems or concerns. Each version is given a distinguishing version number. If the Library as you received it specifies that a certain numbered version of the GNU Lesser General Public License "or any later version" applies to it, you have the option of following the terms and conditions either of that published version or of any later version published by the Free Software Foundation. If the Library as you received it does not specify a version number of the GNU Lesser General Public License, you may choose any version of the GNU Lesser General Public License ever published by the Free Software Foundation. If the Library as you received it specifies that a proxy can decide whether future versions of the GNU Lesser General Public License shall apply, that proxy's public statement of acceptance of any version is permanent authorization for you to choose that version for the Library. ================================================ FILE: SECURITY.md ================================================ # Security Policy ## Supported Versions Use this section to tell people about which versions of your project are currently being supported with security updates. | Version | Supported | | ------- | ------------------ | | 5.1.x | :white_check_mark: | | 5.0.x | :x: | | 4.0.x | :white_check_mark: | | < 4.0 | :x: | ## Reporting a Vulnerability Use this section to tell people how to report a vulnerability. Tell them where to go, how often they can expect to get an update on a reported vulnerability, what to expect if the vulnerability is accepted or declined, etc. ================================================ FILE: apps/__init__.py ================================================ # @Time : 2023/8/13 20:43 # @Author : Lan # @File : __init__.py.py # @Software: PyCharm ================================================ FILE: apps/admin/__init__.py ================================================ # @Time : 2023/8/14 14:38 # @Author : Lan # @File : __init__.py.py # @Software: PyCharm ================================================ FILE: apps/admin/dependencies.py ================================================ # @Time : 2023/8/15 17:43 # @Author : Lan # @File : depends.py # @Software: PyCharm from fastapi import Header, HTTPException, Depends from fastapi.requests import Request import base64 import hmac import json import time from core.settings import settings from apps.admin.services import FileService, ConfigService, LocalFileService def create_token(data: dict, expires_in: int = 3600 * 24 * 30) -> str: """ 创建JWT token :param data: 数据负载 :param expires_in: 过期时间(秒) """ header = base64.b64encode( json.dumps({"alg": "HS256", "typ": "JWT"}).encode() ).decode() payload = base64.b64encode( json.dumps({**data, "exp": int(time.time()) + expires_in}).encode() ).decode() signature = hmac.new( settings.admin_token.encode(), f"{header}.{payload}".encode(), "sha256" ).digest() signature = base64.b64encode(signature).decode() return f"{header}.{payload}.{signature}" def verify_token(token: str) -> dict: """ 验证JWT token :param token: JWT token :return: 解码后的数据 """ try: header_b64, payload_b64, signature_b64 = token.split(".") # 验证签名 expected_signature = hmac.new( settings.admin_token.encode(), f"{header_b64}.{payload_b64}".encode(), "sha256", ).digest() expected_signature_b64 = base64.b64encode(expected_signature).decode() if not hmac.compare_digest(signature_b64, expected_signature_b64): raise ValueError("无效的签名") # 解码payload payload = json.loads(base64.b64decode(payload_b64)) # 检查是否过期 if payload.get("exp", 0) < time.time(): raise ValueError("token已过期") return payload except Exception as e: raise ValueError(f"token验证失败: {str(e)}") def _extract_bearer_token(authorization: str) -> str: if not authorization or not authorization.startswith("Bearer "): raise HTTPException(status_code=401, detail="未授权或授权校验失败") token = authorization.split(" ", 1)[1].strip() if not token: raise HTTPException(status_code=401, detail="未授权或授权校验失败") return token def _require_admin_payload(authorization: str) -> dict: token = _extract_bearer_token(authorization) try: payload = verify_token(token) except ValueError as e: raise HTTPException(status_code=401, detail=str(e)) if not payload.get("is_admin", False): raise HTTPException(status_code=401, detail="未授权或授权校验失败") return payload ADMIN_PUBLIC_ENDPOINTS = {("POST", "/admin/login")} async def admin_required( authorization: str = Header(default=None), request: Request = None ): """ 验证管理员权限 """ if request and (request.method, request.url.path) in ADMIN_PUBLIC_ENDPOINTS: return None return _require_admin_payload(authorization) async def share_required_login(authorization: str = Header(default=None)): """ 验证分享上传权限 当settings.openUpload为False时,要求用户必须登录并具有管理员权限 当settings.openUpload为True时,允许游客上传 :param authorization: 认证头信息 :param request: 请求对象 :return: 验证结果 """ if not settings.openUpload: if not authorization or not authorization.startswith("Bearer "): raise HTTPException( status_code=403, detail="本站未开启游客上传,如需上传请先登录后台" ) _require_admin_payload(authorization) return True async def get_file_service(): return FileService() async def get_config_service(): return ConfigService() async def get_local_file_service(): return LocalFileService() ================================================ FILE: apps/admin/schemas.py ================================================ import datetime from typing import Optional, Union from pydantic import BaseModel class IDData(BaseModel): id: int class ShareItem(BaseModel): expire_value: int expire_style: str = "day" filename: str class DeleteItem(BaseModel): filename: str class LoginData(BaseModel): password: str class UpdateFileData(BaseModel): id: int code: Optional[str] = None prefix: Optional[str] = None suffix: Optional[str] = None expired_at: Optional[Union[datetime.datetime, str]] = None expired_count: Optional[int] = None ================================================ FILE: apps/admin/services.py ================================================ import os import time from core.response import APIResponse from core.storage import FileStorageInterface, storages from core.settings import settings from apps.base.models import FileCodes, KeyValue, file_codes_pydantic from apps.base.utils import get_expire_info, get_file_path_name from fastapi import HTTPException from core.settings import data_root from core.utils import hash_password, is_password_hashed class FileService: def __init__(self): self.file_storage: FileStorageInterface = storages[settings.file_storage]() async def delete_file(self, file_id: int): file_code = await FileCodes.get(id=file_id) await self.file_storage.delete_file(file_code) await file_code.delete() async def list_files(self, page: int, size: int, keyword: str = ""): offset = (page - 1) * size files = ( await FileCodes.filter(prefix__icontains=keyword).limit(size).offset(offset) ) total = await FileCodes.filter(prefix__icontains=keyword).count() files_pydantic = [ await file_codes_pydantic.from_tortoise_orm(f) for f in files ] return files_pydantic, total async def download_file(self, file_id: int): file_code = await FileCodes.filter(id=file_id).first() if not file_code: raise HTTPException(status_code=404, detail="文件不存在") if file_code.text: return APIResponse(detail=file_code.text) else: return await self.file_storage.get_file_response(file_code) async def share_local_file(self, item): local_file = LocalFileClass(item.filename) if not await local_file.exists(): raise HTTPException(status_code=404, detail="文件不存在") text = await local_file.read() expired_at, expired_count, used_count, code = await get_expire_info( item.expire_value, item.expire_style ) path, suffix, prefix, uuid_file_name, save_path = await get_file_path_name(item) await self.file_storage.save_file(text, save_path) await FileCodes.create( code=code, prefix=prefix, suffix=suffix, uuid_file_name=uuid_file_name, file_path=path, size=local_file.size, expired_at=expired_at, expired_count=expired_count, used_count=used_count, ) return { "code": code, "name": local_file.file, } class ConfigService: def get_config(self): return dict(settings.items()) async def update_config(self, data: dict): admin_token = data.get("admin_token") if admin_token is None or admin_token == "": raise HTTPException(status_code=400, detail="管理员密码不能为空") if not is_password_hashed(admin_token): data["admin_token"] = hash_password(admin_token) for key, value in data.items(): if key not in settings.default_config: continue if key in [ "errorCount", "errorMinute", "max_save_seconds", "onedrive_proxy", "openUpload", "port", "s3_proxy", "uploadCount", "uploadMinute", "uploadSize", ]: data[key] = int(value) elif key in ["opacity"]: data[key] = float(value) else: data[key] = value await KeyValue.filter(key="settings").update(value=data) for k, v in data.items(): settings.__setattr__(k, v) class LocalFileService: async def list_files(self): files = [] if not os.path.exists(data_root / "local"): os.makedirs(data_root / "local") for file in os.listdir(data_root / "local"): local_file = LocalFileClass(file) files.append({ "file": local_file.file, "ctime": local_file.ctime, "size": local_file.size, }) return files async def delete_file(self, filename: str): file = LocalFileClass(filename) if await file.exists(): await file.delete() return "删除成功" raise HTTPException(status_code=404, detail="文件不存在") class LocalFileClass: def __init__(self, file): self.file = file self.path = data_root / "local" / file if os.path.exists(self.path): self.ctime = time.strftime( "%Y-%m-%d %H:%M:%S", time.localtime(os.path.getctime(self.path)) ) self.size = os.path.getsize(self.path) else: self.ctime = None self.size = None async def read(self): return open(self.path, "rb") async def write(self, data): with open(self.path, "w") as f: f.write(data) async def delete(self): os.remove(self.path) async def exists(self): return os.path.exists(self.path) ================================================ FILE: apps/admin/views.py ================================================ # @Time : 2023/8/14 14:38 # @Author : Lan # @File : views.py # @Software: PyCharm import datetime from fastapi import APIRouter, Depends, HTTPException from apps.admin.services import FileService, ConfigService, LocalFileService from apps.admin.dependencies import ( admin_required, get_file_service, get_config_service, get_local_file_service, ) from apps.admin.schemas import IDData, ShareItem, DeleteItem, LoginData, UpdateFileData from core.response import APIResponse from apps.base.models import FileCodes, KeyValue from apps.admin.dependencies import create_token from core.settings import settings from core.utils import get_now, verify_password admin_api = APIRouter( prefix="/admin", tags=["管理"], dependencies=[Depends(admin_required)] ) @admin_api.post("/login") async def login(data: LoginData): if not verify_password(data.password, settings.admin_token): raise HTTPException(status_code=401, detail="密码错误") token = create_token({"is_admin": True}) return APIResponse(detail={"token": token, "token_type": "Bearer"}) @admin_api.get("/dashboard") async def dashboard(): all_codes = await FileCodes.all() all_size = str(sum([code.size for code in all_codes])) sys_start = await KeyValue.filter(key="sys_start").first() now = await get_now() today_start = now.replace(hour=0, minute=0, second=0, microsecond=0) yesterday_start = today_start - datetime.timedelta(days=1) yesterday_end = today_start - datetime.timedelta(microseconds=1) yesterday_codes = FileCodes.filter( created_at__gte=yesterday_start, created_at__lte=yesterday_end ) today_codes = FileCodes.filter(created_at__gte=today_start) return APIResponse( detail={ "totalFiles": len(all_codes), "storageUsed": all_size, "sysUptime": sys_start.value, "yesterdayCount": await yesterday_codes.count(), "yesterdaySize": str(sum([code.size for code in await yesterday_codes])), "todayCount": await today_codes.count(), "todaySize": str(sum([code.size for code in await today_codes])), } ) @admin_api.delete("/file/delete") async def file_delete( data: IDData, file_service: FileService = Depends(get_file_service), ): await file_service.delete_file(data.id) return APIResponse() @admin_api.get("/file/list") async def file_list( page: int = 1, size: int = 10, keyword: str = "", file_service: FileService = Depends(get_file_service), ): files, total = await file_service.list_files(page, size, keyword) return APIResponse( detail={ "page": page, "size": size, "data": files, "total": total, } ) @admin_api.get("/config/get") async def get_config( config_service: ConfigService = Depends(get_config_service), ): return APIResponse(detail=config_service.get_config()) @admin_api.patch("/config/update") async def update_config( data: dict, config_service: ConfigService = Depends(get_config_service), ): data.pop("themesChoices") await config_service.update_config(data) return APIResponse() @admin_api.get("/file/download") async def file_download( id: int, file_service: FileService = Depends(get_file_service), ): file_content = await file_service.download_file(id) return file_content @admin_api.get("/local/lists") async def get_local_lists( local_file_service: LocalFileService = Depends(get_local_file_service), ): files = await local_file_service.list_files() return APIResponse(detail=files) @admin_api.delete("/local/delete") async def delete_local_file( item: DeleteItem, local_file_service: LocalFileService = Depends(get_local_file_service), ): result = await local_file_service.delete_file(item.filename) return APIResponse(detail=result) @admin_api.post("/local/share") async def share_local_file( item: ShareItem, file_service: FileService = Depends(get_file_service), ): share_info = await file_service.share_local_file(item) return APIResponse(detail=share_info) @admin_api.patch("/file/update") async def update_file( data: UpdateFileData, ): file_code = await FileCodes.filter(id=data.id).first() if not file_code: raise HTTPException(status_code=404, detail="文件不存在") update_data = {} if data.code is not None and data.code != file_code.code: # 判断code是否存在 if await FileCodes.filter(code=data.code).first(): raise HTTPException(status_code=400, detail="code已存在") update_data["code"] = data.code if data.prefix is not None and data.prefix != file_code.prefix: update_data["prefix"] = data.prefix if data.suffix is not None and data.suffix != file_code.suffix: update_data["suffix"] = data.suffix if ( data.expired_at is not None and data.expired_at != "" and data.expired_at != file_code.expired_at ): update_data["expired_at"] = data.expired_at if data.expired_count is not None and data.expired_count != file_code.expired_count: update_data["expired_count"] = data.expired_count await file_code.update_from_dict(update_data).save() return APIResponse(detail="更新成功") ================================================ FILE: apps/base/__init__.py ================================================ # @Time : 2023/8/13 20:43 # @Author : Lan # @File : __init__.py.py # @Software: PyCharm ================================================ FILE: apps/base/dependencies.py ================================================ from typing import Dict, Union from datetime import datetime, timedelta from fastapi import HTTPException, Request class IPRateLimit: def __init__(self, count: int, minutes: int): self.ips: Dict[str, Dict[str, Union[int, datetime]]] = {} self.count = count self.minutes = minutes def check_ip(self, ip: str) -> bool: if ip in self.ips: ip_info = self.ips[ip] if ip_info["count"] >= self.count: if ip_info["time"] + timedelta(minutes=self.minutes) > datetime.now(): return False self.ips.pop(ip) return True def add_ip(self, ip: str) -> int: ip_info = self.ips.get(ip, {"count": 0, "time": datetime.now()}) ip_info["count"] += 1 ip_info["time"] = datetime.now() self.ips[ip] = ip_info return ip_info["count"] async def remove_expired_ip(self) -> None: now = datetime.now() expiration = timedelta(minutes=self.minutes) self.ips = { ip: info for ip, info in self.ips.items() if info["time"] + expiration >= now } def __call__(self, request: Request) -> str: ip = ( request.headers.get("X-Real-IP") or request.headers.get("X-Forwarded-For") or request.client.host ) if not self.check_ip(ip): raise HTTPException(status_code=423, detail="请求次数过多,请稍后再试") return ip ================================================ FILE: apps/base/migrations/migrations_001.py ================================================ from tortoise import connections async def create_file_codes_table(): conn = connections.get("default") await conn.execute_script( """ CREATE TABLE IF NOT EXISTS filecodes ( id INTEGER not null primary key autoincrement, code VARCHAR(255) not null unique, prefix VARCHAR(255) default '' not null, suffix VARCHAR(255) default '' not null, uuid_file_name VARCHAR(255), file_path VARCHAR(255), size INT default 0 not null, text TEXT, expired_at TIMESTAMP, expired_count INT default 0 not null, used_count INT default 0 not null, created_at TIMESTAMP default CURRENT_TIMESTAMP not null ); CREATE INDEX IF NOT EXISTS idx_filecodes_code_1c7ee7 on filecodes (code); """ ) async def create_key_value_table(): conn = connections.get("default") await conn.execute_script( """ CREATE TABLE IF NOT EXISTS keyvalue ( id INTEGER not null primary key autoincrement, key VARCHAR(255) not null unique, value JSON, created_at TIMESTAMP default CURRENT_TIMESTAMP not null ); CREATE INDEX IF NOT EXISTS idx_keyvalue_key_eab890 on keyvalue (key); """ ) async def migrate(): await create_file_codes_table() await create_key_value_table() ================================================ FILE: apps/base/migrations/migrations_002.py ================================================ from tortoise import connections async def create_upload_chunk_and_update_file_codes_table(): conn = connections.get("default") await conn.execute_script( """ ALTER TABLE "filecodes" ADD "file_hash" VARCHAR(128); ALTER TABLE "filecodes" ADD "is_chunked" BOOL NOT NULL DEFAULT False; ALTER TABLE "filecodes" ADD "upload_id" VARCHAR(128); CREATE TABLE "uploadchunk" ( id INTEGER not null primary key autoincrement, "upload_id" VARCHAR(36) NOT NULL, "chunk_index" INT NOT NULL, "chunk_hash" VARCHAR(128) NOT NULL, "total_chunks" INT NOT NULL, "file_size" BIGINT NOT NULL, "chunk_size" INT NOT NULL, "created_at" TIMESTAMPTZ NOT NULL, "file_name" VARCHAR(255) NOT NULL, "completed" BOOL NOT NULL ); """ ) async def migrate(): await create_upload_chunk_and_update_file_codes_table() ================================================ FILE: apps/base/migrations/migrations_003.py ================================================ from tortoise import connections async def create_presign_upload_session_table(): conn = connections.get("default") await conn.execute_script( """ CREATE TABLE IF NOT EXISTS presignuploadsession ( id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, upload_id VARCHAR(36) NOT NULL UNIQUE, file_name VARCHAR(255) NOT NULL, file_size BIGINT NOT NULL, save_path VARCHAR(512) NOT NULL, mode VARCHAR(10) NOT NULL, expire_value INT NOT NULL DEFAULT 1, expire_style VARCHAR(20) NOT NULL DEFAULT 'day', created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, expires_at TIMESTAMP NOT NULL ); CREATE INDEX IF NOT EXISTS idx_presignuploadsession_upload_id ON presignuploadsession (upload_id); """ ) async def migrate(): await create_presign_upload_session_table() ================================================ FILE: apps/base/migrations/migrations_004.py ================================================ from tortoise import connections async def add_save_path_to_uploadchunk(): conn = connections.get("default") await conn.execute_script( """ ALTER TABLE uploadchunk ADD COLUMN save_path VARCHAR(512) NULL; """ ) async def migrate(): await add_save_path_to_uploadchunk() ================================================ FILE: apps/base/migrations/migrations_005.py ================================================ from tortoise import connections def _need_upgrade(columns: list[tuple]) -> bool: for column in columns: # PRAGMA table_info 返回 (cid, name, type, notnull, dflt_value, pk) if column[1] == "size": column_type = (column[2] or "").upper() return "BIGINT" not in column_type return False async def migrate(): conn = connections.get("default") result = await conn.execute_query("PRAGMA table_info(filecodes)") columns = result[1] if result and len(result) > 1 else [] if not columns or not _need_upgrade(columns): return await conn.execute_script( """ BEGIN; CREATE TABLE IF NOT EXISTS filecodes_new ( id INTEGER not null primary key autoincrement, code VARCHAR(255) not null unique, prefix VARCHAR(255) default '' not null, suffix VARCHAR(255) default '' not null, uuid_file_name VARCHAR(255), file_path VARCHAR(255), size BIGINT default 0 not null, text TEXT, expired_at TIMESTAMP, expired_count INT default 0 not null, used_count INT default 0 not null, created_at TIMESTAMP default CURRENT_TIMESTAMP not null, file_hash VARCHAR(128), is_chunked BOOL default False not null, upload_id VARCHAR(128) ); INSERT INTO filecodes_new (id, code, prefix, suffix, uuid_file_name, file_path, size, text, expired_at, expired_count, used_count, created_at, file_hash, is_chunked, upload_id) SELECT id, code, prefix, suffix, uuid_file_name, file_path, size, text, expired_at, expired_count, used_count, created_at, file_hash, is_chunked, upload_id FROM filecodes; DROP TABLE filecodes; ALTER TABLE filecodes_new RENAME TO filecodes; CREATE INDEX IF NOT EXISTS idx_filecodes_code_1c7ee7 on filecodes (code); COMMIT; """ ) ================================================ FILE: apps/base/models.py ================================================ # @Time : 2023/8/13 20:43 # @Author : Lan # @File : models.py # @Software: PyCharm from typing import Optional from tortoise.models import Model from tortoise.contrib.pydantic import pydantic_model_creator from tortoise import fields, models from datetime import datetime from core.utils import get_now class FileCodes(models.Model): id = fields.IntField(pk=True) code = fields.CharField(max_length=255, unique=True, index=True) prefix = fields.CharField(max_length=255, default="") suffix = fields.CharField(max_length=255, default="") uuid_file_name = fields.CharField(max_length=255, null=True) file_path = fields.CharField(max_length=255, null=True) size = fields.BigIntField(default=0) text = fields.TextField(null=True) expired_at = fields.DatetimeField(null=True) expired_count = fields.IntField(default=0) used_count = fields.IntField(default=0) created_at = fields.DatetimeField(auto_now_add=True) file_hash = fields.CharField(max_length=64, null=True) is_chunked = fields.BooleanField(default=False) upload_id = fields.CharField(max_length=36, null=True) async def is_expired(self): if self.expired_at is None: return False if self.expired_at and self.expired_count < 0: return self.expired_at < await get_now() return self.expired_count <= 0 async def get_file_path(self): return f"{self.file_path}/{self.uuid_file_name}" class UploadChunk(models.Model): id = fields.IntField(pk=True) upload_id = fields.CharField(max_length=36, index=True) chunk_index = fields.IntField() chunk_hash = fields.CharField(max_length=64) total_chunks = fields.IntField() file_size = fields.BigIntField() chunk_size = fields.IntField() file_name = fields.CharField(max_length=255) save_path = fields.CharField(max_length=512, null=True) created_at = fields.DatetimeField(auto_now_add=True) completed = fields.BooleanField(default=False) class KeyValue(Model): id: Optional[int] = fields.IntField(pk=True) key: Optional[str] = fields.CharField( max_length=255, description="键", index=True, unique=True ) value: Optional[str] = fields.JSONField(description="值", null=True) created_at: Optional[datetime] = fields.DatetimeField( auto_now_add=True, description="创建时间" ) class PresignUploadSession(models.Model): """预签名上传会话模型""" id = fields.IntField(pk=True) upload_id = fields.CharField(max_length=36, unique=True, index=True) file_name = fields.CharField(max_length=255) file_size = fields.BigIntField() save_path = fields.CharField(max_length=512) mode = fields.CharField(max_length=10) # "direct" 或 "proxy" expire_value = fields.IntField(default=1) expire_style = fields.CharField(max_length=20, default="day") created_at = fields.DatetimeField(auto_now_add=True) expires_at = fields.DatetimeField() # 会话过期时间 async def is_expired(self): """检查会话是否已过期""" return self.expires_at < await get_now() file_codes_pydantic = pydantic_model_creator(FileCodes, name="FileCodes") upload_chunk_pydantic = pydantic_model_creator(UploadChunk, name="UploadChunk") key_value_pydantic = pydantic_model_creator(KeyValue, name="KeyValue") presign_upload_session_pydantic = pydantic_model_creator( PresignUploadSession, name="PresignUploadSession" ) ================================================ FILE: apps/base/schemas.py ================================================ from pydantic import BaseModel from typing import Optional class SelectFileModel(BaseModel): code: str class InitChunkUploadModel(BaseModel): file_name: str chunk_size: int = 5 * 1024 * 1024 file_size: int file_hash: str class CompleteUploadModel(BaseModel): expire_value: int expire_style: str # 预签名上传相关模型 class PresignUploadInitRequest(BaseModel): """预签名上传初始化请求""" file_name: str file_size: int expire_value: int = 1 expire_style: str = "day" class PresignUploadInitResponse(BaseModel): """预签名上传初始化响应""" upload_id: str upload_url: str mode: str # "direct" 或 "proxy" save_path: str expires_in: int # URL过期时间(秒) ================================================ FILE: apps/base/utils.py ================================================ import datetime import hashlib import os import uuid from urllib.parse import unquote from fastapi import UploadFile, HTTPException from typing import Optional, Tuple from apps.base.dependencies import IPRateLimit from apps.base.models import FileCodes from core.settings import settings from core.utils import ( get_random_num, get_random_string, max_save_times_desc, sanitize_filename, get_now, ) async def get_file_path_name(file: UploadFile) -> Tuple[str, str, str, str, str]: today = await get_now() storage_path = settings.storage_path.strip("/") file_uuid = uuid.uuid4().hex filename = await sanitize_filename(unquote(file.filename or "")) base_path = f"share/data/{today.strftime('%Y/%m/%d')}/{file_uuid}" path = f"{storage_path}/{base_path}" if storage_path else base_path prefix, suffix = os.path.splitext(filename) save_path = f"{path}/{filename}" return path, suffix, prefix, filename, save_path async def get_chunk_file_path_name( file_name: str, upload_id: str ) -> Tuple[str, str, str, str, str]: today = await get_now() storage_path = settings.storage_path.strip("/") base_path = f"share/data/{today.strftime('%Y/%m/%d')}/{upload_id}" path = f"{storage_path}/{base_path}" if storage_path else base_path prefix, suffix = os.path.splitext(file_name) save_path = f"{path}/{prefix}{suffix}" return path, suffix, prefix, file_name, save_path async def get_expire_info( expire_value: int, expire_style: str ) -> Tuple[Optional[datetime.datetime], int, int, str]: expired_count, used_count = -1, 0 now = await get_now() code = None max_timedelta = ( datetime.timedelta(seconds=settings.max_save_seconds) if settings.max_save_seconds > 0 else datetime.timedelta(days=7) ) detail = ( await max_save_times_desc(settings.max_save_seconds) if settings.max_save_seconds > 0 else "7天" ) detail = f"限制最长时间为 {detail[0]},可换用其他方式" expire_styles = { "day": lambda: now + datetime.timedelta(days=expire_value), "hour": lambda: now + datetime.timedelta(hours=expire_value), "minute": lambda: now + datetime.timedelta(minutes=expire_value), "count": lambda: (now + datetime.timedelta(days=1), expire_value), "forever": lambda: (None, None), } if expire_style in expire_styles: result = expire_styles[expire_style]() if isinstance(result, tuple): expired_at, extra = result if expire_style == "count": expired_count = extra elif expire_style == "forever": code = await get_random_code(style="string") else: expired_at = result if expired_at and expired_at - now > max_timedelta: raise HTTPException(status_code=403, detail=detail) else: expired_at = now + datetime.timedelta(days=1) if not code: code = await get_random_code() return expired_at, expired_count, used_count, code async def get_random_code(style: str = "num") -> str: while True: code = await get_random_num() if style == "num" else await get_random_string() if not await FileCodes.filter(code=code).exists(): return str(code) async def calculate_file_hash(file: UploadFile, chunk_size=1024 * 1024) -> str: sha = hashlib.sha256() await file.seek(0) while True: chunk = await file.read(chunk_size) if not chunk: break sha.update(chunk) await file.seek(0) return sha.hexdigest() ip_limit = { "error": IPRateLimit(count=settings.errorCount, minutes=settings.errorMinute), "upload": IPRateLimit(count=settings.uploadCount, minutes=settings.uploadMinute), } ================================================ FILE: apps/base/views.py ================================================ import datetime import hashlib import os import uuid from datetime import timedelta from urllib.parse import unquote from typing import Optional, Tuple, Union from fastapi import APIRouter, Form, UploadFile, File, Depends, HTTPException from starlette import status from apps.admin.dependencies import share_required_login from apps.base.models import FileCodes, UploadChunk, PresignUploadSession from apps.base.schemas import ( SelectFileModel, InitChunkUploadModel, CompleteUploadModel, PresignUploadInitRequest, ) from apps.base.utils import ( get_expire_info, get_file_path_name, ip_limit, get_chunk_file_path_name, ) from core.response import APIResponse from core.settings import settings from core.storage import storages, FileStorageInterface from core.utils import get_select_token, get_now, sanitize_filename share_api = APIRouter(prefix="/share", tags=["分享"]) # ============ 公共服务层 ============ class FileUploadService: """统一的文件上传服务""" @staticmethod async def generate_file_path( file_name: str, upload_id: Optional[str] = None ) -> tuple[str, str, str, str, str]: """统一的路径生成""" today = datetime.datetime.now() storage_path = settings.storage_path.strip("/") file_uuid = upload_id or uuid.uuid4().hex filename = await sanitize_filename(unquote(file_name)) base_path = f"share/data/{today.strftime('%Y/%m/%d')}/{file_uuid}" path = f"{storage_path}/{base_path}" if storage_path else base_path prefix, suffix = os.path.splitext(filename) save_path = f"{path}/{filename}" return path, suffix, prefix, filename, save_path @staticmethod async def create_file_record( file_name: str, file_size: int, file_path: str, expire_value: int, expire_style: str, **extra_fields, ) -> str: """统一创建FileCodes记录,返回code""" expired_at, expired_count, used_count, code = await get_expire_info( expire_value, expire_style ) prefix, suffix = os.path.splitext(file_name) await FileCodes.create( code=code, prefix=prefix, suffix=suffix, uuid_file_name=file_name, file_path=file_path, size=file_size, expired_at=expired_at, expired_count=expired_count, used_count=used_count, **extra_fields, ) return code async def validate_file_size(file: UploadFile, max_size: int) -> int: size = file.size if size is None: await file.seek(0, 2) # type: ignore[arg-type] size = file.file.tell() await file.seek(0) if size > max_size: max_size_mb = max_size / (1024 * 1024) raise HTTPException( status_code=403, detail=f"大小超过限制,最大为{max_size_mb:.2f} MB" ) return size async def create_file_code(code, **kwargs): return await FileCodes.create(code=code, **kwargs) @share_api.post("/text/", dependencies=[Depends(share_required_login)]) async def share_text( text: str = Form(...), expire_value: int = Form(default=1, gt=0), expire_style: str = Form(default="day"), ip: str = Depends(ip_limit["upload"]), ): text_size = len(text.encode("utf-8")) max_txt_size = 222 * 1024 if text_size > max_txt_size: raise HTTPException(status_code=403, detail="内容过多,建议采用文件形式") expired_at, expired_count, used_count, code = await get_expire_info( expire_value, expire_style ) await create_file_code( code=code, text=text, expired_at=expired_at, expired_count=expired_count, used_count=used_count, size=len(text), prefix="Text", ) ip_limit["upload"].add_ip(ip) return APIResponse(detail={"code": code}) @share_api.post("/file/", dependencies=[Depends(share_required_login)]) async def share_file( expire_value: int = Form(default=1, gt=0), expire_style: str = Form(default="day"), file: UploadFile = File(...), ip: str = Depends(ip_limit["upload"]), ): file_size = await validate_file_size(file, settings.uploadSize) if expire_style not in settings.expireStyle: raise HTTPException(status_code=400, detail="过期时间类型错误") expired_at, expired_count, used_count, code = await get_expire_info( expire_value, expire_style ) path, suffix, prefix, uuid_file_name, save_path = await get_file_path_name(file) file_storage: FileStorageInterface = storages[settings.file_storage]() await file_storage.save_file(file, save_path) await create_file_code( code=code, prefix=prefix, suffix=suffix, uuid_file_name=uuid_file_name, file_path=path, size=file_size, expired_at=expired_at, expired_count=expired_count, used_count=used_count, ) ip_limit["upload"].add_ip(ip) return APIResponse(detail={"code": code, "name": file.filename}) async def get_code_file_by_code( code: str, check: bool = True ) -> Tuple[bool, Union[FileCodes, str]]: file_code = await FileCodes.filter(code=code).first() if not file_code: return False, "文件不存在" if await file_code.is_expired() and check: return False, "文件已过期" return True, file_code async def update_file_usage(file_code: FileCodes) -> None: file_code.used_count += 1 if file_code.expired_count > 0: file_code.expired_count -= 1 await file_code.save() @share_api.get("/select/") async def get_code_file(code: str, ip: str = Depends(ip_limit["error"])): file_storage: FileStorageInterface = storages[settings.file_storage]() has, file_code = await get_code_file_by_code(code) if not has: ip_limit["error"].add_ip(ip) return APIResponse(code=404, detail=file_code) assert isinstance(file_code, FileCodes) await update_file_usage(file_code) return await file_storage.get_file_response(file_code) @share_api.post("/select/") async def select_file(data: SelectFileModel, ip: str = Depends(ip_limit["error"])): file_storage: FileStorageInterface = storages[settings.file_storage]() has, file_code = await get_code_file_by_code(data.code) if not has: ip_limit["error"].add_ip(ip) return APIResponse(code=404, detail=file_code) assert isinstance(file_code, FileCodes) await update_file_usage(file_code) return APIResponse( detail={ "code": file_code.code, "name": file_code.prefix + file_code.suffix, "size": file_code.size, "text": ( file_code.text if file_code.text is not None else await file_storage.get_file_url(file_code) ), } ) @share_api.get("/download") async def download_file(key: str, code: str, ip: str = Depends(ip_limit["error"])): file_storage: FileStorageInterface = storages[settings.file_storage]() if await get_select_token(code) != key: ip_limit["error"].add_ip(ip) raise HTTPException(status_code=403, detail="下载鉴权失败") has, file_code = await get_code_file_by_code(code, False) if not has: return APIResponse(code=404, detail="文件不存在") assert isinstance(file_code, FileCodes) return ( APIResponse(detail=file_code.text) if file_code.text else await file_storage.get_file_response(file_code) ) chunk_api = APIRouter(prefix="/chunk", tags=["切片"]) @chunk_api.post("/upload/init/", dependencies=[Depends(share_required_login)]) async def init_chunk_upload(data: InitChunkUploadModel): # 服务端校验:根据 total_chunks * chunk_size 计算理论最大上传量 total_chunks = (data.file_size + data.chunk_size - 1) // data.chunk_size max_possible_size = total_chunks * data.chunk_size if max_possible_size > settings.uploadSize: max_size_mb = settings.uploadSize / (1024 * 1024) raise HTTPException( status_code=403, detail=f"文件大小超过限制,最大为 {max_size_mb:.2f} MB" ) # # 秒传检查 # existing = await FileCodes.filter(file_hash=data.file_hash).first() # if existing: # if await existing.is_expired(): # file_storage: FileStorageInterface = storages[settings.file_storage]( # ) # await file_storage.delete_file(existing) # await existing.delete() # else: # return APIResponse(detail={ # "code": existing.code, # "existed": True, # "name": f'{existing.prefix}{existing.suffix}' # }) # 断点续传:检查是否存在相同文件的未完成上传会话 existing_session = await UploadChunk.filter( chunk_hash=data.file_hash, chunk_index=-1, file_size=data.file_size, file_name=data.file_name, ).first() if existing_session: if not existing_session.save_path: await UploadChunk.filter(upload_id=existing_session.upload_id).delete() else: uploaded_chunks = await UploadChunk.filter( upload_id=existing_session.upload_id, completed=True ).values_list("chunk_index", flat=True) return APIResponse( detail={ "existed": False, "upload_id": existing_session.upload_id, "chunk_size": existing_session.chunk_size, "total_chunks": existing_session.total_chunks, "uploaded_chunks": list(uploaded_chunks), } ) # 创建新的上传会话 upload_id = uuid.uuid4().hex _, _, _, _, save_path = await get_chunk_file_path_name(data.file_name, upload_id) await UploadChunk.create( upload_id=upload_id, chunk_index=-1, total_chunks=total_chunks, file_size=data.file_size, chunk_size=data.chunk_size, chunk_hash=data.file_hash, file_name=data.file_name, save_path=save_path, ) return APIResponse( detail={ "existed": False, "upload_id": upload_id, "chunk_size": data.chunk_size, "total_chunks": total_chunks, "uploaded_chunks": [], } ) @chunk_api.post( "/upload/chunk/{upload_id}/{chunk_index}", dependencies=[Depends(share_required_login)], ) async def upload_chunk( upload_id: str, chunk_index: int, chunk: UploadFile = File(...), ): # 获取上传会话信息 chunk_info = await UploadChunk.filter(upload_id=upload_id, chunk_index=-1).first() if not chunk_info: raise HTTPException(status.HTTP_404_NOT_FOUND, detail="上传会话不存在") # 检查分片索引有效性 if chunk_index < 0 or chunk_index >= chunk_info.total_chunks: raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="无效的分片索引") # 检查是否已上传(支持断点续传) existing_chunk = await UploadChunk.filter( upload_id=upload_id, chunk_index=chunk_index, completed=True ).first() if existing_chunk: return APIResponse( detail={"chunk_hash": existing_chunk.chunk_hash, "skipped": True} ) # 读取分片数据并计算哈希 chunk_data = await chunk.read() chunk_size = len(chunk_data) # 校验分片大小不超过声明的 chunk_size if chunk_size > chunk_info.chunk_size: raise HTTPException( status.HTTP_400_BAD_REQUEST, detail=f"分片大小超过声明值: 最大 {chunk_info.chunk_size}, 实际 {chunk_size}", ) # 计算已上传分片数,校验累计大小不超限(用分片数 * chunk_size 估算) uploaded_count = await UploadChunk.filter( upload_id=upload_id, completed=True ).count() # 已上传分片的最大可能大小 + 当前分片 max_uploaded_size = uploaded_count * chunk_info.chunk_size + chunk_size if max_uploaded_size > settings.uploadSize: max_size_mb = settings.uploadSize / (1024 * 1024) raise HTTPException( status_code=403, detail=f"累计上传大小超过限制,最大为 {max_size_mb:.2f} MB" ) chunk_hash = hashlib.sha256(chunk_data).hexdigest() save_path = chunk_info.save_path # 保存分片到存储 storage = storages[settings.file_storage]() try: await storage.save_chunk( upload_id, chunk_index, chunk_data, chunk_hash, save_path ) except Exception as e: raise HTTPException( status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"分片保存失败: {str(e)}" ) # 更新或创建分片记录(保存成功后再记录) await UploadChunk.update_or_create( upload_id=upload_id, chunk_index=chunk_index, defaults={ "chunk_hash": chunk_hash, "completed": True, "file_size": chunk_info.file_size, "total_chunks": chunk_info.total_chunks, "chunk_size": chunk_info.chunk_size, "file_name": chunk_info.file_name, "save_path": chunk_info.save_path, }, ) return APIResponse(detail={"chunk_hash": chunk_hash}) @chunk_api.delete("/upload/{upload_id}", dependencies=[Depends(share_required_login)]) async def cancel_upload(upload_id: str): """取消上传并清理临时文件""" chunk_info = await UploadChunk.filter(upload_id=upload_id, chunk_index=-1).first() if not chunk_info: raise HTTPException(status.HTTP_404_NOT_FOUND, detail="上传会话不存在") save_path = chunk_info.save_path # 清理存储中的临时文件 storage = storages[settings.file_storage]() if save_path: try: await storage.clean_chunks(upload_id, save_path) except Exception as e: pass # 清理数据库记录 await UploadChunk.filter(upload_id=upload_id).delete() return APIResponse(detail={"message": "上传已取消"}) @chunk_api.get( "/upload/status/{upload_id}", dependencies=[Depends(share_required_login)] ) async def get_upload_status(upload_id: str): """获取上传状态""" chunk_info = await UploadChunk.filter(upload_id=upload_id, chunk_index=-1).first() if not chunk_info: raise HTTPException(status.HTTP_404_NOT_FOUND, detail="上传会话不存在") # 获取已上传的分片列表 uploaded_chunks = await UploadChunk.filter( upload_id=upload_id, completed=True ).values_list("chunk_index", flat=True) return APIResponse( detail={ "upload_id": upload_id, "file_name": chunk_info.file_name, "file_size": chunk_info.file_size, "chunk_size": chunk_info.chunk_size, "total_chunks": chunk_info.total_chunks, "uploaded_chunks": list(uploaded_chunks), "progress": len(uploaded_chunks) / chunk_info.total_chunks * 100, } ) @chunk_api.post( "/upload/complete/{upload_id}", dependencies=[Depends(share_required_login)] ) async def complete_upload( upload_id: str, data: CompleteUploadModel, ip: str = Depends(ip_limit["upload"]) ): # 获取上传基本信息 chunk_info = await UploadChunk.filter(upload_id=upload_id, chunk_index=-1).first() if not chunk_info: raise HTTPException(status.HTTP_404_NOT_FOUND, detail="上传会话不存在") storage = storages[settings.file_storage]() # 验证所有分片 completed_chunks_list = await UploadChunk.filter( upload_id=upload_id, completed=True ).all() if len(completed_chunks_list) != chunk_info.total_chunks: raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="分片不完整") # 用分片数 * chunk_size 校验最大可能大小 max_total_size = len(completed_chunks_list) * chunk_info.chunk_size if max_total_size > settings.uploadSize: save_path = chunk_info.save_path if save_path: try: await storage.clean_chunks(upload_id, save_path) except Exception: pass await UploadChunk.filter(upload_id=upload_id).delete() max_size_mb = settings.uploadSize / (1024 * 1024) raise HTTPException( status_code=403, detail=f"实际上传大小超过限制,最大为 {max_size_mb:.2f} MB" ) save_path = chunk_info.save_path path = os.path.dirname(save_path) if save_path else "" prefix, suffix = os.path.splitext(chunk_info.file_name) try: # 合并文件并计算哈希 _, file_hash = await storage.merge_chunks(upload_id, chunk_info, save_path) # 创建文件记录 expired_at, expired_count, used_count, code = await get_expire_info( data.expire_value, data.expire_style ) await FileCodes.create( code=code, file_hash=file_hash, # 使用合并后计算的哈希 is_chunked=True, upload_id=upload_id, size=chunk_info.file_size, expired_at=expired_at, expired_count=expired_count, used_count=used_count, file_path=path, uuid_file_name=f"{prefix}{suffix}", prefix=prefix, suffix=suffix, ) # 清理临时文件 await storage.clean_chunks(upload_id, save_path) # 清理数据库中的分片记录 await UploadChunk.filter(upload_id=upload_id).delete() ip_limit["upload"].add_ip(ip) return APIResponse(detail={"code": code, "name": chunk_info.file_name}) except ValueError as e: raise HTTPException(status.HTTP_400_BAD_REQUEST, detail=str(e)) except Exception as e: # 合并失败时清理临时文件 try: await storage.clean_chunks(upload_id, save_path) except Exception: pass raise HTTPException( status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"文件合并失败: {str(e)}" ) # ============ 预签名上传API ============ presign_api = APIRouter(prefix="/presign", tags=["预签名上传"]) PRESIGN_SESSION_EXPIRES = 900 # 15分钟 async def _get_valid_session( upload_id: str, expected_mode: Optional[str] = None ) -> PresignUploadSession: """获取并验证会话""" session = await PresignUploadSession.filter(upload_id=upload_id).first() if not session: raise HTTPException(404, "上传会话不存在") if await session.is_expired(): await session.delete() raise HTTPException(404, "上传会话已过期") if expected_mode and session.mode != expected_mode: raise HTTPException(400, f"此会话不支持{expected_mode}模式") return session @presign_api.post("/upload/init", dependencies=[Depends(share_required_login)]) async def presign_upload_init( data: PresignUploadInitRequest, ip: str = Depends(ip_limit["upload"]) ): """初始化预签名上传,S3返回直传URL,其他存储返回代理URL""" if data.file_size > settings.uploadSize: raise HTTPException( 403, f"文件大小超过限制,最大为 {settings.uploadSize / (1024 * 1024):.2f} MB", ) if data.expire_style not in settings.expireStyle: raise HTTPException(400, "过期时间类型错误") upload_id = uuid.uuid4().hex path, _, _, filename, save_path = await FileUploadService.generate_file_path( data.file_name, upload_id ) storage: FileStorageInterface = storages[settings.file_storage]() presigned_url = await storage.generate_presigned_upload_url( save_path, PRESIGN_SESSION_EXPIRES ) mode = "direct" if presigned_url else "proxy" upload_url = presigned_url or f"/api/presign/upload/proxy/{upload_id}" await PresignUploadSession.create( upload_id=upload_id, file_name=filename, file_size=data.file_size, save_path=save_path, mode=mode, expire_value=data.expire_value, expire_style=data.expire_style, expires_at=await get_now() + timedelta(seconds=PRESIGN_SESSION_EXPIRES), ) ip_limit["upload"].add_ip(ip) return APIResponse( detail={ "upload_id": upload_id, "upload_url": upload_url, "mode": mode, "expires_in": PRESIGN_SESSION_EXPIRES, } ) @presign_api.put( "/upload/proxy/{upload_id}", dependencies=[Depends(share_required_login)] ) async def presign_upload_proxy( upload_id: str, file: UploadFile = File(...), ip: str = Depends(ip_limit["upload"]) ): """代理模式上传,服务器转存到存储后端""" session = await _get_valid_session(upload_id, expected_mode="proxy") file_size = await validate_file_size(file, settings.uploadSize) if abs(file_size - session.file_size) > 1024: raise HTTPException(400, "文件大小与声明不符") storage: FileStorageInterface = storages[settings.file_storage]() try: await storage.save_file(file, session.save_path) except Exception as e: raise HTTPException(500, f"文件保存失败: {str(e)}") code = await FileUploadService.create_file_record( session.file_name, file_size, os.path.dirname(session.save_path), session.expire_value, session.expire_style, ) await session.delete() ip_limit["upload"].add_ip(ip) return APIResponse(detail={"code": code, "name": session.file_name}) @presign_api.post( "/upload/confirm/{upload_id}", dependencies=[Depends(share_required_login)] ) async def presign_upload_confirm(upload_id: str, ip: str = Depends(ip_limit["upload"])): """直传确认,客户端完成S3直传后调用获取分享码""" session = await _get_valid_session(upload_id, expected_mode="direct") storage: FileStorageInterface = storages[settings.file_storage]() if not await storage.file_exists(session.save_path): raise HTTPException(404, "文件未上传或上传失败") code = await FileUploadService.create_file_record( session.file_name, session.file_size, os.path.dirname(session.save_path), session.expire_value, session.expire_style, ) await session.delete() ip_limit["upload"].add_ip(ip) return APIResponse(detail={"code": code, "name": session.file_name}) @presign_api.get( "/upload/status/{upload_id}", dependencies=[Depends(share_required_login)] ) async def presign_upload_status(upload_id: str): """查询上传会话状态""" session = await PresignUploadSession.filter(upload_id=upload_id).first() if not session: raise HTTPException(404, "上传会话不存在") return APIResponse( detail={ "upload_id": session.upload_id, "file_name": session.file_name, "file_size": session.file_size, "mode": session.mode, "created_at": session.created_at.isoformat(), "expires_at": session.expires_at.isoformat(), "is_expired": await session.is_expired(), } ) @presign_api.delete("/upload/{upload_id}", dependencies=[Depends(share_required_login)]) async def presign_upload_cancel(upload_id: str): """取消上传会话""" session = await PresignUploadSession.filter(upload_id=upload_id).first() if not session: raise HTTPException(404, "上传会话不存在") if session.mode == "direct": storage: FileStorageInterface = storages[settings.file_storage]() try: if await storage.file_exists(session.save_path): temp_file_code = FileCodes( file_path=os.path.dirname(session.save_path), uuid_file_name=os.path.basename(session.save_path), ) await storage.delete_file(temp_file_code) except Exception: pass await session.delete() return APIResponse(detail={"message": "上传会话已取消"}) ================================================ FILE: core/__init__.py ================================================ # @Time : 2023/8/11 20:06 # @Author : Lan # @File : __init__.py.py # @Software: PyCharm ================================================ FILE: core/config.py ================================================ from apps.base.models import KeyValue from apps.base.utils import ip_limit from core.settings import DEFAULT_CONFIG, settings async def ensure_settings_row() -> None: await KeyValue.get_or_create(key="settings", defaults={"value": DEFAULT_CONFIG}) def _sync_ip_limits() -> None: ip_limit["error"].minutes = settings.errorMinute ip_limit["error"].count = settings.errorCount ip_limit["upload"].minutes = settings.uploadMinute ip_limit["upload"].count = settings.uploadCount async def refresh_settings() -> None: """从数据库读取最新配置并应用到运行时。""" config_record = await KeyValue.filter(key="settings").first() settings.user_config = config_record.value if config_record and config_record.value else {} _sync_ip_limits() ================================================ FILE: core/database.py ================================================ import asyncio import glob import importlib import os from contextlib import asynccontextmanager from typing import IO from tortoise import Tortoise from core.logger import logger from core.settings import data_root _DB_FILE = os.path.join(data_root, "filecodebox.db") _STARTUP_LOCK_FILE = os.path.join(data_root, "filecodebox.startup.lock") def get_db_config() -> dict: return { "connections": { "default": { "engine": "tortoise.backends.sqlite", "credentials": { "file_path": _DB_FILE, "journal_mode": "WAL", "busy_timeout": 10000, "foreign_keys": "ON", }, } }, "apps": { "models": { "models": ["apps.base.models"], "default_connection": "default", } }, "use_tz": False, "timezone": "Asia/Shanghai", } def _lock_file(file_obj: IO[str]) -> None: if os.name == "nt": import msvcrt # Windows 需要锁定至少 1 字节 if os.fstat(file_obj.fileno()).st_size == 0: file_obj.write("0") file_obj.flush() msvcrt.locking(file_obj.fileno(), msvcrt.LK_LOCK, 1) else: import fcntl fcntl.flock(file_obj.fileno(), fcntl.LOCK_EX) def _unlock_file(file_obj: IO[str]) -> None: if os.name == "nt": import msvcrt msvcrt.locking(file_obj.fileno(), msvcrt.LK_UNLCK, 1) else: import fcntl fcntl.flock(file_obj.fileno(), fcntl.LOCK_UN) @asynccontextmanager async def db_startup_lock(): os.makedirs(data_root, exist_ok=True) lock_file = open(_STARTUP_LOCK_FILE, "a+", encoding="utf-8") try: await asyncio.to_thread(_lock_file, lock_file) yield finally: await asyncio.to_thread(_unlock_file, lock_file) lock_file.close() async def init_db(): try: db_config = get_db_config() if not Tortoise._inited: await Tortoise.init(config=db_config) async with db_startup_lock(): # 创建migrations表 await Tortoise.get_connection("default").execute_script(""" CREATE TABLE IF NOT EXISTS migrates ( id INTEGER PRIMARY KEY AUTOINCREMENT, migration_file VARCHAR(255) NOT NULL UNIQUE, executed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ) """) # 执行迁移 await execute_migrations() except Exception as e: logger.error(f"数据库初始化失败: {str(e)}") raise async def execute_migrations(): """执行数据库迁移""" try: # 收集迁移文件 migration_files = [] for root, dirs, files in os.walk("apps"): if "migrations" in dirs: migration_path = os.path.join(root, "migrations") migration_files.extend(glob.glob(os.path.join(migration_path, "migrations_*.py"))) # 按文件名排序 migration_files.sort() for migration_file in migration_files: file_name = os.path.basename(migration_file) # 检查是否已执行 executed = await Tortoise.get_connection("default").execute_query( "SELECT id FROM migrates WHERE migration_file = ?", [file_name] ) if not executed[1]: logger.info(f"执行迁移: {file_name}") # 导入并执行migration module_path = migration_file.replace("/", ".").replace("\\", ".").replace(".py", "") try: migration_module = importlib.import_module(module_path) if hasattr(migration_module, "migrate"): await migration_module.migrate() # 记录执行 await Tortoise.get_connection("default").execute_query( "INSERT INTO migrates (migration_file) VALUES (?)", [file_name] ) logger.info(f"迁移完成: {file_name}") except Exception as e: logger.error(f"迁移 {file_name} 执行失败: {str(e)}") raise except Exception as e: logger.error(f"迁移过程发生错误: {str(e)}") raise ================================================ FILE: core/logger.py ================================================ import logging import sys def setup_logger(): # 创建logger对象 _logger = logging.getLogger('FileCodeBox') _logger.setLevel(logging.INFO) # 创建控制台处理器 console_handler = logging.StreamHandler(sys.stdout) console_handler.setLevel(logging.INFO) # 设置日志格式 formatter = logging.Formatter( '%(asctime)s - %(name)s - %(levelname)s - %(message)s', datefmt='%Y-%m-%d %H:%M:%S' ) console_handler.setFormatter(formatter) # 添加处理器到logger _logger.addHandler(console_handler) return _logger # 创建全局logger实例 logger = setup_logger() ================================================ FILE: core/response.py ================================================ # @Time : 2023/8/14 11:48 # @Author : Lan # @File : response.py # @Software: PyCharm from typing import Generic, Optional, TypeVar from pydantic import BaseModel T = TypeVar("T") class APIResponse(BaseModel, Generic[T]): code: int = 200 message: str = "ok" detail: Optional[T] = None ================================================ FILE: core/settings.py ================================================ # @Time : 2023/8/15 09:51 # @Author : Lan # @File : settings.py # @Software: PyCharm from pathlib import Path BASE_DIR = Path(__file__).resolve().parent.parent data_root = BASE_DIR / "data" if not data_root.exists(): data_root.mkdir(parents=True, exist_ok=True) DEFAULT_CONFIG = { "file_storage": "local", "storage_path": "", "name": "文件快递柜 - FileCodeBox", "description": "开箱即用的文件快传系统", "notify_title": "系统通知", "notify_content": '欢迎使用 FileCodeBox,本程序开源于 Github ,欢迎Star和Fork。', "page_explain": "请勿上传或分享违法内容。根据《中华人民共和国网络安全法》、《中华人民共和国刑法》、《中华人民共和国治安管理处罚法》等相关规定。 传播或存储违法、违规内容,会受到相关处罚,严重者将承担刑事责任。本站坚决配合相关部门,确保网络内容的安全,和谐,打造绿色网络环境。", "keywords": "FileCodeBox, 文件快递柜, 口令传送箱, 匿名口令分享文本, 文件", "s3_access_key_id": "", "s3_secret_access_key": "", "s3_bucket_name": "", "s3_endpoint_url": "", "s3_region_name": "auto", "s3_signature_version": "s3v2", "s3_hostname": "", "s3_proxy": 0, "max_save_seconds": 0, "aws_session_token": "", "onedrive_domain": "", "onedrive_client_id": "", "onedrive_username": "", "onedrive_password": "", "onedrive_root_path": "filebox_storage", "onedrive_proxy": 0, "webdav_root_path": "filebox_storage", "webdav_proxy": 0, "admin_token": "FileCodeBox2023", "openUpload": 1, "uploadSize": 1024 * 1024 * 10, "expireStyle": ["day", "hour", "minute", "forever", "count"], "uploadMinute": 1, "enableChunk": 0, "webdav_url": "", "webdav_password": "", "webdav_username": "", "opacity": 0.9, "background": "", "uploadCount": 10, "themesChoices": [ { "name": "2023", "key": "themes/2023", "author": "Lan", "version": "1.0", }, { "name": "2024", "key": "themes/2024", "author": "Lan", "version": "1.0", }, ], "themesSelect": "themes/2024", "errorMinute": 1, "errorCount": 10, "serverWorkers": 1, "serverHost": "0.0.0.0", "serverPort": 12345, "showAdminAddr": 0, "robotsText": "User-agent: *\nDisallow: /", } class Settings: def __init__(self, defaults=None): self.default_config = defaults or {} self.user_config = {} def __getattr__(self, attr): if attr in self.user_config: return self.user_config[attr] if attr in self.default_config: return self.default_config[attr] raise AttributeError( f"'{self.__class__.__name__}' object has no attribute '{attr}'" ) def __setattr__(self, key, value): if key in ["default_config", "user_config"]: super().__setattr__(key, value) else: self.user_config[key] = value def items(self): return {**self.default_config, **self.user_config}.items() settings = Settings(DEFAULT_CONFIG) ================================================ FILE: core/storage.py ================================================ # @Time : 2023/8/11 20:06 # @Author : Lan # @File : storage.py # @Software: PyCharm import base64 import hashlib import os import tempfile from core.logger import logger import shutil from typing import Optional from urllib.parse import quote, unquote import aiofiles import aiohttp import asyncio from pathlib import Path import datetime import re import aioboto3 from botocore.config import Config from fastapi import HTTPException, Response, UploadFile from core.response import APIResponse from core.settings import data_root, settings from apps.base.models import FileCodes, UploadChunk from core.utils import get_file_url, sanitize_filename from fastapi.responses import FileResponse, StreamingResponse class FileStorageInterface: async def save_file(self, file: UploadFile, save_path: str): """ 保存文件 """ raise NotImplementedError async def delete_file(self, file_code: FileCodes): """ 删除文件 """ raise NotImplementedError async def get_file_url(self, file_code: FileCodes): """ 获取文件分享的url 如果服务不支持直接访问文件,可以通过服务器中转下载。 此时,此方法可以调用 utils.py 中的 `get_file_url` 方法,获取服务器中转下载的url """ raise NotImplementedError async def get_file_response(self, file_code: FileCodes): """ 获取文件响应 如果服务不支持直接访问文件,则需要实现该方法,返回文件响应 其余情况,可以不实现该方法 """ raise NotImplementedError async def save_chunk(self, upload_id: str, chunk_index: int, chunk_data: bytes, chunk_hash: str, save_path: str): """ 保存分片文件 :param upload_id: 上传会话ID :param chunk_index: 分片索引 :param chunk_data: 分片数据 :param chunk_hash: 分片哈希值 :param save_path: 文件保存路径 """ raise NotImplementedError async def merge_chunks(self, upload_id: str, chunk_info: UploadChunk, save_path: str) -> tuple[str, str]: """ 合并分片文件并返回文件路径和完整哈希值 :param upload_id: 上传会话ID :param chunk_info: 分片信息 :param save_path: 文件保存路径 :return: (文件路径, 文件哈希值) """ raise NotImplementedError async def generate_presigned_upload_url(self, save_path: str, expires_in: int = 900) -> Optional[str]: """ 生成预签名上传URL :param save_path: 文件保存路径 :param expires_in: URL过期时间(秒),默认15分钟 :return: 预签名URL,如果不支持直传则返回None """ return None # 默认不支持直传,使用代理模式 async def file_exists(self, save_path: str) -> bool: """ 检查文件是否存在 :param save_path: 文件路径 :return: 文件是否存在 """ raise NotImplementedError async def clean_chunks(self, upload_id: str, save_path: str): """ 清理临时分片文件 :param upload_id: 上传会话ID :param save_path: 文件保存路径 """ raise NotImplementedError class SystemFileStorage(FileStorageInterface): def __init__(self): self.chunk_size = 256 * 1024 self.root_path = data_root def _save(self, file, save_path): with open(save_path, "wb") as f: chunk = file.read(self.chunk_size) while chunk: f.write(chunk) chunk = file.read(self.chunk_size) async def save_file(self, file: UploadFile, save_path: str): path_obj = Path(save_path) directory = str(path_obj.parent) # 提取原始文件名并进行清理 filename = await sanitize_filename(path_obj.name) # 构建安全的完整保存路径 safe_save_path = self.root_path / directory / filename # 确保目录存在 if not safe_save_path.parent.exists(): safe_save_path.parent.mkdir(parents=True) await asyncio.to_thread(self._save, file.file, safe_save_path) async def delete_file(self, file_code: FileCodes): save_path = self.root_path / await file_code.get_file_path() if save_path.exists(): save_path.unlink() async def get_file_url(self, file_code: FileCodes): return await get_file_url(file_code.code) async def get_file_response(self, file_code: FileCodes): file_path = self.root_path / await file_code.get_file_path() if not file_path.exists(): return APIResponse(code=404, detail="文件已过期删除") filename = f"{file_code.prefix}{file_code.suffix}" encoded_filename = quote(filename, safe='') content_disposition = f"attachment; filename*=UTF-8''{encoded_filename}" # 尝试获取文件系统大小,如果成功则设置 Content-Length headers = {"Content-Disposition": content_disposition} try: content_length = file_path.stat().st_size headers["Content-Length"] = str(content_length) except Exception: # 如果获取文件大小失败,则不提供 Content-Length pass return FileResponse( file_path, media_type="application/octet-stream", headers=headers, filename=filename # 保留原始文件名以备某些场景使用 ) async def save_chunk(self, upload_id: str, chunk_index: int, chunk_data: bytes, chunk_hash: str, save_path: str): """ 保存分片文件到本地文件系统 :param upload_id: 上传会话ID :param chunk_index: 分片索引 :param chunk_data: 分片数据 :param chunk_hash: 分片哈希值 :param save_path: 文件保存路径 """ chunk_dir = self.root_path / save_path chunk_path = chunk_dir.parent / 'chunks' / upload_id / f"{chunk_index}.part" if not chunk_path.parent.exists(): chunk_path.parent.mkdir(parents=True, exist_ok=True) # 使用临时文件写入,确保原子性 temp_path = chunk_path.with_suffix('.tmp') try: async with aiofiles.open(temp_path, "wb") as f: await f.write(chunk_data) # 原子重命名 temp_path.rename(chunk_path) except Exception as e: if temp_path.exists(): temp_path.unlink() raise e async def merge_chunks(self, upload_id: str, chunk_info: UploadChunk, save_path: str) -> tuple[str, str]: """ 合并本地文件系统的分片文件并返回文件路径和完整哈希值 :param upload_id: 上传会话ID :param chunk_info: 分片信息 :param save_path: 文件保存路径 :return: (文件路径, 文件哈希值) """ output_path = self.root_path / save_path output_path.parent.mkdir(parents=True, exist_ok=True) chunk_base_dir = output_path.parent / 'chunks' / upload_id file_sha256 = hashlib.sha256() # 使用临时文件写入,确保原子性 temp_output = output_path.with_suffix('.merging') try: async with aiofiles.open(temp_output, "wb") as out_file: for i in range(chunk_info.total_chunks): # 获取分片记录 chunk_record = await UploadChunk.filter(upload_id=upload_id, chunk_index=i).first() if not chunk_record: raise ValueError(f"分片{i}记录不存在") chunk_path = chunk_base_dir / f"{i}.part" if not chunk_path.exists(): raise ValueError(f"分片{i}文件不存在") async with aiofiles.open(chunk_path, "rb") as in_file: chunk_data = await in_file.read() current_hash = hashlib.sha256(chunk_data).hexdigest() if current_hash != chunk_record.chunk_hash: raise ValueError(f"分片{i}哈希不匹配: 期望 {chunk_record.chunk_hash}, 实际 {current_hash}") file_sha256.update(chunk_data) await out_file.write(chunk_data) # 原子重命名 temp_output.rename(output_path) except Exception as e: if temp_output.exists(): temp_output.unlink() raise e return str(output_path), file_sha256.hexdigest() async def clean_chunks(self, upload_id: str, save_path: str): """ 清理本地文件系统的临时分片文件 :param upload_id: 上传会话ID :param save_path: 文件保存路径 """ chunk_dir = (self.root_path / save_path).parent / 'chunks' / upload_id if chunk_dir.exists(): try: shutil.rmtree(chunk_dir) except Exception as e: logger.info(f"清理本地分片目录失败: {e}") # 清理父级 chunks 目录(如果为空) chunks_parent = chunk_dir.parent if chunks_parent.exists() and not any(chunks_parent.iterdir()): try: chunks_parent.rmdir() except Exception as e: logger.info(f"清理 chunks 父目录失败: {e}") async def file_exists(self, save_path: str) -> bool: """ 检查文件是否存在于本地文件系统 :param save_path: 文件路径 :return: 文件是否存在 """ file_path = self.root_path / save_path return file_path.exists() class S3FileStorage(FileStorageInterface): def __init__(self): self.access_key_id = settings.s3_access_key_id self.secret_access_key = settings.s3_secret_access_key self.bucket_name = settings.s3_bucket_name self.s3_hostname = settings.s3_hostname self.region_name = settings.s3_region_name self.signature_version = settings.s3_signature_version self.endpoint_url = settings.s3_endpoint_url or f"https://{self.s3_hostname}" self.aws_session_token = settings.aws_session_token self.proxy = settings.s3_proxy self.session = aioboto3.Session( aws_access_key_id=self.access_key_id, aws_secret_access_key=self.secret_access_key, ) if not settings.s3_endpoint_url: self.endpoint_url = f"https://{self.s3_hostname}" else: # 如果提供了 s3_endpoint_url,则优先使用它 self.endpoint_url = settings.s3_endpoint_url async def save_file(self, file: UploadFile, save_path: str): async with self.session.client( "s3", endpoint_url=self.endpoint_url, aws_session_token=self.aws_session_token, region_name=self.region_name, config=Config(signature_version=self.signature_version), ) as s3: # 使用 upload_fileobj 流式上传,避免将整个文件加载到内存 await s3.upload_fileobj( file.file, self.bucket_name, save_path, ExtraArgs={"ContentType": file.content_type or "application/octet-stream"}, ) async def delete_file(self, file_code: FileCodes): async with self.session.client( "s3", endpoint_url=self.endpoint_url, region_name=self.region_name, config=Config(signature_version=self.signature_version), ) as s3: await s3.delete_object( Bucket=self.bucket_name, Key=await file_code.get_file_path() ) async def get_file_response(self, file_code: FileCodes): try: filename = file_code.prefix + file_code.suffix content_length = None # 初始化为 None,表示未知大小 async with self.session.client( "s3", endpoint_url=self.endpoint_url, region_name=self.region_name, config=Config(signature_version=self.signature_version), ) as s3: # 尝试获取文件大小(HEAD请求) try: head_response = await s3.head_object( Bucket=self.bucket_name, Key=await file_code.get_file_path() ) # 从HEAD响应中获取Content-Length if 'ContentLength' in head_response: content_length = head_response['ContentLength'] elif 'Content-Length' in head_response['ResponseMetadata']['HTTPHeaders']: content_length = int(head_response['ResponseMetadata']['HTTPHeaders']['Content-Length']) except Exception: # 如果HEAD请求失败,则不提供 Content-Length pass link = await s3.generate_presigned_url( "get_object", Params={ "Bucket": self.bucket_name, "Key": await file_code.get_file_path(), }, ExpiresIn=3600, ) # 创建ClientSession并传递给生成器复用 session = aiohttp.ClientSession() async def stream_generator(): try: async with session.get(link) as resp: if resp.status != 200: raise HTTPException( status_code=resp.status, detail=f"从S3获取文件失败: {resp.status}" ) # 设置块大小(例如64KB) chunk_size = 65536 while True: chunk = await resp.content.read(chunk_size) if not chunk: break yield chunk finally: await session.close() from fastapi.responses import StreamingResponse encoded_filename = quote(filename, safe='') headers = { "Content-Disposition": f"attachment; filename*=UTF-8''{encoded_filename}" } if content_length is not None: headers["Content-Length"] = str(content_length) return StreamingResponse( stream_generator(), media_type="application/octet-stream", headers=headers ) except HTTPException: raise except Exception: raise HTTPException(status_code=503, detail="服务代理下载异常,请稍后再试") async def get_file_url(self, file_code: FileCodes): if file_code.prefix == "文本分享": return file_code.text if self.proxy: return await get_file_url(file_code.code) else: async with self.session.client( "s3", endpoint_url=self.endpoint_url, region_name=self.region_name, config=Config(signature_version=self.signature_version), ) as s3: result = await s3.generate_presigned_url( "get_object", Params={ "Bucket": self.bucket_name, "Key": await file_code.get_file_path(), }, ExpiresIn=3600, ) return result async def save_chunk(self, upload_id: str, chunk_index: int, chunk_data: bytes, chunk_hash: str, save_path: str): """ 保存分片到 S3(使用独立对象存储每个分片) 注意:这里不使用 S3 原生的 multipart upload,而是将每个分片作为独立对象存储 """ chunk_key = str(Path(save_path).parent / "chunks" / upload_id / f"{chunk_index}.part") async with self.session.client( 's3', endpoint_url=self.endpoint_url, aws_session_token=self.aws_session_token, region_name=self.region_name, config=Config(signature_version=self.signature_version), ) as s3: # 将分片作为独立对象上传 await s3.put_object( Bucket=self.bucket_name, Key=chunk_key, Body=chunk_data, Metadata={ 'chunk-hash': chunk_hash, 'chunk-index': str(chunk_index) } ) async def merge_chunks(self, upload_id: str, chunk_info: UploadChunk, save_path: str) -> tuple[str, str]: """ 合并 S3 上的分片文件 使用 S3 的 multipart upload API 实现流式合并,避免内存问题 """ file_sha256 = hashlib.sha256() chunk_dir = str(Path(save_path).parent / "chunks" / upload_id) async with self.session.client( 's3', endpoint_url=self.endpoint_url, aws_session_token=self.aws_session_token, region_name=self.region_name, config=Config(signature_version=self.signature_version), ) as s3: # 创建 multipart upload mpu = await s3.create_multipart_upload( Bucket=self.bucket_name, Key=save_path, ContentType='application/octet-stream' ) mpu_id = mpu['UploadId'] parts = [] try: # 按顺序读取、验证并上传每个分片 for i in range(chunk_info.total_chunks): chunk_key = f"{chunk_dir}/{i}.part" chunk_record = await UploadChunk.filter(upload_id=upload_id, chunk_index=i).first() if not chunk_record: raise ValueError(f"分片{i}记录不存在") try: response = await s3.get_object( Bucket=self.bucket_name, Key=chunk_key ) chunk_data = await response['Body'].read() except Exception as e: raise ValueError(f"分片{i}文件不存在: {e}") current_hash = hashlib.sha256(chunk_data).hexdigest() if current_hash != chunk_record.chunk_hash: raise ValueError(f"分片{i}哈希不匹配: 期望 {chunk_record.chunk_hash}, 实际 {current_hash}") file_sha256.update(chunk_data) # 上传分片到 multipart upload part_response = await s3.upload_part( Bucket=self.bucket_name, Key=save_path, UploadId=mpu_id, PartNumber=i + 1, # S3 part numbers start at 1 Body=chunk_data ) parts.append({ 'PartNumber': i + 1, 'ETag': part_response['ETag'] }) # 释放内存 del chunk_data # 完成 multipart upload await s3.complete_multipart_upload( Bucket=self.bucket_name, Key=save_path, UploadId=mpu_id, MultipartUpload={'Parts': parts} ) except Exception as e: # 出错时取消 multipart upload await s3.abort_multipart_upload( Bucket=self.bucket_name, Key=save_path, UploadId=mpu_id ) raise e return save_path, file_sha256.hexdigest() async def clean_chunks(self, upload_id: str, save_path: str): """ 清理 S3 上的临时分片文件 :param upload_id: 上传会话ID :param save_path: 文件保存路径 """ chunk_dir = str(Path(save_path).parent / "chunks" / upload_id) async with self.session.client( 's3', endpoint_url=self.endpoint_url, aws_session_token=self.aws_session_token, region_name=self.region_name, config=Config(signature_version=self.signature_version), ) as s3: try: # 列出并删除所有分片对象 paginator = s3.get_paginator('list_objects_v2') async for page in paginator.paginate(Bucket=self.bucket_name, Prefix=chunk_dir): objects = page.get('Contents', []) if objects: delete_objects = [{'Key': obj['Key']} for obj in objects] await s3.delete_objects( Bucket=self.bucket_name, Delete={'Objects': delete_objects} ) except Exception as e: logger.info(f"清理 S3 分片数据时出错: {e}") async def generate_presigned_upload_url(self, save_path: str, expires_in: int = 900) -> Optional[str]: """ 生成S3预签名上传URL :param save_path: 文件保存路径 :param expires_in: URL过期时间(秒),默认15分钟 :return: 预签名PUT URL """ async with self.session.client( "s3", endpoint_url=self.endpoint_url, aws_session_token=self.aws_session_token, region_name=self.region_name, config=Config(signature_version=self.signature_version), ) as s3: return await s3.generate_presigned_url( "put_object", Params={ "Bucket": self.bucket_name, "Key": save_path, }, ExpiresIn=expires_in, ) async def file_exists(self, save_path: str) -> bool: """ 检查文件是否存在于S3 :param save_path: 文件路径 :return: 文件是否存在 """ async with self.session.client( "s3", endpoint_url=self.endpoint_url, aws_session_token=self.aws_session_token, region_name=self.region_name, config=Config(signature_version=self.signature_version), ) as s3: try: await s3.head_object(Bucket=self.bucket_name, Key=save_path) return True except Exception: return False class OneDriveFileStorage(FileStorageInterface): def __init__(self): try: import msal from office365.graph_client import GraphClient from office365.runtime.client_request_exception import ( ClientRequestException, ) except ImportError: raise ImportError("请先安装`msal`和`Office365-REST-Python-Client`") self.msal = msal self.domain = settings.onedrive_domain self.client_id = settings.onedrive_client_id self.username = settings.onedrive_username self.password = settings.onedrive_password self.proxy = settings.onedrive_proxy self._ClientRequestException = ClientRequestException try: client = GraphClient(self.acquire_token_pwd) self.root_path = ( client.me.drive.root.get_by_path(settings.onedrive_root_path) .get() .execute_query() ) except ClientRequestException as e: if e.code == "itemNotFound": client.me.drive.root.create_folder(settings.onedrive_root_path) self.root_path = ( client.me.drive.root.get_by_path( settings.onedrive_root_path) .get() .execute_query() ) else: raise e except Exception as e: raise Exception("OneDrive验证失败,请检查配置是否正确\n" + str(e)) def acquire_token_pwd(self): authority_url = f"https://login.microsoftonline.com/{self.domain}" app = self.msal.PublicClientApplication( authority=authority_url, client_id=self.client_id ) result = app.acquire_token_by_username_password( username=self.username, password=self.password, scopes=["https://graph.microsoft.com/.default"], ) return result def _get_path_str(self, path): if isinstance(path, str): path = path.replace("\\", "/").replace("//", "/").split("/") elif isinstance(path, Path): path = str(path).replace("\\", "/").replace("//", "/").split("/") else: raise TypeError("path must be str or Path") path[-1] = path[-1].split(".")[0] return "/".join(path) def _save(self, file, save_path): content = file.file.read() name = save_path(file.filename) path = self._get_path_str(save_path) self.root_path.get_by_path(path).upload(name, content).execute_query() async def save_file(self, file: UploadFile, save_path: str): await asyncio.to_thread(self._save, file, save_path) def _delete(self, save_path): path = self._get_path_str(save_path) try: self.root_path.get_by_path(path).delete_object().execute_query() except self._ClientRequestException as e: if e.code == "itemNotFound": pass else: raise e async def delete_file(self, file_code: FileCodes): await asyncio.to_thread(self._delete, await file_code.get_file_path()) def _convert_link_to_download_link(self, link): p1 = re.search(r"https://(.+)\.sharepoint\.com", link).group(1) p2 = re.search(r"personal/(.+)/", link).group(1) p3 = re.search(rf"{p2}/(.+)", link).group(1) return f"https://{p1}.sharepoint.com/personal/{p2}/_layouts/52/download.aspx?share={p3}" def _get_file_url(self, save_path, name): path = self._get_path_str(save_path) remote_file = self.root_path.get_by_path(path + "/" + name) expiration_datetime = datetime.datetime.now( tz=datetime.timezone.utc ) + datetime.timedelta(hours=1) expiration_datetime = expiration_datetime.strftime( "%Y-%m-%dT%H:%M:%SZ") permission = remote_file.create_link( "view", "anonymous", expiration_datetime=expiration_datetime ).execute_query() return self._convert_link_to_download_link(permission.link.webUrl) async def get_file_response(self, file_code: FileCodes): try: filename = file_code.prefix + file_code.suffix link = await asyncio.to_thread( self._get_file_url, await file_code.get_file_path(), filename ) content_length = None # 初始化为 None,表示未知大小 # 创建ClientSession并复用 session = aiohttp.ClientSession() # 尝试发送HEAD请求获取Content-Length try: async with session.head(link) as resp: if resp.status == 200 and 'Content-Length' in resp.headers: content_length = int(resp.headers['Content-Length']) except Exception: # 如果HEAD请求失败,则不提供 Content-Length pass async def stream_generator(): try: async with session.get(link) as resp: if resp.status != 200: raise HTTPException( status_code=resp.status, detail=f"从OneDrive获取文件失败: {resp.status}" ) chunk_size = 65536 while True: chunk = await resp.content.read(chunk_size) if not chunk: break yield chunk finally: await session.close() encoded_filename = quote(filename, safe='') headers = { "Content-Disposition": f"attachment; filename*=UTF-8''{encoded_filename}" } if content_length is not None: headers["Content-Length"] = str(content_length) return StreamingResponse( stream_generator(), media_type="application/octet-stream", headers=headers ) except HTTPException: raise except Exception: raise HTTPException(status_code=503, detail="服务代理下载异常,请稍后再试") async def get_file_url(self, file_code: FileCodes): if self.proxy: return await get_file_url(file_code.code) else: return await asyncio.to_thread( self._get_file_url, await file_code.get_file_path(), f"{file_code.prefix}{file_code.suffix}", ) def _save_chunk(self, chunk_path: str, chunk_data: bytes): """同步保存分片到 OneDrive""" path_parts = chunk_path.replace("\\", "/").split("/") filename = path_parts[-1] dir_path = "/".join(path_parts[:-1]) # 确保目录存在 current_folder = self.root_path for part in dir_path.split("/"): if part: try: current_folder = current_folder.get_by_path(part).get().execute_query() except self._ClientRequestException as e: if e.code == "itemNotFound": current_folder = current_folder.create_folder(part).execute_query() else: raise e # 上传分片 current_folder.upload(filename, chunk_data).execute_query() async def save_chunk(self, upload_id: str, chunk_index: int, chunk_data: bytes, chunk_hash: str, save_path: str): """保存分片到 OneDrive""" chunk_path = str(Path(save_path).parent / "chunks" / upload_id / f"{chunk_index}.part") await asyncio.to_thread(self._save_chunk, chunk_path, chunk_data) def _read_chunk(self, chunk_path: str) -> bytes: """同步读取分片""" path = self._get_path_str(chunk_path) file_obj = self.root_path.get_by_path(path).get().execute_query() return file_obj.get_content().execute_query().value def _upload_merged(self, save_path: str, data: bytes): """同步上传合并后的文件""" path_parts = save_path.replace("\\", "/").split("/") filename = path_parts[-1] dir_path = "/".join(path_parts[:-1]) # 确保目录存在 current_folder = self.root_path for part in dir_path.split("/"): if part: try: current_folder = current_folder.get_by_path(part).get().execute_query() except self._ClientRequestException as e: if e.code == "itemNotFound": current_folder = current_folder.create_folder(part).execute_query() else: raise e current_folder.upload(filename, data).execute_query() async def merge_chunks(self, upload_id: str, chunk_info: UploadChunk, save_path: str) -> tuple[str, str]: """合并 OneDrive 上的分片文件,使用临时文件避免内存问题""" file_sha256 = hashlib.sha256() chunk_dir = str(Path(save_path).parent / "chunks" / upload_id) # 使用临时文件存储合并数据 with tempfile.NamedTemporaryFile(delete=False) as temp_file: temp_path = temp_file.name try: async with aiofiles.open(temp_path, 'wb') as out_file: for i in range(chunk_info.total_chunks): chunk_path = f"{chunk_dir}/{i}.part" chunk_record = await UploadChunk.filter(upload_id=upload_id, chunk_index=i).first() if not chunk_record: raise ValueError(f"分片{i}记录不存在") try: chunk_data = await asyncio.to_thread(self._read_chunk, chunk_path) except Exception as e: raise ValueError(f"分片{i}文件不存在: {e}") current_hash = hashlib.sha256(chunk_data).hexdigest() if current_hash != chunk_record.chunk_hash: raise ValueError(f"分片{i}哈希不匹配: 期望 {chunk_record.chunk_hash}, 实际 {current_hash}") file_sha256.update(chunk_data) await out_file.write(chunk_data) del chunk_data # 释放内存 # 读取临时文件并上传 async with aiofiles.open(temp_path, 'rb') as f: merged_content = await f.read() await asyncio.to_thread(self._upload_merged, save_path, merged_content) finally: # 清理临时文件 if os.path.exists(temp_path): os.unlink(temp_path) return save_path, file_sha256.hexdigest() def _delete_chunk_dir(self, chunk_dir: str): """同步删除分片目录""" try: path = self._get_path_str(chunk_dir) self.root_path.get_by_path(path).delete_object().execute_query() except self._ClientRequestException as e: if e.code != "itemNotFound": raise e async def clean_chunks(self, upload_id: str, save_path: str): """清理 OneDrive 上的临时分片文件""" chunk_dir = str(Path(save_path).parent / "chunks" / upload_id) try: await asyncio.to_thread(self._delete_chunk_dir, chunk_dir) except Exception as e: logger.info(f"清理 OneDrive 分片时出错: {e}") def _file_exists(self, save_path: str) -> bool: """同步检查文件是否存在""" try: path = self._get_path_str(save_path) self.root_path.get_by_path(path).get().execute_query() return True except self._ClientRequestException as e: if e.code == "itemNotFound": return False raise e async def file_exists(self, save_path: str) -> bool: """ 检查文件是否存在于OneDrive :param save_path: 文件路径 :return: 文件是否存在 """ return await asyncio.to_thread(self._file_exists, save_path) class OpenDALFileStorage(FileStorageInterface): def __init__(self): try: import opendal except ImportError: raise ImportError('请先安装 `opendal`, 例如: "pip install opendal"') self.service = settings.opendal_scheme service_settings = {} for key, value in settings.items(): if key.startswith("opendal_" + self.service): setting_name = key.split("_", 2)[2] service_settings[setting_name] = value self.operator = opendal.AsyncOperator( settings.opendal_scheme, **service_settings ) async def save_file(self, file: UploadFile, save_path: str): # 使用 asyncio.to_thread 避免阻塞事件循环 content = await asyncio.to_thread(file.file.read) await self.operator.write(save_path, content) async def delete_file(self, file_code: FileCodes): await self.operator.delete(await file_code.get_file_path()) async def get_file_url(self, file_code: FileCodes): return await get_file_url(file_code.code) async def get_file_response(self, file_code: FileCodes): try: filename = file_code.prefix + file_code.suffix content_length = None # 初始化为 None,表示未知大小 # 尝试获取文件大小 try: stat_result = await self.operator.stat(await file_code.get_file_path()) if hasattr(stat_result, 'content_length') and stat_result.content_length: content_length = stat_result.content_length elif hasattr(stat_result, 'size') and stat_result.size: content_length = stat_result.size except Exception: # 如果获取大小失败,则不提供 Content-Length pass # 尝试使用流式读取器 try: # OpenDAL 可能提供 reader 方法返回一个异步读取器 reader = await self.operator.reader(await file_code.get_file_path()) except AttributeError: # 如果 reader 方法不存在,回退到全量读取(兼容旧版本) content = await self.operator.read(await file_code.get_file_path()) encoded_filename = quote(filename, safe='') headers = { "Content-Disposition": f"attachment; filename*=UTF-8''{encoded_filename}" } if content_length is not None: headers["Content-Length"] = str(content_length) return Response( content, headers=headers, media_type="application/octet-stream" ) async def stream_generator(): chunk_size = 65536 while True: chunk = await reader.read(chunk_size) if not chunk: break yield chunk encoded_filename = quote(filename, safe='') headers = { "Content-Disposition": f"attachment; filename*=UTF-8''{encoded_filename}" } if content_length is not None: headers["Content-Length"] = str(content_length) return StreamingResponse( stream_generator(), media_type="application/octet-stream", headers=headers ) except Exception as e: logger.info(e) raise HTTPException(status_code=404, detail="文件已过期删除") async def save_chunk(self, upload_id: str, chunk_index: int, chunk_data: bytes, chunk_hash: str, save_path: str): """保存分片到 OpenDAL 存储""" chunk_path = str(Path(save_path).parent / "chunks" / upload_id / f"{chunk_index}.part") await self.operator.write(chunk_path, chunk_data) async def merge_chunks(self, upload_id: str, chunk_info: UploadChunk, save_path: str) -> tuple[str, str]: """合并 OpenDAL 存储上的分片文件,使用临时文件避免内存问题""" file_sha256 = hashlib.sha256() chunk_dir = str(Path(save_path).parent / "chunks" / upload_id) # 使用临时文件存储合并数据 with tempfile.NamedTemporaryFile(delete=False) as temp_file: temp_path = temp_file.name try: async with aiofiles.open(temp_path, 'wb') as out_file: for i in range(chunk_info.total_chunks): chunk_path = f"{chunk_dir}/{i}.part" chunk_record = await UploadChunk.filter(upload_id=upload_id, chunk_index=i).first() if not chunk_record: raise ValueError(f"分片{i}记录不存在") try: chunk_data = await self.operator.read(chunk_path) except Exception as e: raise ValueError(f"分片{i}文件不存在: {e}") current_hash = hashlib.sha256(chunk_data).hexdigest() if current_hash != chunk_record.chunk_hash: raise ValueError(f"分片{i}哈希不匹配: 期望 {chunk_record.chunk_hash}, 实际 {current_hash}") file_sha256.update(chunk_data) await out_file.write(chunk_data) del chunk_data # 释放内存 # 读取临时文件并写入存储 async with aiofiles.open(temp_path, 'rb') as f: merged_content = await f.read() await self.operator.write(save_path, merged_content) finally: # 清理临时文件 if os.path.exists(temp_path): os.unlink(temp_path) return save_path, file_sha256.hexdigest() async def clean_chunks(self, upload_id: str, save_path: str): """清理 OpenDAL 存储上的临时分片文件""" chunk_dir = str(Path(save_path).parent / "chunks" / upload_id) try: # OpenDAL 支持递归删除 await self.operator.remove_all(chunk_dir) except Exception as e: logger.info(f"清理 OpenDAL 分片时出错: {e}") async def file_exists(self, save_path: str) -> bool: """ 检查文件是否存在于OpenDAL存储 :param save_path: 文件路径 :return: 文件是否存在 """ try: await self.operator.stat(save_path) return True except Exception: return False class WebDAVFileStorage(FileStorageInterface): _instance: Optional["WebDAVFileStorage"] = None def __init__(self): if not hasattr(self, "_initialized"): self.base_url = settings.webdav_url.rstrip("/") + "/" self.auth = aiohttp.BasicAuth( login=settings.webdav_username, password=settings.webdav_password ) self._initialized = True def _build_url(self, path: str) -> str: encoded_path = quote(str(path.replace("\\", "/").lstrip("/")).lstrip("/")) return f"{self.base_url}{encoded_path}" async def _mkdir_p(self, directory_path: str): """递归创建目录(类似mkdir -p)""" path_obj = Path(unquote(directory_path)) current_path = "" async with aiohttp.ClientSession(auth=self.auth) as session: # 逐级检查目录是否存在 for part in path_obj.parts: current_path = str(Path(current_path) / part) url = self._build_url(current_path) # 检查目录是否存在 async with session.head(url) as resp: if resp.status == 404: # 创建目录 async with session.request("MKCOL", url) as mkcol_resp: if mkcol_resp.status not in (200, 201, 409): content = await mkcol_resp.text() raise HTTPException( status_code=mkcol_resp.status, detail=f"目录创建失败: {content[:200]}", ) async def _is_dir_empty(self, dir_path: str) -> bool: """检查目录是否为空""" url = self._build_url(dir_path) async with aiohttp.ClientSession(auth=self.auth) as session: async with session.request("PROPFIND", url, headers={"Depth": "1"}) as resp: if resp.status != 207: # 207 是 Multi-Status 响应 return False content = await resp.text() # 如果只有一个 response(当前目录),说明目录为空 return content.count("") <= 1 async def _delete_empty_dirs(self, file_path: str, session: aiohttp.ClientSession): """递归删除空目录""" path_obj = Path(file_path) current_path = path_obj.parent while str(current_path) != ".": if not await self._is_dir_empty(str(current_path)): break url = self._build_url(str(current_path)) async with session.delete(url) as resp: if resp.status not in (200, 204, 404): break current_path = current_path.parent async def save_file(self, file: UploadFile, save_path: str): """保存文件(自动创建目录,流式上传)""" path_obj = Path(save_path) directory_path = str(path_obj.parent) # 提取原始文件名并进行清理 filename = await sanitize_filename(path_obj.name) # 构建安全的保存路径 safe_save_path = str(Path(directory_path) / filename) try: # 先创建目录结构 await self._mkdir_p(directory_path) # 上传文件(流式) url = self._build_url(safe_save_path) async def file_sender(): """流式读取文件内容""" chunk_size = 256 * 1024 # 256KB chunks while True: chunk = await asyncio.to_thread(file.file.read, chunk_size) if not chunk: break yield chunk async with aiohttp.ClientSession(auth=self.auth) as session: async with session.put( url, data=file_sender(), headers={"Content-Type": file.content_type or "application/octet-stream"} ) as resp: if resp.status not in (200, 201, 204): content = await resp.text() raise HTTPException( status_code=resp.status, detail=f"文件上传失败: {content[:200]}", ) except aiohttp.ClientError as e: raise HTTPException( status_code=503, detail=f"WebDAV连接异常: {str(e)}") async def delete_file(self, file_code: FileCodes): """删除WebDAV文件及空目录""" file_path = await file_code.get_file_path() url = self._build_url(file_path) try: async with aiohttp.ClientSession(auth=self.auth) as session: # 删除文件 async with session.delete(url) as resp: if resp.status not in (200, 204, 404): content = await resp.text() raise HTTPException( status_code=resp.status, detail=f"WebDAV删除失败: {content[:200]}", ) # 使用同一个 session 删除空目录 await self._delete_empty_dirs(file_path, session) except aiohttp.ClientError as e: raise HTTPException( status_code=503, detail=f"WebDAV连接异常: {str(e)}") async def get_file_url(self, file_code: FileCodes): return await get_file_url(file_code.code) async def get_file_response(self, file_code: FileCodes): """获取文件响应(代理模式)""" try: filename = file_code.prefix + file_code.suffix url = self._build_url(await file_code.get_file_path()) content_length = None # 初始化为 None,表示未知大小 # 创建ClientSession并复用(包含认证头) session = aiohttp.ClientSession(headers={ "Authorization": f"Basic {base64.b64encode(f'{settings.webdav_username}:{settings.webdav_password}'.encode()).decode()}" }) # 尝试发送HEAD请求获取Content-Length try: async with session.head(url) as resp: if resp.status == 200 and 'Content-Length' in resp.headers: content_length = int(resp.headers['Content-Length']) except Exception: # 如果HEAD请求失败,则不提供 Content-Length pass async def stream_generator(): try: async with session.get(url) as resp: if resp.status != 200: raise HTTPException( status_code=resp.status, detail=f"文件获取失败{resp.status}: {await resp.text()}", ) chunk_size = 65536 while True: chunk = await resp.content.read(chunk_size) if not chunk: break yield chunk finally: await session.close() encoded_filename = quote(filename, safe='') headers = { "Content-Disposition": f"attachment; filename*=UTF-8''{encoded_filename}" } if content_length is not None: headers["Content-Length"] = str(content_length) return StreamingResponse( stream_generator(), media_type="application/octet-stream", headers=headers ) except aiohttp.ClientError as e: raise HTTPException( status_code=503, detail=f"WebDAV连接异常: {str(e)}") async def save_chunk(self, upload_id: str, chunk_index: int, chunk_data: bytes, chunk_hash: str, save_path: str): """保存分片到 WebDAV""" chunk_dir = str(Path(save_path).parent / "chunks" / upload_id) chunk_path = f"{chunk_dir}/{chunk_index}.part" # 先创建目录结构 await self._mkdir_p(chunk_dir) chunk_url = self._build_url(chunk_path) async with aiohttp.ClientSession(auth=self.auth) as session: async with session.put(chunk_url, data=chunk_data) as resp: if resp.status not in (200, 201, 204): content = await resp.text() raise HTTPException( status_code=resp.status, detail=f"分片上传失败: {content[:200]}" ) async def merge_chunks(self, upload_id: str, chunk_info: UploadChunk, save_path: str) -> tuple[str, str]: """ 合并 WebDAV 上的分片文件 使用临时文件避免内存问题 """ file_sha256 = hashlib.sha256() chunk_dir = str(Path(save_path).parent / "chunks" / upload_id) # 使用临时文件存储合并数据,避免内存问题 with tempfile.NamedTemporaryFile(delete=False) as temp_file: temp_path = temp_file.name try: async with aiohttp.ClientSession(auth=self.auth) as session: # 按顺序读取并验证每个分片,写入临时文件 async with aiofiles.open(temp_path, 'wb') as out_file: for i in range(chunk_info.total_chunks): chunk_path = f"{chunk_dir}/{i}.part" chunk_url = self._build_url(chunk_path) # 获取分片记录 chunk_record = await UploadChunk.filter(upload_id=upload_id, chunk_index=i).first() if not chunk_record: raise ValueError(f"分片{i}记录不存在") # 下载分片数据 async with session.get(chunk_url) as resp: if resp.status != 200: raise ValueError(f"分片{i}文件不存在或无法访问") chunk_data = await resp.read() # 验证哈希 current_hash = hashlib.sha256(chunk_data).hexdigest() if current_hash != chunk_record.chunk_hash: raise ValueError(f"分片{i}哈希不匹配: 期望 {chunk_record.chunk_hash}, 实际 {current_hash}") file_sha256.update(chunk_data) await out_file.write(chunk_data) del chunk_data # 释放内存 # 确保目标目录存在 output_dir = str(Path(save_path).parent) await self._mkdir_p(output_dir) # 流式上传合并后的文件 output_url = self._build_url(save_path) async def file_sender(): async with aiofiles.open(temp_path, 'rb') as f: while True: chunk = await f.read(256 * 1024) if not chunk: break yield chunk async with session.put(output_url, data=file_sender()) as resp: if resp.status not in (200, 201, 204): content = await resp.text() raise HTTPException( status_code=resp.status, detail=f"合并文件上传失败: {content[:200]}" ) finally: # 清理临时文件 if os.path.exists(temp_path): os.unlink(temp_path) return save_path, file_sha256.hexdigest() async def clean_chunks(self, upload_id: str, save_path: str): """ 清理 WebDAV 上的临时分片文件 :param upload_id: 上传会话ID :param save_path: 文件保存路径 """ chunk_dir = str(Path(save_path).parent / "chunks" / upload_id) chunk_dir_url = self._build_url(chunk_dir) async with aiohttp.ClientSession(auth=self.auth) as session: try: # 检查分片目录是否存在 async with session.request("PROPFIND", chunk_dir_url, headers={"Depth": "1"}) as resp: if resp.status == 207: # 207 表示 Multi-Status # 获取目录下的所有分片文件 xml_data = await resp.text() file_paths = re.findall( r'(.*?)', xml_data) for file_path in file_paths: if file_path.endswith(".part"): # 删除分片文件 file_url = self._build_url(file_path) async with session.delete(file_url) as delete_resp: if delete_resp.status not in (200, 204, 404): logger.info(f"删除分片文件失败: {file_path}") # 删除分片目录 async with session.delete(chunk_dir_url) as delete_resp: if delete_resp.status not in (200, 204, 404): logger.info(f"删除分片目录失败: {chunk_dir_url}") else: logger.info(f"分片目录不存在: {chunk_dir_url}") except Exception as e: logger.info(f"清理 WebDAV 分片时出错: {e}") async def file_exists(self, save_path: str) -> bool: """ 检查文件是否存在于WebDAV :param save_path: 文件路径 :return: 文件是否存在 """ url = self._build_url(save_path) async with aiohttp.ClientSession(auth=self.auth) as session: async with session.head(url) as resp: return resp.status == 200 storages = { "local": SystemFileStorage, "s3": S3FileStorage, "onedrive": OneDriveFileStorage, "opendal": OpenDALFileStorage, "webdav": WebDAVFileStorage, } ================================================ FILE: core/tasks.py ================================================ # @Time : 2023/8/15 22:00 # @Author : Lan # @File : tasks.py # @Software: PyCharm import asyncio import datetime import logging import os from tortoise.expressions import Q from apps.base.models import FileCodes, UploadChunk from apps.base.utils import ip_limit, get_chunk_file_path_name from core.config import refresh_settings from core.settings import settings, data_root from core.storage import FileStorageInterface, storages from core.utils import get_now async def delete_expire_files(): while True: try: await refresh_settings() file_storage: FileStorageInterface = storages[settings.file_storage]() # 遍历 share目录下的所有文件夹,删除空的文件夹,并判断父目录是否为空,如果为空也删除 if settings.file_storage == "local": for root, dirs, files in os.walk(f"{data_root}/share/data"): if not dirs and not files: os.rmdir(root) await ip_limit["error"].remove_expired_ip() await ip_limit["upload"].remove_expired_ip() expire_data = await FileCodes.filter( Q(expired_at__lt=await get_now()) | Q(expired_count=0) ).all() for exp in expire_data: try: await file_storage.delete_file(exp) except Exception as e: logging.error(f"删除过期文件失败 code={exp.code}: {e}") try: await exp.delete() except Exception as e: logging.error(f"删除记录失败 code={exp.code}: {e}") except Exception as e: logging.error(e) finally: await asyncio.sleep(600) async def clean_incomplete_uploads(): while True: try: await refresh_settings() file_storage: FileStorageInterface = storages[settings.file_storage]() expire_hours = getattr(settings, "chunk_expire_hours", 24) now = await get_now() expire_time = now - datetime.timedelta(hours=expire_hours) expired_sessions = await UploadChunk.filter( chunk_index=-1, created_at__lt=expire_time ).all() for session in expired_sessions: try: save_path = session.save_path if not save_path: _, _, _, _, save_path = await get_chunk_file_path_name( session.file_name, session.upload_id ) await file_storage.clean_chunks(session.upload_id, save_path) except Exception as e: logging.error( f"清理分片文件失败 upload_id={session.upload_id}: {e}" ) try: await UploadChunk.filter(upload_id=session.upload_id).delete() logging.info(f"已清理过期上传会话 upload_id={session.upload_id}") except Exception as e: logging.error( f"删除分片记录失败 upload_id={session.upload_id}: {e}" ) except Exception as e: logging.error(f"清理未完成上传任务异常: {e}") finally: await asyncio.sleep(3600) ================================================ FILE: core/utils.py ================================================ # @Time : 2023/8/13 19:54 # @Author : Lan # @File : utils.py # @Software: PyCharm import datetime import hashlib import os import random import re import string import time from core.settings import settings async def get_random_num(): """ 获取随机数 :return: """ return random.randint(10000, 99999) r_s = string.ascii_uppercase + string.digits async def get_random_string(): """ 获取随机字符串 :return: """ return "".join(random.choice(r_s) for _ in range(5)) async def get_now(): """ 获取当前时间 :return: """ return datetime.datetime.now(datetime.timezone(datetime.timedelta(hours=8))) async def get_select_token(code: str): """ 获取下载token :param code: :return: """ token = settings.admin_token return hashlib.sha256( f"{code}{int(time.time() / 1000)}000{token}".encode() ).hexdigest() async def get_file_url(code: str): """ 对于需要通过服务器中转下载的服务,获取文件下载地址 :param code: :return: """ return f"/share/download?key={await get_select_token(code)}&code={code}" async def max_save_times_desc(max_save_seconds: int): """ 获取最大保存时间的描述 :param max_save_seconds: :return: """ def gen_desc_zh(value: int, desc: str): if value > 0: return f"{value}{desc}" else: return "" def gen_desc_en(value: int, desc: str): if value > 0: ret = f"{value} {desc}" if value > 1: ret += "s" ret += " " return ret else: return "" max_timedelta = datetime.timedelta(seconds=max_save_seconds) desc_zh, desc_en = "最长保存时间:", "Max save time: " desc_zh += gen_desc_zh(max_timedelta.days, "天") desc_en += gen_desc_en(max_timedelta.days, "day") desc_zh += gen_desc_zh(max_timedelta.seconds // 3600, "小时") desc_en += gen_desc_en(max_timedelta.seconds // 3600, "hour") desc_zh += gen_desc_zh(max_timedelta.seconds % 3600 // 60, "分钟") desc_en += gen_desc_en(max_timedelta.seconds % 3600 // 60, "minute") desc_zh += gen_desc_zh(max_timedelta.seconds % 60, "秒") desc_en += gen_desc_en(max_timedelta.seconds % 60, "second") return desc_zh, desc_en def hash_password(password: str) -> str: """ 使用 SHA256 + salt 哈希密码 返回格式: sha256$$ """ salt = os.urandom(16).hex() password_hash = hashlib.sha256(f"{salt}{password}".encode()).hexdigest() return f"sha256${salt}${password_hash}" def verify_password(password: str, hashed: str) -> bool: """ 验证密码是否匹配 支持新格式 (sha256$salt$hash) 和旧格式 (明文) """ if not hashed: return False # 新格式: sha256$salt$hash if hashed.startswith("sha256$"): parts = hashed.split("$") if len(parts) != 3: return False _, salt, stored_hash = parts password_hash = hashlib.sha256(f"{salt}{password}".encode()).hexdigest() return password_hash == stored_hash # 旧格式: 明文比较 (兼容迁移前的数据) return password == hashed def is_password_hashed(password: str) -> bool: """ 检查密码是否已经是哈希格式 """ return password.startswith("sha256$") and len(password.split("$")) == 3 async def sanitize_filename(filename: str) -> str: """ 安全处理文件名: 1. 剥离路径只保留文件名 2. 替换非法字符 3. 处理空文件名情况 """ filename = os.path.basename(filename) illegal_chars = r'[\\/*?:"<>|\x00-\x1F]' # 包含控制字符 # 替换非法字符为下划线 cleaned = re.sub(illegal_chars, "_", filename) # 处理空格(可选替换为_) cleaned = cleaned.replace(" ", "_") # 处理连续下划线 cleaned = re.sub(r"_+", "_", cleaned) # 处理首尾特殊字符 cleaned = cleaned.strip("._") # 处理空文件名情况 if not cleaned: cleaned = "unnamed_file" # 长度限制(按需调整) return cleaned[:255] ================================================ FILE: docker-compose.yml ================================================ version: "3" services: file-code-box: image: lanol/filecodebox:latest volumes: - fcb-data:/app/data:rw restart: unless-stopped ports: - "12345:12345" volumes: fcb-data: external: false ================================================ FILE: docs/.vitepress/cache/deps/_metadata.json ================================================ { "hash": "8f855eaf", "configHash": "1b3ca22f", "lockfileHash": "bd28b2c2", "browserHash": "29e84937", "optimized": { "vue": { "src": "../../../node_modules/.pnpm/vue@3.5.13/node_modules/vue/dist/vue.runtime.esm-bundler.js", "file": "vue.js", "fileHash": "3215885f", "needsInterop": false }, "vitepress > @vue/devtools-api": { "src": "../../../node_modules/.pnpm/@vue+devtools-api@7.7.1/node_modules/@vue/devtools-api/dist/index.js", "file": "vitepress___@vue_devtools-api.js", "fileHash": "5a5f95ef", "needsInterop": false }, "vitepress > @vueuse/core": { "src": "../../../node_modules/.pnpm/@vueuse+core@12.5.0/node_modules/@vueuse/core/index.mjs", "file": "vitepress___@vueuse_core.js", "fileHash": "0fbf66f0", "needsInterop": false }, "vitepress > @vueuse/integrations/useFocusTrap": { "src": "../../../node_modules/.pnpm/@vueuse+integrations@12.5.0_focus-trap@7.6.4/node_modules/@vueuse/integrations/useFocusTrap.mjs", "file": "vitepress___@vueuse_integrations_useFocusTrap.js", "fileHash": "91b03896", "needsInterop": false }, "vitepress > mark.js/src/vanilla.js": { "src": "../../../node_modules/.pnpm/mark.js@8.11.1/node_modules/mark.js/src/vanilla.js", "file": "vitepress___mark__js_src_vanilla__js.js", "fileHash": "99e4f81c", "needsInterop": false }, "vitepress > minisearch": { "src": "../../../node_modules/.pnpm/minisearch@7.1.1/node_modules/minisearch/dist/es/index.js", "file": "vitepress___minisearch.js", "fileHash": "cc176c9c", "needsInterop": false } }, "chunks": { "chunk-KT7LHMJ2": { "file": "chunk-KT7LHMJ2.js" }, "chunk-CQOUZRMK": { "file": "chunk-CQOUZRMK.js" } } } ================================================ FILE: docs/.vitepress/cache/deps/chunk-CQOUZRMK.js ================================================ // node_modules/.pnpm/@vue+shared@3.5.13/node_modules/@vue/shared/dist/shared.esm-bundler.js function makeMap(str) { const map2 = /* @__PURE__ */ Object.create(null); for (const key of str.split(",")) map2[key] = 1; return (val) => val in map2; } var EMPTY_OBJ = true ? Object.freeze({}) : {}; var EMPTY_ARR = true ? Object.freeze([]) : []; var NOOP = () => { }; var NO = () => false; var isOn = (key) => key.charCodeAt(0) === 111 && key.charCodeAt(1) === 110 && // uppercase letter (key.charCodeAt(2) > 122 || key.charCodeAt(2) < 97); var isModelListener = (key) => key.startsWith("onUpdate:"); var extend = Object.assign; var remove = (arr, el) => { const i = arr.indexOf(el); if (i > -1) { arr.splice(i, 1); } }; var hasOwnProperty = Object.prototype.hasOwnProperty; var hasOwn = (val, key) => hasOwnProperty.call(val, key); var isArray = Array.isArray; var isMap = (val) => toTypeString(val) === "[object Map]"; var isSet = (val) => toTypeString(val) === "[object Set]"; var isDate = (val) => toTypeString(val) === "[object Date]"; var isRegExp = (val) => toTypeString(val) === "[object RegExp]"; var isFunction = (val) => typeof val === "function"; var isString = (val) => typeof val === "string"; var isSymbol = (val) => typeof val === "symbol"; var isObject = (val) => val !== null && typeof val === "object"; var isPromise = (val) => { return (isObject(val) || isFunction(val)) && isFunction(val.then) && isFunction(val.catch); }; var objectToString = Object.prototype.toString; var toTypeString = (value) => objectToString.call(value); var toRawType = (value) => { return toTypeString(value).slice(8, -1); }; var isPlainObject = (val) => toTypeString(val) === "[object Object]"; var isIntegerKey = (key) => isString(key) && key !== "NaN" && key[0] !== "-" && "" + parseInt(key, 10) === key; var isReservedProp = makeMap( // the leading comma is intentional so empty string "" is also included ",key,ref,ref_for,ref_key,onVnodeBeforeMount,onVnodeMounted,onVnodeBeforeUpdate,onVnodeUpdated,onVnodeBeforeUnmount,onVnodeUnmounted" ); var isBuiltInDirective = makeMap( "bind,cloak,else-if,else,for,html,if,model,on,once,pre,show,slot,text,memo" ); var cacheStringFunction = (fn) => { const cache = /* @__PURE__ */ Object.create(null); return (str) => { const hit = cache[str]; return hit || (cache[str] = fn(str)); }; }; var camelizeRE = /-(\w)/g; var camelize = cacheStringFunction( (str) => { return str.replace(camelizeRE, (_, c) => c ? c.toUpperCase() : ""); } ); var hyphenateRE = /\B([A-Z])/g; var hyphenate = cacheStringFunction( (str) => str.replace(hyphenateRE, "-$1").toLowerCase() ); var capitalize = cacheStringFunction((str) => { return str.charAt(0).toUpperCase() + str.slice(1); }); var toHandlerKey = cacheStringFunction( (str) => { const s = str ? `on${capitalize(str)}` : ``; return s; } ); var hasChanged = (value, oldValue) => !Object.is(value, oldValue); var invokeArrayFns = (fns, ...arg) => { for (let i = 0; i < fns.length; i++) { fns[i](...arg); } }; var def = (obj, key, value, writable = false) => { Object.defineProperty(obj, key, { configurable: true, enumerable: false, writable, value }); }; var looseToNumber = (val) => { const n = parseFloat(val); return isNaN(n) ? val : n; }; var toNumber = (val) => { const n = isString(val) ? Number(val) : NaN; return isNaN(n) ? val : n; }; var _globalThis; var getGlobalThis = () => { return _globalThis || (_globalThis = typeof globalThis !== "undefined" ? globalThis : typeof self !== "undefined" ? self : typeof window !== "undefined" ? window : typeof global !== "undefined" ? global : {}); }; var GLOBALS_ALLOWED = "Infinity,undefined,NaN,isFinite,isNaN,parseFloat,parseInt,decodeURI,decodeURIComponent,encodeURI,encodeURIComponent,Math,Number,Date,Array,Object,Boolean,String,RegExp,Map,Set,JSON,Intl,BigInt,console,Error,Symbol"; var isGloballyAllowed = makeMap(GLOBALS_ALLOWED); function normalizeStyle(value) { if (isArray(value)) { const res = {}; for (let i = 0; i < value.length; i++) { const item = value[i]; const normalized = isString(item) ? parseStringStyle(item) : normalizeStyle(item); if (normalized) { for (const key in normalized) { res[key] = normalized[key]; } } } return res; } else if (isString(value) || isObject(value)) { return value; } } var listDelimiterRE = /;(?![^(]*\))/g; var propertyDelimiterRE = /:([^]+)/; var styleCommentRE = /\/\*[^]*?\*\//g; function parseStringStyle(cssText) { const ret = {}; cssText.replace(styleCommentRE, "").split(listDelimiterRE).forEach((item) => { if (item) { const tmp = item.split(propertyDelimiterRE); tmp.length > 1 && (ret[tmp[0].trim()] = tmp[1].trim()); } }); return ret; } function stringifyStyle(styles) { if (!styles) return ""; if (isString(styles)) return styles; let ret = ""; for (const key in styles) { const value = styles[key]; if (isString(value) || typeof value === "number") { const normalizedKey = key.startsWith(`--`) ? key : hyphenate(key); ret += `${normalizedKey}:${value};`; } } return ret; } function normalizeClass(value) { let res = ""; if (isString(value)) { res = value; } else if (isArray(value)) { for (let i = 0; i < value.length; i++) { const normalized = normalizeClass(value[i]); if (normalized) { res += normalized + " "; } } } else if (isObject(value)) { for (const name in value) { if (value[name]) { res += name + " "; } } } return res.trim(); } function normalizeProps(props) { if (!props) return null; let { class: klass, style } = props; if (klass && !isString(klass)) { props.class = normalizeClass(klass); } if (style) { props.style = normalizeStyle(style); } return props; } var HTML_TAGS = "html,body,base,head,link,meta,style,title,address,article,aside,footer,header,hgroup,h1,h2,h3,h4,h5,h6,nav,section,div,dd,dl,dt,figcaption,figure,picture,hr,img,li,main,ol,p,pre,ul,a,b,abbr,bdi,bdo,br,cite,code,data,dfn,em,i,kbd,mark,q,rp,rt,ruby,s,samp,small,span,strong,sub,sup,time,u,var,wbr,area,audio,map,track,video,embed,object,param,source,canvas,script,noscript,del,ins,caption,col,colgroup,table,thead,tbody,td,th,tr,button,datalist,fieldset,form,input,label,legend,meter,optgroup,option,output,progress,select,textarea,details,dialog,menu,summary,template,blockquote,iframe,tfoot"; var SVG_TAGS = "svg,animate,animateMotion,animateTransform,circle,clipPath,color-profile,defs,desc,discard,ellipse,feBlend,feColorMatrix,feComponentTransfer,feComposite,feConvolveMatrix,feDiffuseLighting,feDisplacementMap,feDistantLight,feDropShadow,feFlood,feFuncA,feFuncB,feFuncG,feFuncR,feGaussianBlur,feImage,feMerge,feMergeNode,feMorphology,feOffset,fePointLight,feSpecularLighting,feSpotLight,feTile,feTurbulence,filter,foreignObject,g,hatch,hatchpath,image,line,linearGradient,marker,mask,mesh,meshgradient,meshpatch,meshrow,metadata,mpath,path,pattern,polygon,polyline,radialGradient,rect,set,solidcolor,stop,switch,symbol,text,textPath,title,tspan,unknown,use,view"; var MATH_TAGS = "annotation,annotation-xml,maction,maligngroup,malignmark,math,menclose,merror,mfenced,mfrac,mfraction,mglyph,mi,mlabeledtr,mlongdiv,mmultiscripts,mn,mo,mover,mpadded,mphantom,mprescripts,mroot,mrow,ms,mscarries,mscarry,msgroup,msline,mspace,msqrt,msrow,mstack,mstyle,msub,msubsup,msup,mtable,mtd,mtext,mtr,munder,munderover,none,semantics"; var VOID_TAGS = "area,base,br,col,embed,hr,img,input,link,meta,param,source,track,wbr"; var isHTMLTag = makeMap(HTML_TAGS); var isSVGTag = makeMap(SVG_TAGS); var isMathMLTag = makeMap(MATH_TAGS); var isVoidTag = makeMap(VOID_TAGS); var specialBooleanAttrs = `itemscope,allowfullscreen,formnovalidate,ismap,nomodule,novalidate,readonly`; var isSpecialBooleanAttr = makeMap(specialBooleanAttrs); var isBooleanAttr = makeMap( specialBooleanAttrs + `,async,autofocus,autoplay,controls,default,defer,disabled,hidden,inert,loop,open,required,reversed,scoped,seamless,checked,muted,multiple,selected` ); function includeBooleanAttr(value) { return !!value || value === ""; } var isKnownHtmlAttr = makeMap( `accept,accept-charset,accesskey,action,align,allow,alt,async,autocapitalize,autocomplete,autofocus,autoplay,background,bgcolor,border,buffered,capture,challenge,charset,checked,cite,class,code,codebase,color,cols,colspan,content,contenteditable,contextmenu,controls,coords,crossorigin,csp,data,datetime,decoding,default,defer,dir,dirname,disabled,download,draggable,dropzone,enctype,enterkeyhint,for,form,formaction,formenctype,formmethod,formnovalidate,formtarget,headers,height,hidden,high,href,hreflang,http-equiv,icon,id,importance,inert,integrity,ismap,itemprop,keytype,kind,label,lang,language,loading,list,loop,low,manifest,max,maxlength,minlength,media,min,multiple,muted,name,novalidate,open,optimum,pattern,ping,placeholder,poster,preload,radiogroup,readonly,referrerpolicy,rel,required,reversed,rows,rowspan,sandbox,scope,scoped,selected,shape,size,sizes,slot,span,spellcheck,src,srcdoc,srclang,srcset,start,step,style,summary,tabindex,target,title,translate,type,usemap,value,width,wrap` ); var isKnownSvgAttr = makeMap( `xmlns,accent-height,accumulate,additive,alignment-baseline,alphabetic,amplitude,arabic-form,ascent,attributeName,attributeType,azimuth,baseFrequency,baseline-shift,baseProfile,bbox,begin,bias,by,calcMode,cap-height,class,clip,clipPathUnits,clip-path,clip-rule,color,color-interpolation,color-interpolation-filters,color-profile,color-rendering,contentScriptType,contentStyleType,crossorigin,cursor,cx,cy,d,decelerate,descent,diffuseConstant,direction,display,divisor,dominant-baseline,dur,dx,dy,edgeMode,elevation,enable-background,end,exponent,fill,fill-opacity,fill-rule,filter,filterRes,filterUnits,flood-color,flood-opacity,font-family,font-size,font-size-adjust,font-stretch,font-style,font-variant,font-weight,format,from,fr,fx,fy,g1,g2,glyph-name,glyph-orientation-horizontal,glyph-orientation-vertical,glyphRef,gradientTransform,gradientUnits,hanging,height,href,hreflang,horiz-adv-x,horiz-origin-x,id,ideographic,image-rendering,in,in2,intercept,k,k1,k2,k3,k4,kernelMatrix,kernelUnitLength,kerning,keyPoints,keySplines,keyTimes,lang,lengthAdjust,letter-spacing,lighting-color,limitingConeAngle,local,marker-end,marker-mid,marker-start,markerHeight,markerUnits,markerWidth,mask,maskContentUnits,maskUnits,mathematical,max,media,method,min,mode,name,numOctaves,offset,opacity,operator,order,orient,orientation,origin,overflow,overline-position,overline-thickness,panose-1,paint-order,path,pathLength,patternContentUnits,patternTransform,patternUnits,ping,pointer-events,points,pointsAtX,pointsAtY,pointsAtZ,preserveAlpha,preserveAspectRatio,primitiveUnits,r,radius,referrerPolicy,refX,refY,rel,rendering-intent,repeatCount,repeatDur,requiredExtensions,requiredFeatures,restart,result,rotate,rx,ry,scale,seed,shape-rendering,slope,spacing,specularConstant,specularExponent,speed,spreadMethod,startOffset,stdDeviation,stemh,stemv,stitchTiles,stop-color,stop-opacity,strikethrough-position,strikethrough-thickness,string,stroke,stroke-dasharray,stroke-dashoffset,stroke-linecap,stroke-linejoin,stroke-miterlimit,stroke-opacity,stroke-width,style,surfaceScale,systemLanguage,tabindex,tableValues,target,targetX,targetY,text-anchor,text-decoration,text-rendering,textLength,to,transform,transform-origin,type,u1,u2,underline-position,underline-thickness,unicode,unicode-bidi,unicode-range,units-per-em,v-alphabetic,v-hanging,v-ideographic,v-mathematical,values,vector-effect,version,vert-adv-y,vert-origin-x,vert-origin-y,viewBox,viewTarget,visibility,width,widths,word-spacing,writing-mode,x,x-height,x1,x2,xChannelSelector,xlink:actuate,xlink:arcrole,xlink:href,xlink:role,xlink:show,xlink:title,xlink:type,xmlns:xlink,xml:base,xml:lang,xml:space,y,y1,y2,yChannelSelector,z,zoomAndPan` ); var isKnownMathMLAttr = makeMap( `accent,accentunder,actiontype,align,alignmentscope,altimg,altimg-height,altimg-valign,altimg-width,alttext,bevelled,close,columnsalign,columnlines,columnspan,denomalign,depth,dir,display,displaystyle,encoding,equalcolumns,equalrows,fence,fontstyle,fontweight,form,frame,framespacing,groupalign,height,href,id,indentalign,indentalignfirst,indentalignlast,indentshift,indentshiftfirst,indentshiftlast,indextype,justify,largetop,largeop,lquote,lspace,mathbackground,mathcolor,mathsize,mathvariant,maxsize,minlabelspacing,mode,other,overflow,position,rowalign,rowlines,rowspan,rquote,rspace,scriptlevel,scriptminsize,scriptsizemultiplier,selection,separator,separators,shift,side,src,stackalign,stretchy,subscriptshift,superscriptshift,symmetric,voffset,width,widths,xlink:href,xlink:show,xlink:type,xmlns` ); function isRenderableAttrValue(value) { if (value == null) { return false; } const type = typeof value; return type === "string" || type === "number" || type === "boolean"; } var cssVarNameEscapeSymbolsRE = /[ !"#$%&'()*+,./:;<=>?@[\\\]^`{|}~]/g; function getEscapedCssVarName(key, doubleEscape) { return key.replace( cssVarNameEscapeSymbolsRE, (s) => doubleEscape ? s === '"' ? '\\\\\\"' : `\\\\${s}` : `\\${s}` ); } function looseCompareArrays(a, b) { if (a.length !== b.length) return false; let equal = true; for (let i = 0; equal && i < a.length; i++) { equal = looseEqual(a[i], b[i]); } return equal; } function looseEqual(a, b) { if (a === b) return true; let aValidType = isDate(a); let bValidType = isDate(b); if (aValidType || bValidType) { return aValidType && bValidType ? a.getTime() === b.getTime() : false; } aValidType = isSymbol(a); bValidType = isSymbol(b); if (aValidType || bValidType) { return a === b; } aValidType = isArray(a); bValidType = isArray(b); if (aValidType || bValidType) { return aValidType && bValidType ? looseCompareArrays(a, b) : false; } aValidType = isObject(a); bValidType = isObject(b); if (aValidType || bValidType) { if (!aValidType || !bValidType) { return false; } const aKeysCount = Object.keys(a).length; const bKeysCount = Object.keys(b).length; if (aKeysCount !== bKeysCount) { return false; } for (const key in a) { const aHasKey = a.hasOwnProperty(key); const bHasKey = b.hasOwnProperty(key); if (aHasKey && !bHasKey || !aHasKey && bHasKey || !looseEqual(a[key], b[key])) { return false; } } } return String(a) === String(b); } function looseIndexOf(arr, val) { return arr.findIndex((item) => looseEqual(item, val)); } var isRef = (val) => { return !!(val && val["__v_isRef"] === true); }; var toDisplayString = (val) => { return isString(val) ? val : val == null ? "" : isArray(val) || isObject(val) && (val.toString === objectToString || !isFunction(val.toString)) ? isRef(val) ? toDisplayString(val.value) : JSON.stringify(val, replacer, 2) : String(val); }; var replacer = (_key, val) => { if (isRef(val)) { return replacer(_key, val.value); } else if (isMap(val)) { return { [`Map(${val.size})`]: [...val.entries()].reduce( (entries, [key, val2], i) => { entries[stringifySymbol(key, i) + " =>"] = val2; return entries; }, {} ) }; } else if (isSet(val)) { return { [`Set(${val.size})`]: [...val.values()].map((v) => stringifySymbol(v)) }; } else if (isSymbol(val)) { return stringifySymbol(val); } else if (isObject(val) && !isArray(val) && !isPlainObject(val)) { return String(val); } return val; }; var stringifySymbol = (v, i = "") => { var _a; return ( // Symbol.description in es2019+ so we need to cast here to pass // the lib: es2016 check isSymbol(v) ? `Symbol(${(_a = v.description) != null ? _a : i})` : v ); }; // node_modules/.pnpm/@vue+reactivity@3.5.13/node_modules/@vue/reactivity/dist/reactivity.esm-bundler.js function warn(msg, ...args) { console.warn(`[Vue warn] ${msg}`, ...args); } var activeEffectScope; var EffectScope = class { constructor(detached = false) { this.detached = detached; this._active = true; this.effects = []; this.cleanups = []; this._isPaused = false; this.parent = activeEffectScope; if (!detached && activeEffectScope) { this.index = (activeEffectScope.scopes || (activeEffectScope.scopes = [])).push( this ) - 1; } } get active() { return this._active; } pause() { if (this._active) { this._isPaused = true; let i, l; if (this.scopes) { for (i = 0, l = this.scopes.length; i < l; i++) { this.scopes[i].pause(); } } for (i = 0, l = this.effects.length; i < l; i++) { this.effects[i].pause(); } } } /** * Resumes the effect scope, including all child scopes and effects. */ resume() { if (this._active) { if (this._isPaused) { this._isPaused = false; let i, l; if (this.scopes) { for (i = 0, l = this.scopes.length; i < l; i++) { this.scopes[i].resume(); } } for (i = 0, l = this.effects.length; i < l; i++) { this.effects[i].resume(); } } } } run(fn) { if (this._active) { const currentEffectScope = activeEffectScope; try { activeEffectScope = this; return fn(); } finally { activeEffectScope = currentEffectScope; } } else if (true) { warn(`cannot run an inactive effect scope.`); } } /** * This should only be called on non-detached scopes * @internal */ on() { activeEffectScope = this; } /** * This should only be called on non-detached scopes * @internal */ off() { activeEffectScope = this.parent; } stop(fromParent) { if (this._active) { this._active = false; let i, l; for (i = 0, l = this.effects.length; i < l; i++) { this.effects[i].stop(); } this.effects.length = 0; for (i = 0, l = this.cleanups.length; i < l; i++) { this.cleanups[i](); } this.cleanups.length = 0; if (this.scopes) { for (i = 0, l = this.scopes.length; i < l; i++) { this.scopes[i].stop(true); } this.scopes.length = 0; } if (!this.detached && this.parent && !fromParent) { const last = this.parent.scopes.pop(); if (last && last !== this) { this.parent.scopes[this.index] = last; last.index = this.index; } } this.parent = void 0; } } }; function effectScope(detached) { return new EffectScope(detached); } function getCurrentScope() { return activeEffectScope; } function onScopeDispose(fn, failSilently = false) { if (activeEffectScope) { activeEffectScope.cleanups.push(fn); } else if (!failSilently) { warn( `onScopeDispose() is called when there is no active effect scope to be associated with.` ); } } var activeSub; var pausedQueueEffects = /* @__PURE__ */ new WeakSet(); var ReactiveEffect = class { constructor(fn) { this.fn = fn; this.deps = void 0; this.depsTail = void 0; this.flags = 1 | 4; this.next = void 0; this.cleanup = void 0; this.scheduler = void 0; if (activeEffectScope && activeEffectScope.active) { activeEffectScope.effects.push(this); } } pause() { this.flags |= 64; } resume() { if (this.flags & 64) { this.flags &= ~64; if (pausedQueueEffects.has(this)) { pausedQueueEffects.delete(this); this.trigger(); } } } /** * @internal */ notify() { if (this.flags & 2 && !(this.flags & 32)) { return; } if (!(this.flags & 8)) { batch(this); } } run() { if (!(this.flags & 1)) { return this.fn(); } this.flags |= 2; cleanupEffect(this); prepareDeps(this); const prevEffect = activeSub; const prevShouldTrack = shouldTrack; activeSub = this; shouldTrack = true; try { return this.fn(); } finally { if (activeSub !== this) { warn( "Active effect was not restored correctly - this is likely a Vue internal bug." ); } cleanupDeps(this); activeSub = prevEffect; shouldTrack = prevShouldTrack; this.flags &= ~2; } } stop() { if (this.flags & 1) { for (let link = this.deps; link; link = link.nextDep) { removeSub(link); } this.deps = this.depsTail = void 0; cleanupEffect(this); this.onStop && this.onStop(); this.flags &= ~1; } } trigger() { if (this.flags & 64) { pausedQueueEffects.add(this); } else if (this.scheduler) { this.scheduler(); } else { this.runIfDirty(); } } /** * @internal */ runIfDirty() { if (isDirty(this)) { this.run(); } } get dirty() { return isDirty(this); } }; var batchDepth = 0; var batchedSub; var batchedComputed; function batch(sub, isComputed = false) { sub.flags |= 8; if (isComputed) { sub.next = batchedComputed; batchedComputed = sub; return; } sub.next = batchedSub; batchedSub = sub; } function startBatch() { batchDepth++; } function endBatch() { if (--batchDepth > 0) { return; } if (batchedComputed) { let e = batchedComputed; batchedComputed = void 0; while (e) { const next = e.next; e.next = void 0; e.flags &= ~8; e = next; } } let error; while (batchedSub) { let e = batchedSub; batchedSub = void 0; while (e) { const next = e.next; e.next = void 0; e.flags &= ~8; if (e.flags & 1) { try { ; e.trigger(); } catch (err) { if (!error) error = err; } } e = next; } } if (error) throw error; } function prepareDeps(sub) { for (let link = sub.deps; link; link = link.nextDep) { link.version = -1; link.prevActiveLink = link.dep.activeLink; link.dep.activeLink = link; } } function cleanupDeps(sub) { let head; let tail = sub.depsTail; let link = tail; while (link) { const prev = link.prevDep; if (link.version === -1) { if (link === tail) tail = prev; removeSub(link); removeDep(link); } else { head = link; } link.dep.activeLink = link.prevActiveLink; link.prevActiveLink = void 0; link = prev; } sub.deps = head; sub.depsTail = tail; } function isDirty(sub) { for (let link = sub.deps; link; link = link.nextDep) { if (link.dep.version !== link.version || link.dep.computed && (refreshComputed(link.dep.computed) || link.dep.version !== link.version)) { return true; } } if (sub._dirty) { return true; } return false; } function refreshComputed(computed3) { if (computed3.flags & 4 && !(computed3.flags & 16)) { return; } computed3.flags &= ~16; if (computed3.globalVersion === globalVersion) { return; } computed3.globalVersion = globalVersion; const dep = computed3.dep; computed3.flags |= 2; if (dep.version > 0 && !computed3.isSSR && computed3.deps && !isDirty(computed3)) { computed3.flags &= ~2; return; } const prevSub = activeSub; const prevShouldTrack = shouldTrack; activeSub = computed3; shouldTrack = true; try { prepareDeps(computed3); const value = computed3.fn(computed3._value); if (dep.version === 0 || hasChanged(value, computed3._value)) { computed3._value = value; dep.version++; } } catch (err) { dep.version++; throw err; } finally { activeSub = prevSub; shouldTrack = prevShouldTrack; cleanupDeps(computed3); computed3.flags &= ~2; } } function removeSub(link, soft = false) { const { dep, prevSub, nextSub } = link; if (prevSub) { prevSub.nextSub = nextSub; link.prevSub = void 0; } if (nextSub) { nextSub.prevSub = prevSub; link.nextSub = void 0; } if (dep.subsHead === link) { dep.subsHead = nextSub; } if (dep.subs === link) { dep.subs = prevSub; if (!prevSub && dep.computed) { dep.computed.flags &= ~4; for (let l = dep.computed.deps; l; l = l.nextDep) { removeSub(l, true); } } } if (!soft && !--dep.sc && dep.map) { dep.map.delete(dep.key); } } function removeDep(link) { const { prevDep, nextDep } = link; if (prevDep) { prevDep.nextDep = nextDep; link.prevDep = void 0; } if (nextDep) { nextDep.prevDep = prevDep; link.nextDep = void 0; } } function effect(fn, options) { if (fn.effect instanceof ReactiveEffect) { fn = fn.effect.fn; } const e = new ReactiveEffect(fn); if (options) { extend(e, options); } try { e.run(); } catch (err) { e.stop(); throw err; } const runner = e.run.bind(e); runner.effect = e; return runner; } function stop(runner) { runner.effect.stop(); } var shouldTrack = true; var trackStack = []; function pauseTracking() { trackStack.push(shouldTrack); shouldTrack = false; } function resetTracking() { const last = trackStack.pop(); shouldTrack = last === void 0 ? true : last; } function cleanupEffect(e) { const { cleanup } = e; e.cleanup = void 0; if (cleanup) { const prevSub = activeSub; activeSub = void 0; try { cleanup(); } finally { activeSub = prevSub; } } } var globalVersion = 0; var Link = class { constructor(sub, dep) { this.sub = sub; this.dep = dep; this.version = dep.version; this.nextDep = this.prevDep = this.nextSub = this.prevSub = this.prevActiveLink = void 0; } }; var Dep = class { constructor(computed3) { this.computed = computed3; this.version = 0; this.activeLink = void 0; this.subs = void 0; this.map = void 0; this.key = void 0; this.sc = 0; if (true) { this.subsHead = void 0; } } track(debugInfo) { if (!activeSub || !shouldTrack || activeSub === this.computed) { return; } let link = this.activeLink; if (link === void 0 || link.sub !== activeSub) { link = this.activeLink = new Link(activeSub, this); if (!activeSub.deps) { activeSub.deps = activeSub.depsTail = link; } else { link.prevDep = activeSub.depsTail; activeSub.depsTail.nextDep = link; activeSub.depsTail = link; } addSub(link); } else if (link.version === -1) { link.version = this.version; if (link.nextDep) { const next = link.nextDep; next.prevDep = link.prevDep; if (link.prevDep) { link.prevDep.nextDep = next; } link.prevDep = activeSub.depsTail; link.nextDep = void 0; activeSub.depsTail.nextDep = link; activeSub.depsTail = link; if (activeSub.deps === link) { activeSub.deps = next; } } } if (activeSub.onTrack) { activeSub.onTrack( extend( { effect: activeSub }, debugInfo ) ); } return link; } trigger(debugInfo) { this.version++; globalVersion++; this.notify(debugInfo); } notify(debugInfo) { startBatch(); try { if (true) { for (let head = this.subsHead; head; head = head.nextSub) { if (head.sub.onTrigger && !(head.sub.flags & 8)) { head.sub.onTrigger( extend( { effect: head.sub }, debugInfo ) ); } } } for (let link = this.subs; link; link = link.prevSub) { if (link.sub.notify()) { ; link.sub.dep.notify(); } } } finally { endBatch(); } } }; function addSub(link) { link.dep.sc++; if (link.sub.flags & 4) { const computed3 = link.dep.computed; if (computed3 && !link.dep.subs) { computed3.flags |= 4 | 16; for (let l = computed3.deps; l; l = l.nextDep) { addSub(l); } } const currentTail = link.dep.subs; if (currentTail !== link) { link.prevSub = currentTail; if (currentTail) currentTail.nextSub = link; } if (link.dep.subsHead === void 0) { link.dep.subsHead = link; } link.dep.subs = link; } } var targetMap = /* @__PURE__ */ new WeakMap(); var ITERATE_KEY = Symbol( true ? "Object iterate" : "" ); var MAP_KEY_ITERATE_KEY = Symbol( true ? "Map keys iterate" : "" ); var ARRAY_ITERATE_KEY = Symbol( true ? "Array iterate" : "" ); function track(target, type, key) { if (shouldTrack && activeSub) { let depsMap = targetMap.get(target); if (!depsMap) { targetMap.set(target, depsMap = /* @__PURE__ */ new Map()); } let dep = depsMap.get(key); if (!dep) { depsMap.set(key, dep = new Dep()); dep.map = depsMap; dep.key = key; } if (true) { dep.track({ target, type, key }); } else { dep.track(); } } } function trigger(target, type, key, newValue, oldValue, oldTarget) { const depsMap = targetMap.get(target); if (!depsMap) { globalVersion++; return; } const run = (dep) => { if (dep) { if (true) { dep.trigger({ target, type, key, newValue, oldValue, oldTarget }); } else { dep.trigger(); } } }; startBatch(); if (type === "clear") { depsMap.forEach(run); } else { const targetIsArray = isArray(target); const isArrayIndex = targetIsArray && isIntegerKey(key); if (targetIsArray && key === "length") { const newLength = Number(newValue); depsMap.forEach((dep, key2) => { if (key2 === "length" || key2 === ARRAY_ITERATE_KEY || !isSymbol(key2) && key2 >= newLength) { run(dep); } }); } else { if (key !== void 0 || depsMap.has(void 0)) { run(depsMap.get(key)); } if (isArrayIndex) { run(depsMap.get(ARRAY_ITERATE_KEY)); } switch (type) { case "add": if (!targetIsArray) { run(depsMap.get(ITERATE_KEY)); if (isMap(target)) { run(depsMap.get(MAP_KEY_ITERATE_KEY)); } } else if (isArrayIndex) { run(depsMap.get("length")); } break; case "delete": if (!targetIsArray) { run(depsMap.get(ITERATE_KEY)); if (isMap(target)) { run(depsMap.get(MAP_KEY_ITERATE_KEY)); } } break; case "set": if (isMap(target)) { run(depsMap.get(ITERATE_KEY)); } break; } } } endBatch(); } function getDepFromReactive(object, key) { const depMap = targetMap.get(object); return depMap && depMap.get(key); } function reactiveReadArray(array) { const raw = toRaw(array); if (raw === array) return raw; track(raw, "iterate", ARRAY_ITERATE_KEY); return isShallow(array) ? raw : raw.map(toReactive); } function shallowReadArray(arr) { track(arr = toRaw(arr), "iterate", ARRAY_ITERATE_KEY); return arr; } var arrayInstrumentations = { __proto__: null, [Symbol.iterator]() { return iterator(this, Symbol.iterator, toReactive); }, concat(...args) { return reactiveReadArray(this).concat( ...args.map((x) => isArray(x) ? reactiveReadArray(x) : x) ); }, entries() { return iterator(this, "entries", (value) => { value[1] = toReactive(value[1]); return value; }); }, every(fn, thisArg) { return apply(this, "every", fn, thisArg, void 0, arguments); }, filter(fn, thisArg) { return apply(this, "filter", fn, thisArg, (v) => v.map(toReactive), arguments); }, find(fn, thisArg) { return apply(this, "find", fn, thisArg, toReactive, arguments); }, findIndex(fn, thisArg) { return apply(this, "findIndex", fn, thisArg, void 0, arguments); }, findLast(fn, thisArg) { return apply(this, "findLast", fn, thisArg, toReactive, arguments); }, findLastIndex(fn, thisArg) { return apply(this, "findLastIndex", fn, thisArg, void 0, arguments); }, // flat, flatMap could benefit from ARRAY_ITERATE but are not straight-forward to implement forEach(fn, thisArg) { return apply(this, "forEach", fn, thisArg, void 0, arguments); }, includes(...args) { return searchProxy(this, "includes", args); }, indexOf(...args) { return searchProxy(this, "indexOf", args); }, join(separator) { return reactiveReadArray(this).join(separator); }, // keys() iterator only reads `length`, no optimisation required lastIndexOf(...args) { return searchProxy(this, "lastIndexOf", args); }, map(fn, thisArg) { return apply(this, "map", fn, thisArg, void 0, arguments); }, pop() { return noTracking(this, "pop"); }, push(...args) { return noTracking(this, "push", args); }, reduce(fn, ...args) { return reduce(this, "reduce", fn, args); }, reduceRight(fn, ...args) { return reduce(this, "reduceRight", fn, args); }, shift() { return noTracking(this, "shift"); }, // slice could use ARRAY_ITERATE but also seems to beg for range tracking some(fn, thisArg) { return apply(this, "some", fn, thisArg, void 0, arguments); }, splice(...args) { return noTracking(this, "splice", args); }, toReversed() { return reactiveReadArray(this).toReversed(); }, toSorted(comparer) { return reactiveReadArray(this).toSorted(comparer); }, toSpliced(...args) { return reactiveReadArray(this).toSpliced(...args); }, unshift(...args) { return noTracking(this, "unshift", args); }, values() { return iterator(this, "values", toReactive); } }; function iterator(self2, method, wrapValue) { const arr = shallowReadArray(self2); const iter = arr[method](); if (arr !== self2 && !isShallow(self2)) { iter._next = iter.next; iter.next = () => { const result = iter._next(); if (result.value) { result.value = wrapValue(result.value); } return result; }; } return iter; } var arrayProto = Array.prototype; function apply(self2, method, fn, thisArg, wrappedRetFn, args) { const arr = shallowReadArray(self2); const needsWrap = arr !== self2 && !isShallow(self2); const methodFn = arr[method]; if (methodFn !== arrayProto[method]) { const result2 = methodFn.apply(self2, args); return needsWrap ? toReactive(result2) : result2; } let wrappedFn = fn; if (arr !== self2) { if (needsWrap) { wrappedFn = function(item, index) { return fn.call(this, toReactive(item), index, self2); }; } else if (fn.length > 2) { wrappedFn = function(item, index) { return fn.call(this, item, index, self2); }; } } const result = methodFn.call(arr, wrappedFn, thisArg); return needsWrap && wrappedRetFn ? wrappedRetFn(result) : result; } function reduce(self2, method, fn, args) { const arr = shallowReadArray(self2); let wrappedFn = fn; if (arr !== self2) { if (!isShallow(self2)) { wrappedFn = function(acc, item, index) { return fn.call(this, acc, toReactive(item), index, self2); }; } else if (fn.length > 3) { wrappedFn = function(acc, item, index) { return fn.call(this, acc, item, index, self2); }; } } return arr[method](wrappedFn, ...args); } function searchProxy(self2, method, args) { const arr = toRaw(self2); track(arr, "iterate", ARRAY_ITERATE_KEY); const res = arr[method](...args); if ((res === -1 || res === false) && isProxy(args[0])) { args[0] = toRaw(args[0]); return arr[method](...args); } return res; } function noTracking(self2, method, args = []) { pauseTracking(); startBatch(); const res = toRaw(self2)[method].apply(self2, args); endBatch(); resetTracking(); return res; } var isNonTrackableKeys = makeMap(`__proto__,__v_isRef,__isVue`); var builtInSymbols = new Set( Object.getOwnPropertyNames(Symbol).filter((key) => key !== "arguments" && key !== "caller").map((key) => Symbol[key]).filter(isSymbol) ); function hasOwnProperty2(key) { if (!isSymbol(key)) key = String(key); const obj = toRaw(this); track(obj, "has", key); return obj.hasOwnProperty(key); } var BaseReactiveHandler = class { constructor(_isReadonly = false, _isShallow = false) { this._isReadonly = _isReadonly; this._isShallow = _isShallow; } get(target, key, receiver) { if (key === "__v_skip") return target["__v_skip"]; const isReadonly2 = this._isReadonly, isShallow2 = this._isShallow; if (key === "__v_isReactive") { return !isReadonly2; } else if (key === "__v_isReadonly") { return isReadonly2; } else if (key === "__v_isShallow") { return isShallow2; } else if (key === "__v_raw") { if (receiver === (isReadonly2 ? isShallow2 ? shallowReadonlyMap : readonlyMap : isShallow2 ? shallowReactiveMap : reactiveMap).get(target) || // receiver is not the reactive proxy, but has the same prototype // this means the receiver is a user proxy of the reactive proxy Object.getPrototypeOf(target) === Object.getPrototypeOf(receiver)) { return target; } return; } const targetIsArray = isArray(target); if (!isReadonly2) { let fn; if (targetIsArray && (fn = arrayInstrumentations[key])) { return fn; } if (key === "hasOwnProperty") { return hasOwnProperty2; } } const res = Reflect.get( target, key, // if this is a proxy wrapping a ref, return methods using the raw ref // as receiver so that we don't have to call `toRaw` on the ref in all // its class methods isRef2(target) ? target : receiver ); if (isSymbol(key) ? builtInSymbols.has(key) : isNonTrackableKeys(key)) { return res; } if (!isReadonly2) { track(target, "get", key); } if (isShallow2) { return res; } if (isRef2(res)) { return targetIsArray && isIntegerKey(key) ? res : res.value; } if (isObject(res)) { return isReadonly2 ? readonly(res) : reactive(res); } return res; } }; var MutableReactiveHandler = class extends BaseReactiveHandler { constructor(isShallow2 = false) { super(false, isShallow2); } set(target, key, value, receiver) { let oldValue = target[key]; if (!this._isShallow) { const isOldValueReadonly = isReadonly(oldValue); if (!isShallow(value) && !isReadonly(value)) { oldValue = toRaw(oldValue); value = toRaw(value); } if (!isArray(target) && isRef2(oldValue) && !isRef2(value)) { if (isOldValueReadonly) { return false; } else { oldValue.value = value; return true; } } } const hadKey = isArray(target) && isIntegerKey(key) ? Number(key) < target.length : hasOwn(target, key); const result = Reflect.set( target, key, value, isRef2(target) ? target : receiver ); if (target === toRaw(receiver)) { if (!hadKey) { trigger(target, "add", key, value); } else if (hasChanged(value, oldValue)) { trigger(target, "set", key, value, oldValue); } } return result; } deleteProperty(target, key) { const hadKey = hasOwn(target, key); const oldValue = target[key]; const result = Reflect.deleteProperty(target, key); if (result && hadKey) { trigger(target, "delete", key, void 0, oldValue); } return result; } has(target, key) { const result = Reflect.has(target, key); if (!isSymbol(key) || !builtInSymbols.has(key)) { track(target, "has", key); } return result; } ownKeys(target) { track( target, "iterate", isArray(target) ? "length" : ITERATE_KEY ); return Reflect.ownKeys(target); } }; var ReadonlyReactiveHandler = class extends BaseReactiveHandler { constructor(isShallow2 = false) { super(true, isShallow2); } set(target, key) { if (true) { warn( `Set operation on key "${String(key)}" failed: target is readonly.`, target ); } return true; } deleteProperty(target, key) { if (true) { warn( `Delete operation on key "${String(key)}" failed: target is readonly.`, target ); } return true; } }; var mutableHandlers = new MutableReactiveHandler(); var readonlyHandlers = new ReadonlyReactiveHandler(); var shallowReactiveHandlers = new MutableReactiveHandler(true); var shallowReadonlyHandlers = new ReadonlyReactiveHandler(true); var toShallow = (value) => value; var getProto = (v) => Reflect.getPrototypeOf(v); function createIterableMethod(method, isReadonly2, isShallow2) { return function(...args) { const target = this["__v_raw"]; const rawTarget = toRaw(target); const targetIsMap = isMap(rawTarget); const isPair = method === "entries" || method === Symbol.iterator && targetIsMap; const isKeyOnly = method === "keys" && targetIsMap; const innerIterator = target[method](...args); const wrap = isShallow2 ? toShallow : isReadonly2 ? toReadonly : toReactive; !isReadonly2 && track( rawTarget, "iterate", isKeyOnly ? MAP_KEY_ITERATE_KEY : ITERATE_KEY ); return { // iterator protocol next() { const { value, done } = innerIterator.next(); return done ? { value, done } : { value: isPair ? [wrap(value[0]), wrap(value[1])] : wrap(value), done }; }, // iterable protocol [Symbol.iterator]() { return this; } }; }; } function createReadonlyMethod(type) { return function(...args) { if (true) { const key = args[0] ? `on key "${args[0]}" ` : ``; warn( `${capitalize(type)} operation ${key}failed: target is readonly.`, toRaw(this) ); } return type === "delete" ? false : type === "clear" ? void 0 : this; }; } function createInstrumentations(readonly2, shallow) { const instrumentations = { get(key) { const target = this["__v_raw"]; const rawTarget = toRaw(target); const rawKey = toRaw(key); if (!readonly2) { if (hasChanged(key, rawKey)) { track(rawTarget, "get", key); } track(rawTarget, "get", rawKey); } const { has } = getProto(rawTarget); const wrap = shallow ? toShallow : readonly2 ? toReadonly : toReactive; if (has.call(rawTarget, key)) { return wrap(target.get(key)); } else if (has.call(rawTarget, rawKey)) { return wrap(target.get(rawKey)); } else if (target !== rawTarget) { target.get(key); } }, get size() { const target = this["__v_raw"]; !readonly2 && track(toRaw(target), "iterate", ITERATE_KEY); return Reflect.get(target, "size", target); }, has(key) { const target = this["__v_raw"]; const rawTarget = toRaw(target); const rawKey = toRaw(key); if (!readonly2) { if (hasChanged(key, rawKey)) { track(rawTarget, "has", key); } track(rawTarget, "has", rawKey); } return key === rawKey ? target.has(key) : target.has(key) || target.has(rawKey); }, forEach(callback, thisArg) { const observed = this; const target = observed["__v_raw"]; const rawTarget = toRaw(target); const wrap = shallow ? toShallow : readonly2 ? toReadonly : toReactive; !readonly2 && track(rawTarget, "iterate", ITERATE_KEY); return target.forEach((value, key) => { return callback.call(thisArg, wrap(value), wrap(key), observed); }); } }; extend( instrumentations, readonly2 ? { add: createReadonlyMethod("add"), set: createReadonlyMethod("set"), delete: createReadonlyMethod("delete"), clear: createReadonlyMethod("clear") } : { add(value) { if (!shallow && !isShallow(value) && !isReadonly(value)) { value = toRaw(value); } const target = toRaw(this); const proto = getProto(target); const hadKey = proto.has.call(target, value); if (!hadKey) { target.add(value); trigger(target, "add", value, value); } return this; }, set(key, value) { if (!shallow && !isShallow(value) && !isReadonly(value)) { value = toRaw(value); } const target = toRaw(this); const { has, get } = getProto(target); let hadKey = has.call(target, key); if (!hadKey) { key = toRaw(key); hadKey = has.call(target, key); } else if (true) { checkIdentityKeys(target, has, key); } const oldValue = get.call(target, key); target.set(key, value); if (!hadKey) { trigger(target, "add", key, value); } else if (hasChanged(value, oldValue)) { trigger(target, "set", key, value, oldValue); } return this; }, delete(key) { const target = toRaw(this); const { has, get } = getProto(target); let hadKey = has.call(target, key); if (!hadKey) { key = toRaw(key); hadKey = has.call(target, key); } else if (true) { checkIdentityKeys(target, has, key); } const oldValue = get ? get.call(target, key) : void 0; const result = target.delete(key); if (hadKey) { trigger(target, "delete", key, void 0, oldValue); } return result; }, clear() { const target = toRaw(this); const hadItems = target.size !== 0; const oldTarget = true ? isMap(target) ? new Map(target) : new Set(target) : void 0; const result = target.clear(); if (hadItems) { trigger( target, "clear", void 0, void 0, oldTarget ); } return result; } } ); const iteratorMethods = [ "keys", "values", "entries", Symbol.iterator ]; iteratorMethods.forEach((method) => { instrumentations[method] = createIterableMethod(method, readonly2, shallow); }); return instrumentations; } function createInstrumentationGetter(isReadonly2, shallow) { const instrumentations = createInstrumentations(isReadonly2, shallow); return (target, key, receiver) => { if (key === "__v_isReactive") { return !isReadonly2; } else if (key === "__v_isReadonly") { return isReadonly2; } else if (key === "__v_raw") { return target; } return Reflect.get( hasOwn(instrumentations, key) && key in target ? instrumentations : target, key, receiver ); }; } var mutableCollectionHandlers = { get: createInstrumentationGetter(false, false) }; var shallowCollectionHandlers = { get: createInstrumentationGetter(false, true) }; var readonlyCollectionHandlers = { get: createInstrumentationGetter(true, false) }; var shallowReadonlyCollectionHandlers = { get: createInstrumentationGetter(true, true) }; function checkIdentityKeys(target, has, key) { const rawKey = toRaw(key); if (rawKey !== key && has.call(target, rawKey)) { const type = toRawType(target); warn( `Reactive ${type} contains both the raw and reactive versions of the same object${type === `Map` ? ` as keys` : ``}, which can lead to inconsistencies. Avoid differentiating between the raw and reactive versions of an object and only use the reactive version if possible.` ); } } var reactiveMap = /* @__PURE__ */ new WeakMap(); var shallowReactiveMap = /* @__PURE__ */ new WeakMap(); var readonlyMap = /* @__PURE__ */ new WeakMap(); var shallowReadonlyMap = /* @__PURE__ */ new WeakMap(); function targetTypeMap(rawType) { switch (rawType) { case "Object": case "Array": return 1; case "Map": case "Set": case "WeakMap": case "WeakSet": return 2; default: return 0; } } function getTargetType(value) { return value["__v_skip"] || !Object.isExtensible(value) ? 0 : targetTypeMap(toRawType(value)); } function reactive(target) { if (isReadonly(target)) { return target; } return createReactiveObject( target, false, mutableHandlers, mutableCollectionHandlers, reactiveMap ); } function shallowReactive(target) { return createReactiveObject( target, false, shallowReactiveHandlers, shallowCollectionHandlers, shallowReactiveMap ); } function readonly(target) { return createReactiveObject( target, true, readonlyHandlers, readonlyCollectionHandlers, readonlyMap ); } function shallowReadonly(target) { return createReactiveObject( target, true, shallowReadonlyHandlers, shallowReadonlyCollectionHandlers, shallowReadonlyMap ); } function createReactiveObject(target, isReadonly2, baseHandlers, collectionHandlers, proxyMap) { if (!isObject(target)) { if (true) { warn( `value cannot be made ${isReadonly2 ? "readonly" : "reactive"}: ${String( target )}` ); } return target; } if (target["__v_raw"] && !(isReadonly2 && target["__v_isReactive"])) { return target; } const existingProxy = proxyMap.get(target); if (existingProxy) { return existingProxy; } const targetType = getTargetType(target); if (targetType === 0) { return target; } const proxy = new Proxy( target, targetType === 2 ? collectionHandlers : baseHandlers ); proxyMap.set(target, proxy); return proxy; } function isReactive(value) { if (isReadonly(value)) { return isReactive(value["__v_raw"]); } return !!(value && value["__v_isReactive"]); } function isReadonly(value) { return !!(value && value["__v_isReadonly"]); } function isShallow(value) { return !!(value && value["__v_isShallow"]); } function isProxy(value) { return value ? !!value["__v_raw"] : false; } function toRaw(observed) { const raw = observed && observed["__v_raw"]; return raw ? toRaw(raw) : observed; } function markRaw(value) { if (!hasOwn(value, "__v_skip") && Object.isExtensible(value)) { def(value, "__v_skip", true); } return value; } var toReactive = (value) => isObject(value) ? reactive(value) : value; var toReadonly = (value) => isObject(value) ? readonly(value) : value; function isRef2(r) { return r ? r["__v_isRef"] === true : false; } function ref(value) { return createRef(value, false); } function shallowRef(value) { return createRef(value, true); } function createRef(rawValue, shallow) { if (isRef2(rawValue)) { return rawValue; } return new RefImpl(rawValue, shallow); } var RefImpl = class { constructor(value, isShallow2) { this.dep = new Dep(); this["__v_isRef"] = true; this["__v_isShallow"] = false; this._rawValue = isShallow2 ? value : toRaw(value); this._value = isShallow2 ? value : toReactive(value); this["__v_isShallow"] = isShallow2; } get value() { if (true) { this.dep.track({ target: this, type: "get", key: "value" }); } else { this.dep.track(); } return this._value; } set value(newValue) { const oldValue = this._rawValue; const useDirectValue = this["__v_isShallow"] || isShallow(newValue) || isReadonly(newValue); newValue = useDirectValue ? newValue : toRaw(newValue); if (hasChanged(newValue, oldValue)) { this._rawValue = newValue; this._value = useDirectValue ? newValue : toReactive(newValue); if (true) { this.dep.trigger({ target: this, type: "set", key: "value", newValue, oldValue }); } else { this.dep.trigger(); } } } }; function triggerRef(ref2) { if (ref2.dep) { if (true) { ref2.dep.trigger({ target: ref2, type: "set", key: "value", newValue: ref2._value }); } else { ref2.dep.trigger(); } } } function unref(ref2) { return isRef2(ref2) ? ref2.value : ref2; } function toValue(source) { return isFunction(source) ? source() : unref(source); } var shallowUnwrapHandlers = { get: (target, key, receiver) => key === "__v_raw" ? target : unref(Reflect.get(target, key, receiver)), set: (target, key, value, receiver) => { const oldValue = target[key]; if (isRef2(oldValue) && !isRef2(value)) { oldValue.value = value; return true; } else { return Reflect.set(target, key, value, receiver); } } }; function proxyRefs(objectWithRefs) { return isReactive(objectWithRefs) ? objectWithRefs : new Proxy(objectWithRefs, shallowUnwrapHandlers); } var CustomRefImpl = class { constructor(factory) { this["__v_isRef"] = true; this._value = void 0; const dep = this.dep = new Dep(); const { get, set } = factory(dep.track.bind(dep), dep.trigger.bind(dep)); this._get = get; this._set = set; } get value() { return this._value = this._get(); } set value(newVal) { this._set(newVal); } }; function customRef(factory) { return new CustomRefImpl(factory); } function toRefs(object) { if (!isProxy(object)) { warn(`toRefs() expects a reactive object but received a plain one.`); } const ret = isArray(object) ? new Array(object.length) : {}; for (const key in object) { ret[key] = propertyToRef(object, key); } return ret; } var ObjectRefImpl = class { constructor(_object, _key, _defaultValue) { this._object = _object; this._key = _key; this._defaultValue = _defaultValue; this["__v_isRef"] = true; this._value = void 0; } get value() { const val = this._object[this._key]; return this._value = val === void 0 ? this._defaultValue : val; } set value(newVal) { this._object[this._key] = newVal; } get dep() { return getDepFromReactive(toRaw(this._object), this._key); } }; var GetterRefImpl = class { constructor(_getter) { this._getter = _getter; this["__v_isRef"] = true; this["__v_isReadonly"] = true; this._value = void 0; } get value() { return this._value = this._getter(); } }; function toRef(source, key, defaultValue) { if (isRef2(source)) { return source; } else if (isFunction(source)) { return new GetterRefImpl(source); } else if (isObject(source) && arguments.length > 1) { return propertyToRef(source, key, defaultValue); } else { return ref(source); } } function propertyToRef(source, key, defaultValue) { const val = source[key]; return isRef2(val) ? val : new ObjectRefImpl(source, key, defaultValue); } var ComputedRefImpl = class { constructor(fn, setter, isSSR) { this.fn = fn; this.setter = setter; this._value = void 0; this.dep = new Dep(this); this.__v_isRef = true; this.deps = void 0; this.depsTail = void 0; this.flags = 16; this.globalVersion = globalVersion - 1; this.next = void 0; this.effect = this; this["__v_isReadonly"] = !setter; this.isSSR = isSSR; } /** * @internal */ notify() { this.flags |= 16; if (!(this.flags & 8) && // avoid infinite self recursion activeSub !== this) { batch(this, true); return true; } else if (true) ; } get value() { const link = true ? this.dep.track({ target: this, type: "get", key: "value" }) : this.dep.track(); refreshComputed(this); if (link) { link.version = this.dep.version; } return this._value; } set value(newValue) { if (this.setter) { this.setter(newValue); } else if (true) { warn("Write operation failed: computed value is readonly"); } } }; function computed(getterOrOptions, debugOptions, isSSR = false) { let getter; let setter; if (isFunction(getterOrOptions)) { getter = getterOrOptions; } else { getter = getterOrOptions.get; setter = getterOrOptions.set; } const cRef = new ComputedRefImpl(getter, setter, isSSR); if (debugOptions && !isSSR) { cRef.onTrack = debugOptions.onTrack; cRef.onTrigger = debugOptions.onTrigger; } return cRef; } var TrackOpTypes = { "GET": "get", "HAS": "has", "ITERATE": "iterate" }; var TriggerOpTypes = { "SET": "set", "ADD": "add", "DELETE": "delete", "CLEAR": "clear" }; var INITIAL_WATCHER_VALUE = {}; var cleanupMap = /* @__PURE__ */ new WeakMap(); var activeWatcher = void 0; function getCurrentWatcher() { return activeWatcher; } function onWatcherCleanup(cleanupFn, failSilently = false, owner = activeWatcher) { if (owner) { let cleanups = cleanupMap.get(owner); if (!cleanups) cleanupMap.set(owner, cleanups = []); cleanups.push(cleanupFn); } else if (!failSilently) { warn( `onWatcherCleanup() was called when there was no active watcher to associate with.` ); } } function watch(source, cb, options = EMPTY_OBJ) { const { immediate, deep, once, scheduler, augmentJob, call } = options; const warnInvalidSource = (s) => { (options.onWarn || warn)( `Invalid watch source: `, s, `A watch source can only be a getter/effect function, a ref, a reactive object, or an array of these types.` ); }; const reactiveGetter = (source2) => { if (deep) return source2; if (isShallow(source2) || deep === false || deep === 0) return traverse(source2, 1); return traverse(source2); }; let effect2; let getter; let cleanup; let boundCleanup; let forceTrigger = false; let isMultiSource = false; if (isRef2(source)) { getter = () => source.value; forceTrigger = isShallow(source); } else if (isReactive(source)) { getter = () => reactiveGetter(source); forceTrigger = true; } else if (isArray(source)) { isMultiSource = true; forceTrigger = source.some((s) => isReactive(s) || isShallow(s)); getter = () => source.map((s) => { if (isRef2(s)) { return s.value; } else if (isReactive(s)) { return reactiveGetter(s); } else if (isFunction(s)) { return call ? call(s, 2) : s(); } else { warnInvalidSource(s); } }); } else if (isFunction(source)) { if (cb) { getter = call ? () => call(source, 2) : source; } else { getter = () => { if (cleanup) { pauseTracking(); try { cleanup(); } finally { resetTracking(); } } const currentEffect = activeWatcher; activeWatcher = effect2; try { return call ? call(source, 3, [boundCleanup]) : source(boundCleanup); } finally { activeWatcher = currentEffect; } }; } } else { getter = NOOP; warnInvalidSource(source); } if (cb && deep) { const baseGetter = getter; const depth = deep === true ? Infinity : deep; getter = () => traverse(baseGetter(), depth); } const scope = getCurrentScope(); const watchHandle = () => { effect2.stop(); if (scope && scope.active) { remove(scope.effects, effect2); } }; if (once && cb) { const _cb = cb; cb = (...args) => { _cb(...args); watchHandle(); }; } let oldValue = isMultiSource ? new Array(source.length).fill(INITIAL_WATCHER_VALUE) : INITIAL_WATCHER_VALUE; const job = (immediateFirstRun) => { if (!(effect2.flags & 1) || !effect2.dirty && !immediateFirstRun) { return; } if (cb) { const newValue = effect2.run(); if (deep || forceTrigger || (isMultiSource ? newValue.some((v, i) => hasChanged(v, oldValue[i])) : hasChanged(newValue, oldValue))) { if (cleanup) { cleanup(); } const currentWatcher = activeWatcher; activeWatcher = effect2; try { const args = [ newValue, // pass undefined as the old value when it's changed for the first time oldValue === INITIAL_WATCHER_VALUE ? void 0 : isMultiSource && oldValue[0] === INITIAL_WATCHER_VALUE ? [] : oldValue, boundCleanup ]; call ? call(cb, 3, args) : ( // @ts-expect-error cb(...args) ); oldValue = newValue; } finally { activeWatcher = currentWatcher; } } } else { effect2.run(); } }; if (augmentJob) { augmentJob(job); } effect2 = new ReactiveEffect(getter); effect2.scheduler = scheduler ? () => scheduler(job, false) : job; boundCleanup = (fn) => onWatcherCleanup(fn, false, effect2); cleanup = effect2.onStop = () => { const cleanups = cleanupMap.get(effect2); if (cleanups) { if (call) { call(cleanups, 4); } else { for (const cleanup2 of cleanups) cleanup2(); } cleanupMap.delete(effect2); } }; if (true) { effect2.onTrack = options.onTrack; effect2.onTrigger = options.onTrigger; } if (cb) { if (immediate) { job(true); } else { oldValue = effect2.run(); } } else if (scheduler) { scheduler(job.bind(null, true), true); } else { effect2.run(); } watchHandle.pause = effect2.pause.bind(effect2); watchHandle.resume = effect2.resume.bind(effect2); watchHandle.stop = watchHandle; return watchHandle; } function traverse(value, depth = Infinity, seen) { if (depth <= 0 || !isObject(value) || value["__v_skip"]) { return value; } seen = seen || /* @__PURE__ */ new Set(); if (seen.has(value)) { return value; } seen.add(value); depth--; if (isRef2(value)) { traverse(value.value, depth, seen); } else if (isArray(value)) { for (let i = 0; i < value.length; i++) { traverse(value[i], depth, seen); } } else if (isSet(value) || isMap(value)) { value.forEach((v) => { traverse(v, depth, seen); }); } else if (isPlainObject(value)) { for (const key in value) { traverse(value[key], depth, seen); } for (const key of Object.getOwnPropertySymbols(value)) { if (Object.prototype.propertyIsEnumerable.call(value, key)) { traverse(value[key], depth, seen); } } } return value; } // node_modules/.pnpm/@vue+runtime-core@3.5.13/node_modules/@vue/runtime-core/dist/runtime-core.esm-bundler.js var stack = []; function pushWarningContext(vnode) { stack.push(vnode); } function popWarningContext() { stack.pop(); } var isWarning = false; function warn$1(msg, ...args) { if (isWarning) return; isWarning = true; pauseTracking(); const instance = stack.length ? stack[stack.length - 1].component : null; const appWarnHandler = instance && instance.appContext.config.warnHandler; const trace = getComponentTrace(); if (appWarnHandler) { callWithErrorHandling( appWarnHandler, instance, 11, [ // eslint-disable-next-line no-restricted-syntax msg + args.map((a) => { var _a, _b; return (_b = (_a = a.toString) == null ? void 0 : _a.call(a)) != null ? _b : JSON.stringify(a); }).join(""), instance && instance.proxy, trace.map( ({ vnode }) => `at <${formatComponentName(instance, vnode.type)}>` ).join("\n"), trace ] ); } else { const warnArgs = [`[Vue warn]: ${msg}`, ...args]; if (trace.length && // avoid spamming console during tests true) { warnArgs.push(` `, ...formatTrace(trace)); } console.warn(...warnArgs); } resetTracking(); isWarning = false; } function getComponentTrace() { let currentVNode = stack[stack.length - 1]; if (!currentVNode) { return []; } const normalizedStack = []; while (currentVNode) { const last = normalizedStack[0]; if (last && last.vnode === currentVNode) { last.recurseCount++; } else { normalizedStack.push({ vnode: currentVNode, recurseCount: 0 }); } const parentInstance = currentVNode.component && currentVNode.component.parent; currentVNode = parentInstance && parentInstance.vnode; } return normalizedStack; } function formatTrace(trace) { const logs = []; trace.forEach((entry, i) => { logs.push(...i === 0 ? [] : [` `], ...formatTraceEntry(entry)); }); return logs; } function formatTraceEntry({ vnode, recurseCount }) { const postfix = recurseCount > 0 ? `... (${recurseCount} recursive calls)` : ``; const isRoot = vnode.component ? vnode.component.parent == null : false; const open = ` at <${formatComponentName( vnode.component, vnode.type, isRoot )}`; const close = `>` + postfix; return vnode.props ? [open, ...formatProps(vnode.props), close] : [open + close]; } function formatProps(props) { const res = []; const keys = Object.keys(props); keys.slice(0, 3).forEach((key) => { res.push(...formatProp(key, props[key])); }); if (keys.length > 3) { res.push(` ...`); } return res; } function formatProp(key, value, raw) { if (isString(value)) { value = JSON.stringify(value); return raw ? value : [`${key}=${value}`]; } else if (typeof value === "number" || typeof value === "boolean" || value == null) { return raw ? value : [`${key}=${value}`]; } else if (isRef2(value)) { value = formatProp(key, toRaw(value.value), true); return raw ? value : [`${key}=Ref<`, value, `>`]; } else if (isFunction(value)) { return [`${key}=fn${value.name ? `<${value.name}>` : ``}`]; } else { value = toRaw(value); return raw ? value : [`${key}=`, value]; } } function assertNumber(val, type) { if (false) return; if (val === void 0) { return; } else if (typeof val !== "number") { warn$1(`${type} is not a valid number - got ${JSON.stringify(val)}.`); } else if (isNaN(val)) { warn$1(`${type} is NaN - the duration expression might be incorrect.`); } } var ErrorCodes = { "SETUP_FUNCTION": 0, "0": "SETUP_FUNCTION", "RENDER_FUNCTION": 1, "1": "RENDER_FUNCTION", "NATIVE_EVENT_HANDLER": 5, "5": "NATIVE_EVENT_HANDLER", "COMPONENT_EVENT_HANDLER": 6, "6": "COMPONENT_EVENT_HANDLER", "VNODE_HOOK": 7, "7": "VNODE_HOOK", "DIRECTIVE_HOOK": 8, "8": "DIRECTIVE_HOOK", "TRANSITION_HOOK": 9, "9": "TRANSITION_HOOK", "APP_ERROR_HANDLER": 10, "10": "APP_ERROR_HANDLER", "APP_WARN_HANDLER": 11, "11": "APP_WARN_HANDLER", "FUNCTION_REF": 12, "12": "FUNCTION_REF", "ASYNC_COMPONENT_LOADER": 13, "13": "ASYNC_COMPONENT_LOADER", "SCHEDULER": 14, "14": "SCHEDULER", "COMPONENT_UPDATE": 15, "15": "COMPONENT_UPDATE", "APP_UNMOUNT_CLEANUP": 16, "16": "APP_UNMOUNT_CLEANUP" }; var ErrorTypeStrings$1 = { ["sp"]: "serverPrefetch hook", ["bc"]: "beforeCreate hook", ["c"]: "created hook", ["bm"]: "beforeMount hook", ["m"]: "mounted hook", ["bu"]: "beforeUpdate hook", ["u"]: "updated", ["bum"]: "beforeUnmount hook", ["um"]: "unmounted hook", ["a"]: "activated hook", ["da"]: "deactivated hook", ["ec"]: "errorCaptured hook", ["rtc"]: "renderTracked hook", ["rtg"]: "renderTriggered hook", [0]: "setup function", [1]: "render function", [2]: "watcher getter", [3]: "watcher callback", [4]: "watcher cleanup function", [5]: "native event handler", [6]: "component event handler", [7]: "vnode hook", [8]: "directive hook", [9]: "transition hook", [10]: "app errorHandler", [11]: "app warnHandler", [12]: "ref function", [13]: "async component loader", [14]: "scheduler flush", [15]: "component update", [16]: "app unmount cleanup function" }; function callWithErrorHandling(fn, instance, type, args) { try { return args ? fn(...args) : fn(); } catch (err) { handleError(err, instance, type); } } function callWithAsyncErrorHandling(fn, instance, type, args) { if (isFunction(fn)) { const res = callWithErrorHandling(fn, instance, type, args); if (res && isPromise(res)) { res.catch((err) => { handleError(err, instance, type); }); } return res; } if (isArray(fn)) { const values = []; for (let i = 0; i < fn.length; i++) { values.push(callWithAsyncErrorHandling(fn[i], instance, type, args)); } return values; } else if (true) { warn$1( `Invalid value type passed to callWithAsyncErrorHandling(): ${typeof fn}` ); } } function handleError(err, instance, type, throwInDev = true) { const contextVNode = instance ? instance.vnode : null; const { errorHandler, throwUnhandledErrorInProduction } = instance && instance.appContext.config || EMPTY_OBJ; if (instance) { let cur = instance.parent; const exposedInstance = instance.proxy; const errorInfo = true ? ErrorTypeStrings$1[type] : `https://vuejs.org/error-reference/#runtime-${type}`; while (cur) { const errorCapturedHooks = cur.ec; if (errorCapturedHooks) { for (let i = 0; i < errorCapturedHooks.length; i++) { if (errorCapturedHooks[i](err, exposedInstance, errorInfo) === false) { return; } } } cur = cur.parent; } if (errorHandler) { pauseTracking(); callWithErrorHandling(errorHandler, null, 10, [ err, exposedInstance, errorInfo ]); resetTracking(); return; } } logError(err, type, contextVNode, throwInDev, throwUnhandledErrorInProduction); } function logError(err, type, contextVNode, throwInDev = true, throwInProd = false) { if (true) { const info = ErrorTypeStrings$1[type]; if (contextVNode) { pushWarningContext(contextVNode); } warn$1(`Unhandled error${info ? ` during execution of ${info}` : ``}`); if (contextVNode) { popWarningContext(); } if (throwInDev) { throw err; } else { console.error(err); } } else if (throwInProd) { throw err; } else { console.error(err); } } var queue = []; var flushIndex = -1; var pendingPostFlushCbs = []; var activePostFlushCbs = null; var postFlushIndex = 0; var resolvedPromise = Promise.resolve(); var currentFlushPromise = null; var RECURSION_LIMIT = 100; function nextTick(fn) { const p2 = currentFlushPromise || resolvedPromise; return fn ? p2.then(this ? fn.bind(this) : fn) : p2; } function findInsertionIndex(id) { let start = flushIndex + 1; let end = queue.length; while (start < end) { const middle = start + end >>> 1; const middleJob = queue[middle]; const middleJobId = getId(middleJob); if (middleJobId < id || middleJobId === id && middleJob.flags & 2) { start = middle + 1; } else { end = middle; } } return start; } function queueJob(job) { if (!(job.flags & 1)) { const jobId = getId(job); const lastJob = queue[queue.length - 1]; if (!lastJob || // fast path when the job id is larger than the tail !(job.flags & 2) && jobId >= getId(lastJob)) { queue.push(job); } else { queue.splice(findInsertionIndex(jobId), 0, job); } job.flags |= 1; queueFlush(); } } function queueFlush() { if (!currentFlushPromise) { currentFlushPromise = resolvedPromise.then(flushJobs); } } function queuePostFlushCb(cb) { if (!isArray(cb)) { if (activePostFlushCbs && cb.id === -1) { activePostFlushCbs.splice(postFlushIndex + 1, 0, cb); } else if (!(cb.flags & 1)) { pendingPostFlushCbs.push(cb); cb.flags |= 1; } } else { pendingPostFlushCbs.push(...cb); } queueFlush(); } function flushPreFlushCbs(instance, seen, i = flushIndex + 1) { if (true) { seen = seen || /* @__PURE__ */ new Map(); } for (; i < queue.length; i++) { const cb = queue[i]; if (cb && cb.flags & 2) { if (instance && cb.id !== instance.uid) { continue; } if (checkRecursiveUpdates(seen, cb)) { continue; } queue.splice(i, 1); i--; if (cb.flags & 4) { cb.flags &= ~1; } cb(); if (!(cb.flags & 4)) { cb.flags &= ~1; } } } } function flushPostFlushCbs(seen) { if (pendingPostFlushCbs.length) { const deduped = [...new Set(pendingPostFlushCbs)].sort( (a, b) => getId(a) - getId(b) ); pendingPostFlushCbs.length = 0; if (activePostFlushCbs) { activePostFlushCbs.push(...deduped); return; } activePostFlushCbs = deduped; if (true) { seen = seen || /* @__PURE__ */ new Map(); } for (postFlushIndex = 0; postFlushIndex < activePostFlushCbs.length; postFlushIndex++) { const cb = activePostFlushCbs[postFlushIndex]; if (checkRecursiveUpdates(seen, cb)) { continue; } if (cb.flags & 4) { cb.flags &= ~1; } if (!(cb.flags & 8)) cb(); cb.flags &= ~1; } activePostFlushCbs = null; postFlushIndex = 0; } } var getId = (job) => job.id == null ? job.flags & 2 ? -1 : Infinity : job.id; function flushJobs(seen) { if (true) { seen = seen || /* @__PURE__ */ new Map(); } const check = true ? (job) => checkRecursiveUpdates(seen, job) : NOOP; try { for (flushIndex = 0; flushIndex < queue.length; flushIndex++) { const job = queue[flushIndex]; if (job && !(job.flags & 8)) { if (check(job)) { continue; } if (job.flags & 4) { job.flags &= ~1; } callWithErrorHandling( job, job.i, job.i ? 15 : 14 ); if (!(job.flags & 4)) { job.flags &= ~1; } } } } finally { for (; flushIndex < queue.length; flushIndex++) { const job = queue[flushIndex]; if (job) { job.flags &= ~1; } } flushIndex = -1; queue.length = 0; flushPostFlushCbs(seen); currentFlushPromise = null; if (queue.length || pendingPostFlushCbs.length) { flushJobs(seen); } } } function checkRecursiveUpdates(seen, fn) { const count = seen.get(fn) || 0; if (count > RECURSION_LIMIT) { const instance = fn.i; const componentName = instance && getComponentName(instance.type); handleError( `Maximum recursive updates exceeded${componentName ? ` in component <${componentName}>` : ``}. This means you have a reactive effect that is mutating its own dependencies and thus recursively triggering itself. Possible sources include component template, render function, updated hook or watcher source function.`, null, 10 ); return true; } seen.set(fn, count + 1); return false; } var isHmrUpdating = false; var hmrDirtyComponents = /* @__PURE__ */ new Map(); if (true) { getGlobalThis().__VUE_HMR_RUNTIME__ = { createRecord: tryWrap(createRecord), rerender: tryWrap(rerender), reload: tryWrap(reload) }; } var map = /* @__PURE__ */ new Map(); function registerHMR(instance) { const id = instance.type.__hmrId; let record = map.get(id); if (!record) { createRecord(id, instance.type); record = map.get(id); } record.instances.add(instance); } function unregisterHMR(instance) { map.get(instance.type.__hmrId).instances.delete(instance); } function createRecord(id, initialDef) { if (map.has(id)) { return false; } map.set(id, { initialDef: normalizeClassComponent(initialDef), instances: /* @__PURE__ */ new Set() }); return true; } function normalizeClassComponent(component) { return isClassComponent(component) ? component.__vccOpts : component; } function rerender(id, newRender) { const record = map.get(id); if (!record) { return; } record.initialDef.render = newRender; [...record.instances].forEach((instance) => { if (newRender) { instance.render = newRender; normalizeClassComponent(instance.type).render = newRender; } instance.renderCache = []; isHmrUpdating = true; instance.update(); isHmrUpdating = false; }); } function reload(id, newComp) { const record = map.get(id); if (!record) return; newComp = normalizeClassComponent(newComp); updateComponentDef(record.initialDef, newComp); const instances = [...record.instances]; for (let i = 0; i < instances.length; i++) { const instance = instances[i]; const oldComp = normalizeClassComponent(instance.type); let dirtyInstances = hmrDirtyComponents.get(oldComp); if (!dirtyInstances) { if (oldComp !== record.initialDef) { updateComponentDef(oldComp, newComp); } hmrDirtyComponents.set(oldComp, dirtyInstances = /* @__PURE__ */ new Set()); } dirtyInstances.add(instance); instance.appContext.propsCache.delete(instance.type); instance.appContext.emitsCache.delete(instance.type); instance.appContext.optionsCache.delete(instance.type); if (instance.ceReload) { dirtyInstances.add(instance); instance.ceReload(newComp.styles); dirtyInstances.delete(instance); } else if (instance.parent) { queueJob(() => { isHmrUpdating = true; instance.parent.update(); isHmrUpdating = false; dirtyInstances.delete(instance); }); } else if (instance.appContext.reload) { instance.appContext.reload(); } else if (typeof window !== "undefined") { window.location.reload(); } else { console.warn( "[HMR] Root or manually mounted instance modified. Full reload required." ); } if (instance.root.ce && instance !== instance.root) { instance.root.ce._removeChildStyle(oldComp); } } queuePostFlushCb(() => { hmrDirtyComponents.clear(); }); } function updateComponentDef(oldComp, newComp) { extend(oldComp, newComp); for (const key in oldComp) { if (key !== "__file" && !(key in newComp)) { delete oldComp[key]; } } } function tryWrap(fn) { return (id, arg) => { try { return fn(id, arg); } catch (e) { console.error(e); console.warn( `[HMR] Something went wrong during Vue component hot-reload. Full reload required.` ); } }; } var devtools$1; var buffer = []; var devtoolsNotInstalled = false; function emit$1(event, ...args) { if (devtools$1) { devtools$1.emit(event, ...args); } else if (!devtoolsNotInstalled) { buffer.push({ event, args }); } } function setDevtoolsHook$1(hook, target) { var _a, _b; devtools$1 = hook; if (devtools$1) { devtools$1.enabled = true; buffer.forEach(({ event, args }) => devtools$1.emit(event, ...args)); buffer = []; } else if ( // handle late devtools injection - only do this if we are in an actual // browser environment to avoid the timer handle stalling test runner exit // (#4815) typeof window !== "undefined" && // some envs mock window but not fully window.HTMLElement && // also exclude jsdom // eslint-disable-next-line no-restricted-syntax !((_b = (_a = window.navigator) == null ? void 0 : _a.userAgent) == null ? void 0 : _b.includes("jsdom")) ) { const replay = target.__VUE_DEVTOOLS_HOOK_REPLAY__ = target.__VUE_DEVTOOLS_HOOK_REPLAY__ || []; replay.push((newHook) => { setDevtoolsHook$1(newHook, target); }); setTimeout(() => { if (!devtools$1) { target.__VUE_DEVTOOLS_HOOK_REPLAY__ = null; devtoolsNotInstalled = true; buffer = []; } }, 3e3); } else { devtoolsNotInstalled = true; buffer = []; } } function devtoolsInitApp(app, version2) { emit$1("app:init", app, version2, { Fragment, Text, Comment, Static }); } function devtoolsUnmountApp(app) { emit$1("app:unmount", app); } var devtoolsComponentAdded = createDevtoolsComponentHook( "component:added" /* COMPONENT_ADDED */ ); var devtoolsComponentUpdated = createDevtoolsComponentHook( "component:updated" /* COMPONENT_UPDATED */ ); var _devtoolsComponentRemoved = createDevtoolsComponentHook( "component:removed" /* COMPONENT_REMOVED */ ); var devtoolsComponentRemoved = (component) => { if (devtools$1 && typeof devtools$1.cleanupBuffer === "function" && // remove the component if it wasn't buffered !devtools$1.cleanupBuffer(component)) { _devtoolsComponentRemoved(component); } }; function createDevtoolsComponentHook(hook) { return (component) => { emit$1( hook, component.appContext.app, component.uid, component.parent ? component.parent.uid : void 0, component ); }; } var devtoolsPerfStart = createDevtoolsPerformanceHook( "perf:start" /* PERFORMANCE_START */ ); var devtoolsPerfEnd = createDevtoolsPerformanceHook( "perf:end" /* PERFORMANCE_END */ ); function createDevtoolsPerformanceHook(hook) { return (component, type, time) => { emit$1(hook, component.appContext.app, component.uid, component, type, time); }; } function devtoolsComponentEmit(component, event, params) { emit$1( "component:emit", component.appContext.app, component, event, params ); } var currentRenderingInstance = null; var currentScopeId = null; function setCurrentRenderingInstance(instance) { const prev = currentRenderingInstance; currentRenderingInstance = instance; currentScopeId = instance && instance.type.__scopeId || null; return prev; } function pushScopeId(id) { currentScopeId = id; } function popScopeId() { currentScopeId = null; } var withScopeId = (_id) => withCtx; function withCtx(fn, ctx = currentRenderingInstance, isNonScopedSlot) { if (!ctx) return fn; if (fn._n) { return fn; } const renderFnWithContext = (...args) => { if (renderFnWithContext._d) { setBlockTracking(-1); } const prevInstance = setCurrentRenderingInstance(ctx); let res; try { res = fn(...args); } finally { setCurrentRenderingInstance(prevInstance); if (renderFnWithContext._d) { setBlockTracking(1); } } if (true) { devtoolsComponentUpdated(ctx); } return res; }; renderFnWithContext._n = true; renderFnWithContext._c = true; renderFnWithContext._d = true; return renderFnWithContext; } function validateDirectiveName(name) { if (isBuiltInDirective(name)) { warn$1("Do not use built-in directive ids as custom directive id: " + name); } } function withDirectives(vnode, directives) { if (currentRenderingInstance === null) { warn$1(`withDirectives can only be used inside render functions.`); return vnode; } const instance = getComponentPublicInstance(currentRenderingInstance); const bindings = vnode.dirs || (vnode.dirs = []); for (let i = 0; i < directives.length; i++) { let [dir, value, arg, modifiers = EMPTY_OBJ] = directives[i]; if (dir) { if (isFunction(dir)) { dir = { mounted: dir, updated: dir }; } if (dir.deep) { traverse(value); } bindings.push({ dir, instance, value, oldValue: void 0, arg, modifiers }); } } return vnode; } function invokeDirectiveHook(vnode, prevVNode, instance, name) { const bindings = vnode.dirs; const oldBindings = prevVNode && prevVNode.dirs; for (let i = 0; i < bindings.length; i++) { const binding = bindings[i]; if (oldBindings) { binding.oldValue = oldBindings[i].value; } let hook = binding.dir[name]; if (hook) { pauseTracking(); callWithAsyncErrorHandling(hook, instance, 8, [ vnode.el, binding, vnode, prevVNode ]); resetTracking(); } } } var TeleportEndKey = Symbol("_vte"); var isTeleport = (type) => type.__isTeleport; var isTeleportDisabled = (props) => props && (props.disabled || props.disabled === ""); var isTeleportDeferred = (props) => props && (props.defer || props.defer === ""); var isTargetSVG = (target) => typeof SVGElement !== "undefined" && target instanceof SVGElement; var isTargetMathML = (target) => typeof MathMLElement === "function" && target instanceof MathMLElement; var resolveTarget = (props, select) => { const targetSelector = props && props.to; if (isString(targetSelector)) { if (!select) { warn$1( `Current renderer does not support string target for Teleports. (missing querySelector renderer option)` ); return null; } else { const target = select(targetSelector); if (!target && !isTeleportDisabled(props)) { warn$1( `Failed to locate Teleport target with selector "${targetSelector}". Note the target element must exist before the component is mounted - i.e. the target cannot be rendered by the component itself, and ideally should be outside of the entire Vue component tree.` ); } return target; } } else { if (!targetSelector && !isTeleportDisabled(props)) { warn$1(`Invalid Teleport target: ${targetSelector}`); } return targetSelector; } }; var TeleportImpl = { name: "Teleport", __isTeleport: true, process(n1, n2, container, anchor, parentComponent, parentSuspense, namespace, slotScopeIds, optimized, internals) { const { mc: mountChildren, pc: patchChildren, pbc: patchBlockChildren, o: { insert, querySelector, createText, createComment } } = internals; const disabled = isTeleportDisabled(n2.props); let { shapeFlag, children, dynamicChildren } = n2; if (isHmrUpdating) { optimized = false; dynamicChildren = null; } if (n1 == null) { const placeholder = n2.el = true ? createComment("teleport start") : createText(""); const mainAnchor = n2.anchor = true ? createComment("teleport end") : createText(""); insert(placeholder, container, anchor); insert(mainAnchor, container, anchor); const mount = (container2, anchor2) => { if (shapeFlag & 16) { if (parentComponent && parentComponent.isCE) { parentComponent.ce._teleportTarget = container2; } mountChildren( children, container2, anchor2, parentComponent, parentSuspense, namespace, slotScopeIds, optimized ); } }; const mountToTarget = () => { const target = n2.target = resolveTarget(n2.props, querySelector); const targetAnchor = prepareAnchor(target, n2, createText, insert); if (target) { if (namespace !== "svg" && isTargetSVG(target)) { namespace = "svg"; } else if (namespace !== "mathml" && isTargetMathML(target)) { namespace = "mathml"; } if (!disabled) { mount(target, targetAnchor); updateCssVars(n2, false); } } else if (!disabled) { warn$1( "Invalid Teleport target on mount:", target, `(${typeof target})` ); } }; if (disabled) { mount(container, mainAnchor); updateCssVars(n2, true); } if (isTeleportDeferred(n2.props)) { queuePostRenderEffect(() => { mountToTarget(); n2.el.__isMounted = true; }, parentSuspense); } else { mountToTarget(); } } else { if (isTeleportDeferred(n2.props) && !n1.el.__isMounted) { queuePostRenderEffect(() => { TeleportImpl.process( n1, n2, container, anchor, parentComponent, parentSuspense, namespace, slotScopeIds, optimized, internals ); delete n1.el.__isMounted; }, parentSuspense); return; } n2.el = n1.el; n2.targetStart = n1.targetStart; const mainAnchor = n2.anchor = n1.anchor; const target = n2.target = n1.target; const targetAnchor = n2.targetAnchor = n1.targetAnchor; const wasDisabled = isTeleportDisabled(n1.props); const currentContainer = wasDisabled ? container : target; const currentAnchor = wasDisabled ? mainAnchor : targetAnchor; if (namespace === "svg" || isTargetSVG(target)) { namespace = "svg"; } else if (namespace === "mathml" || isTargetMathML(target)) { namespace = "mathml"; } if (dynamicChildren) { patchBlockChildren( n1.dynamicChildren, dynamicChildren, currentContainer, parentComponent, parentSuspense, namespace, slotScopeIds ); traverseStaticChildren(n1, n2, true); } else if (!optimized) { patchChildren( n1, n2, currentContainer, currentAnchor, parentComponent, parentSuspense, namespace, slotScopeIds, false ); } if (disabled) { if (!wasDisabled) { moveTeleport( n2, container, mainAnchor, internals, 1 ); } else { if (n2.props && n1.props && n2.props.to !== n1.props.to) { n2.props.to = n1.props.to; } } } else { if ((n2.props && n2.props.to) !== (n1.props && n1.props.to)) { const nextTarget = n2.target = resolveTarget( n2.props, querySelector ); if (nextTarget) { moveTeleport( n2, nextTarget, null, internals, 0 ); } else if (true) { warn$1( "Invalid Teleport target on update:", target, `(${typeof target})` ); } } else if (wasDisabled) { moveTeleport( n2, target, targetAnchor, internals, 1 ); } } updateCssVars(n2, disabled); } }, remove(vnode, parentComponent, parentSuspense, { um: unmount, o: { remove: hostRemove } }, doRemove) { const { shapeFlag, children, anchor, targetStart, targetAnchor, target, props } = vnode; if (target) { hostRemove(targetStart); hostRemove(targetAnchor); } doRemove && hostRemove(anchor); if (shapeFlag & 16) { const shouldRemove = doRemove || !isTeleportDisabled(props); for (let i = 0; i < children.length; i++) { const child = children[i]; unmount( child, parentComponent, parentSuspense, shouldRemove, !!child.dynamicChildren ); } } }, move: moveTeleport, hydrate: hydrateTeleport }; function moveTeleport(vnode, container, parentAnchor, { o: { insert }, m: move }, moveType = 2) { if (moveType === 0) { insert(vnode.targetAnchor, container, parentAnchor); } const { el, anchor, shapeFlag, children, props } = vnode; const isReorder = moveType === 2; if (isReorder) { insert(el, container, parentAnchor); } if (!isReorder || isTeleportDisabled(props)) { if (shapeFlag & 16) { for (let i = 0; i < children.length; i++) { move( children[i], container, parentAnchor, 2 ); } } } if (isReorder) { insert(anchor, container, parentAnchor); } } function hydrateTeleport(node, vnode, parentComponent, parentSuspense, slotScopeIds, optimized, { o: { nextSibling, parentNode, querySelector, insert, createText } }, hydrateChildren) { const target = vnode.target = resolveTarget( vnode.props, querySelector ); if (target) { const disabled = isTeleportDisabled(vnode.props); const targetNode = target._lpa || target.firstChild; if (vnode.shapeFlag & 16) { if (disabled) { vnode.anchor = hydrateChildren( nextSibling(node), vnode, parentNode(node), parentComponent, parentSuspense, slotScopeIds, optimized ); vnode.targetStart = targetNode; vnode.targetAnchor = targetNode && nextSibling(targetNode); } else { vnode.anchor = nextSibling(node); let targetAnchor = targetNode; while (targetAnchor) { if (targetAnchor && targetAnchor.nodeType === 8) { if (targetAnchor.data === "teleport start anchor") { vnode.targetStart = targetAnchor; } else if (targetAnchor.data === "teleport anchor") { vnode.targetAnchor = targetAnchor; target._lpa = vnode.targetAnchor && nextSibling(vnode.targetAnchor); break; } } targetAnchor = nextSibling(targetAnchor); } if (!vnode.targetAnchor) { prepareAnchor(target, vnode, createText, insert); } hydrateChildren( targetNode && nextSibling(targetNode), vnode, target, parentComponent, parentSuspense, slotScopeIds, optimized ); } } updateCssVars(vnode, disabled); } return vnode.anchor && nextSibling(vnode.anchor); } var Teleport = TeleportImpl; function updateCssVars(vnode, isDisabled) { const ctx = vnode.ctx; if (ctx && ctx.ut) { let node, anchor; if (isDisabled) { node = vnode.el; anchor = vnode.anchor; } else { node = vnode.targetStart; anchor = vnode.targetAnchor; } while (node && node !== anchor) { if (node.nodeType === 1) node.setAttribute("data-v-owner", ctx.uid); node = node.nextSibling; } ctx.ut(); } } function prepareAnchor(target, vnode, createText, insert) { const targetStart = vnode.targetStart = createText(""); const targetAnchor = vnode.targetAnchor = createText(""); targetStart[TeleportEndKey] = targetAnchor; if (target) { insert(targetStart, target); insert(targetAnchor, target); } return targetAnchor; } var leaveCbKey = Symbol("_leaveCb"); var enterCbKey = Symbol("_enterCb"); function useTransitionState() { const state = { isMounted: false, isLeaving: false, isUnmounting: false, leavingVNodes: /* @__PURE__ */ new Map() }; onMounted(() => { state.isMounted = true; }); onBeforeUnmount(() => { state.isUnmounting = true; }); return state; } var TransitionHookValidator = [Function, Array]; var BaseTransitionPropsValidators = { mode: String, appear: Boolean, persisted: Boolean, // enter onBeforeEnter: TransitionHookValidator, onEnter: TransitionHookValidator, onAfterEnter: TransitionHookValidator, onEnterCancelled: TransitionHookValidator, // leave onBeforeLeave: TransitionHookValidator, onLeave: TransitionHookValidator, onAfterLeave: TransitionHookValidator, onLeaveCancelled: TransitionHookValidator, // appear onBeforeAppear: TransitionHookValidator, onAppear: TransitionHookValidator, onAfterAppear: TransitionHookValidator, onAppearCancelled: TransitionHookValidator }; var recursiveGetSubtree = (instance) => { const subTree = instance.subTree; return subTree.component ? recursiveGetSubtree(subTree.component) : subTree; }; var BaseTransitionImpl = { name: `BaseTransition`, props: BaseTransitionPropsValidators, setup(props, { slots }) { const instance = getCurrentInstance(); const state = useTransitionState(); return () => { const children = slots.default && getTransitionRawChildren(slots.default(), true); if (!children || !children.length) { return; } const child = findNonCommentChild(children); const rawProps = toRaw(props); const { mode } = rawProps; if (mode && mode !== "in-out" && mode !== "out-in" && mode !== "default") { warn$1(`invalid mode: ${mode}`); } if (state.isLeaving) { return emptyPlaceholder(child); } const innerChild = getInnerChild$1(child); if (!innerChild) { return emptyPlaceholder(child); } let enterHooks = resolveTransitionHooks( innerChild, rawProps, state, instance, // #11061, ensure enterHooks is fresh after clone (hooks) => enterHooks = hooks ); if (innerChild.type !== Comment) { setTransitionHooks(innerChild, enterHooks); } let oldInnerChild = instance.subTree && getInnerChild$1(instance.subTree); if (oldInnerChild && oldInnerChild.type !== Comment && !isSameVNodeType(innerChild, oldInnerChild) && recursiveGetSubtree(instance).type !== Comment) { let leavingHooks = resolveTransitionHooks( oldInnerChild, rawProps, state, instance ); setTransitionHooks(oldInnerChild, leavingHooks); if (mode === "out-in" && innerChild.type !== Comment) { state.isLeaving = true; leavingHooks.afterLeave = () => { state.isLeaving = false; if (!(instance.job.flags & 8)) { instance.update(); } delete leavingHooks.afterLeave; oldInnerChild = void 0; }; return emptyPlaceholder(child); } else if (mode === "in-out" && innerChild.type !== Comment) { leavingHooks.delayLeave = (el, earlyRemove, delayedLeave) => { const leavingVNodesCache = getLeavingNodesForType( state, oldInnerChild ); leavingVNodesCache[String(oldInnerChild.key)] = oldInnerChild; el[leaveCbKey] = () => { earlyRemove(); el[leaveCbKey] = void 0; delete enterHooks.delayedLeave; oldInnerChild = void 0; }; enterHooks.delayedLeave = () => { delayedLeave(); delete enterHooks.delayedLeave; oldInnerChild = void 0; }; }; } else { oldInnerChild = void 0; } } else if (oldInnerChild) { oldInnerChild = void 0; } return child; }; } }; function findNonCommentChild(children) { let child = children[0]; if (children.length > 1) { let hasFound = false; for (const c of children) { if (c.type !== Comment) { if (hasFound) { warn$1( " can only be used on a single element or component. Use for lists." ); break; } child = c; hasFound = true; if (false) break; } } } return child; } var BaseTransition = BaseTransitionImpl; function getLeavingNodesForType(state, vnode) { const { leavingVNodes } = state; let leavingVNodesCache = leavingVNodes.get(vnode.type); if (!leavingVNodesCache) { leavingVNodesCache = /* @__PURE__ */ Object.create(null); leavingVNodes.set(vnode.type, leavingVNodesCache); } return leavingVNodesCache; } function resolveTransitionHooks(vnode, props, state, instance, postClone) { const { appear, mode, persisted = false, onBeforeEnter, onEnter, onAfterEnter, onEnterCancelled, onBeforeLeave, onLeave, onAfterLeave, onLeaveCancelled, onBeforeAppear, onAppear, onAfterAppear, onAppearCancelled } = props; const key = String(vnode.key); const leavingVNodesCache = getLeavingNodesForType(state, vnode); const callHook3 = (hook, args) => { hook && callWithAsyncErrorHandling( hook, instance, 9, args ); }; const callAsyncHook = (hook, args) => { const done = args[1]; callHook3(hook, args); if (isArray(hook)) { if (hook.every((hook2) => hook2.length <= 1)) done(); } else if (hook.length <= 1) { done(); } }; const hooks = { mode, persisted, beforeEnter(el) { let hook = onBeforeEnter; if (!state.isMounted) { if (appear) { hook = onBeforeAppear || onBeforeEnter; } else { return; } } if (el[leaveCbKey]) { el[leaveCbKey]( true /* cancelled */ ); } const leavingVNode = leavingVNodesCache[key]; if (leavingVNode && isSameVNodeType(vnode, leavingVNode) && leavingVNode.el[leaveCbKey]) { leavingVNode.el[leaveCbKey](); } callHook3(hook, [el]); }, enter(el) { let hook = onEnter; let afterHook = onAfterEnter; let cancelHook = onEnterCancelled; if (!state.isMounted) { if (appear) { hook = onAppear || onEnter; afterHook = onAfterAppear || onAfterEnter; cancelHook = onAppearCancelled || onEnterCancelled; } else { return; } } let called = false; const done = el[enterCbKey] = (cancelled) => { if (called) return; called = true; if (cancelled) { callHook3(cancelHook, [el]); } else { callHook3(afterHook, [el]); } if (hooks.delayedLeave) { hooks.delayedLeave(); } el[enterCbKey] = void 0; }; if (hook) { callAsyncHook(hook, [el, done]); } else { done(); } }, leave(el, remove2) { const key2 = String(vnode.key); if (el[enterCbKey]) { el[enterCbKey]( true /* cancelled */ ); } if (state.isUnmounting) { return remove2(); } callHook3(onBeforeLeave, [el]); let called = false; const done = el[leaveCbKey] = (cancelled) => { if (called) return; called = true; remove2(); if (cancelled) { callHook3(onLeaveCancelled, [el]); } else { callHook3(onAfterLeave, [el]); } el[leaveCbKey] = void 0; if (leavingVNodesCache[key2] === vnode) { delete leavingVNodesCache[key2]; } }; leavingVNodesCache[key2] = vnode; if (onLeave) { callAsyncHook(onLeave, [el, done]); } else { done(); } }, clone(vnode2) { const hooks2 = resolveTransitionHooks( vnode2, props, state, instance, postClone ); if (postClone) postClone(hooks2); return hooks2; } }; return hooks; } function emptyPlaceholder(vnode) { if (isKeepAlive(vnode)) { vnode = cloneVNode(vnode); vnode.children = null; return vnode; } } function getInnerChild$1(vnode) { if (!isKeepAlive(vnode)) { if (isTeleport(vnode.type) && vnode.children) { return findNonCommentChild(vnode.children); } return vnode; } if (vnode.component) { return vnode.component.subTree; } const { shapeFlag, children } = vnode; if (children) { if (shapeFlag & 16) { return children[0]; } if (shapeFlag & 32 && isFunction(children.default)) { return children.default(); } } } function setTransitionHooks(vnode, hooks) { if (vnode.shapeFlag & 6 && vnode.component) { vnode.transition = hooks; setTransitionHooks(vnode.component.subTree, hooks); } else if (vnode.shapeFlag & 128) { vnode.ssContent.transition = hooks.clone(vnode.ssContent); vnode.ssFallback.transition = hooks.clone(vnode.ssFallback); } else { vnode.transition = hooks; } } function getTransitionRawChildren(children, keepComment = false, parentKey) { let ret = []; let keyedFragmentCount = 0; for (let i = 0; i < children.length; i++) { let child = children[i]; const key = parentKey == null ? child.key : String(parentKey) + String(child.key != null ? child.key : i); if (child.type === Fragment) { if (child.patchFlag & 128) keyedFragmentCount++; ret = ret.concat( getTransitionRawChildren(child.children, keepComment, key) ); } else if (keepComment || child.type !== Comment) { ret.push(key != null ? cloneVNode(child, { key }) : child); } } if (keyedFragmentCount > 1) { for (let i = 0; i < ret.length; i++) { ret[i].patchFlag = -2; } } return ret; } function defineComponent(options, extraOptions) { return isFunction(options) ? ( // #8236: extend call and options.name access are considered side-effects // by Rollup, so we have to wrap it in a pure-annotated IIFE. (() => extend({ name: options.name }, extraOptions, { setup: options }))() ) : options; } function useId() { const i = getCurrentInstance(); if (i) { return (i.appContext.config.idPrefix || "v") + "-" + i.ids[0] + i.ids[1]++; } else if (true) { warn$1( `useId() is called when there is no active component instance to be associated with.` ); } return ""; } function markAsyncBoundary(instance) { instance.ids = [instance.ids[0] + instance.ids[2]++ + "-", 0, 0]; } var knownTemplateRefs = /* @__PURE__ */ new WeakSet(); function useTemplateRef(key) { const i = getCurrentInstance(); const r = shallowRef(null); if (i) { const refs = i.refs === EMPTY_OBJ ? i.refs = {} : i.refs; let desc; if ((desc = Object.getOwnPropertyDescriptor(refs, key)) && !desc.configurable) { warn$1(`useTemplateRef('${key}') already exists.`); } else { Object.defineProperty(refs, key, { enumerable: true, get: () => r.value, set: (val) => r.value = val }); } } else if (true) { warn$1( `useTemplateRef() is called when there is no active component instance to be associated with.` ); } const ret = true ? readonly(r) : r; if (true) { knownTemplateRefs.add(ret); } return ret; } function setRef(rawRef, oldRawRef, parentSuspense, vnode, isUnmount = false) { if (isArray(rawRef)) { rawRef.forEach( (r, i) => setRef( r, oldRawRef && (isArray(oldRawRef) ? oldRawRef[i] : oldRawRef), parentSuspense, vnode, isUnmount ) ); return; } if (isAsyncWrapper(vnode) && !isUnmount) { if (vnode.shapeFlag & 512 && vnode.type.__asyncResolved && vnode.component.subTree.component) { setRef(rawRef, oldRawRef, parentSuspense, vnode.component.subTree); } return; } const refValue = vnode.shapeFlag & 4 ? getComponentPublicInstance(vnode.component) : vnode.el; const value = isUnmount ? null : refValue; const { i: owner, r: ref2 } = rawRef; if (!owner) { warn$1( `Missing ref owner context. ref cannot be used on hoisted vnodes. A vnode with ref must be created inside the render function.` ); return; } const oldRef = oldRawRef && oldRawRef.r; const refs = owner.refs === EMPTY_OBJ ? owner.refs = {} : owner.refs; const setupState = owner.setupState; const rawSetupState = toRaw(setupState); const canSetSetupRef = setupState === EMPTY_OBJ ? () => false : (key) => { if (true) { if (hasOwn(rawSetupState, key) && !isRef2(rawSetupState[key])) { warn$1( `Template ref "${key}" used on a non-ref value. It will not work in the production build.` ); } if (knownTemplateRefs.has(rawSetupState[key])) { return false; } } return hasOwn(rawSetupState, key); }; if (oldRef != null && oldRef !== ref2) { if (isString(oldRef)) { refs[oldRef] = null; if (canSetSetupRef(oldRef)) { setupState[oldRef] = null; } } else if (isRef2(oldRef)) { oldRef.value = null; } } if (isFunction(ref2)) { callWithErrorHandling(ref2, owner, 12, [value, refs]); } else { const _isString = isString(ref2); const _isRef = isRef2(ref2); if (_isString || _isRef) { const doSet = () => { if (rawRef.f) { const existing = _isString ? canSetSetupRef(ref2) ? setupState[ref2] : refs[ref2] : ref2.value; if (isUnmount) { isArray(existing) && remove(existing, refValue); } else { if (!isArray(existing)) { if (_isString) { refs[ref2] = [refValue]; if (canSetSetupRef(ref2)) { setupState[ref2] = refs[ref2]; } } else { ref2.value = [refValue]; if (rawRef.k) refs[rawRef.k] = ref2.value; } } else if (!existing.includes(refValue)) { existing.push(refValue); } } } else if (_isString) { refs[ref2] = value; if (canSetSetupRef(ref2)) { setupState[ref2] = value; } } else if (_isRef) { ref2.value = value; if (rawRef.k) refs[rawRef.k] = value; } else if (true) { warn$1("Invalid template ref type:", ref2, `(${typeof ref2})`); } }; if (value) { doSet.id = -1; queuePostRenderEffect(doSet, parentSuspense); } else { doSet(); } } else if (true) { warn$1("Invalid template ref type:", ref2, `(${typeof ref2})`); } } } var hasLoggedMismatchError = false; var logMismatchError = () => { if (hasLoggedMismatchError) { return; } console.error("Hydration completed but contains mismatches."); hasLoggedMismatchError = true; }; var isSVGContainer = (container) => container.namespaceURI.includes("svg") && container.tagName !== "foreignObject"; var isMathMLContainer = (container) => container.namespaceURI.includes("MathML"); var getContainerType = (container) => { if (container.nodeType !== 1) return void 0; if (isSVGContainer(container)) return "svg"; if (isMathMLContainer(container)) return "mathml"; return void 0; }; var isComment = (node) => node.nodeType === 8; function createHydrationFunctions(rendererInternals) { const { mt: mountComponent, p: patch, o: { patchProp: patchProp2, createText, nextSibling, parentNode, remove: remove2, insert, createComment } } = rendererInternals; const hydrate2 = (vnode, container) => { if (!container.hasChildNodes()) { warn$1( `Attempting to hydrate existing markup but container is empty. Performing full mount instead.` ); patch(null, vnode, container); flushPostFlushCbs(); container._vnode = vnode; return; } hydrateNode(container.firstChild, vnode, null, null, null); flushPostFlushCbs(); container._vnode = vnode; }; const hydrateNode = (node, vnode, parentComponent, parentSuspense, slotScopeIds, optimized = false) => { optimized = optimized || !!vnode.dynamicChildren; const isFragmentStart = isComment(node) && node.data === "["; const onMismatch = () => handleMismatch( node, vnode, parentComponent, parentSuspense, slotScopeIds, isFragmentStart ); const { type, ref: ref2, shapeFlag, patchFlag } = vnode; let domType = node.nodeType; vnode.el = node; if (true) { def(node, "__vnode", vnode, true); def(node, "__vueParentComponent", parentComponent, true); } if (patchFlag === -2) { optimized = false; vnode.dynamicChildren = null; } let nextNode = null; switch (type) { case Text: if (domType !== 3) { if (vnode.children === "") { insert(vnode.el = createText(""), parentNode(node), node); nextNode = node; } else { nextNode = onMismatch(); } } else { if (node.data !== vnode.children) { warn$1( `Hydration text mismatch in`, node.parentNode, ` - rendered on server: ${JSON.stringify( node.data )} - expected on client: ${JSON.stringify(vnode.children)}` ); logMismatchError(); node.data = vnode.children; } nextNode = nextSibling(node); } break; case Comment: if (isTemplateNode(node)) { nextNode = nextSibling(node); replaceNode( vnode.el = node.content.firstChild, node, parentComponent ); } else if (domType !== 8 || isFragmentStart) { nextNode = onMismatch(); } else { nextNode = nextSibling(node); } break; case Static: if (isFragmentStart) { node = nextSibling(node); domType = node.nodeType; } if (domType === 1 || domType === 3) { nextNode = node; const needToAdoptContent = !vnode.children.length; for (let i = 0; i < vnode.staticCount; i++) { if (needToAdoptContent) vnode.children += nextNode.nodeType === 1 ? nextNode.outerHTML : nextNode.data; if (i === vnode.staticCount - 1) { vnode.anchor = nextNode; } nextNode = nextSibling(nextNode); } return isFragmentStart ? nextSibling(nextNode) : nextNode; } else { onMismatch(); } break; case Fragment: if (!isFragmentStart) { nextNode = onMismatch(); } else { nextNode = hydrateFragment( node, vnode, parentComponent, parentSuspense, slotScopeIds, optimized ); } break; default: if (shapeFlag & 1) { if ((domType !== 1 || vnode.type.toLowerCase() !== node.tagName.toLowerCase()) && !isTemplateNode(node)) { nextNode = onMismatch(); } else { nextNode = hydrateElement( node, vnode, parentComponent, parentSuspense, slotScopeIds, optimized ); } } else if (shapeFlag & 6) { vnode.slotScopeIds = slotScopeIds; const container = parentNode(node); if (isFragmentStart) { nextNode = locateClosingAnchor(node); } else if (isComment(node) && node.data === "teleport start") { nextNode = locateClosingAnchor(node, node.data, "teleport end"); } else { nextNode = nextSibling(node); } mountComponent( vnode, container, null, parentComponent, parentSuspense, getContainerType(container), optimized ); if (isAsyncWrapper(vnode) && !vnode.type.__asyncResolved) { let subTree; if (isFragmentStart) { subTree = createVNode(Fragment); subTree.anchor = nextNode ? nextNode.previousSibling : container.lastChild; } else { subTree = node.nodeType === 3 ? createTextVNode("") : createVNode("div"); } subTree.el = node; vnode.component.subTree = subTree; } } else if (shapeFlag & 64) { if (domType !== 8) { nextNode = onMismatch(); } else { nextNode = vnode.type.hydrate( node, vnode, parentComponent, parentSuspense, slotScopeIds, optimized, rendererInternals, hydrateChildren ); } } else if (shapeFlag & 128) { nextNode = vnode.type.hydrate( node, vnode, parentComponent, parentSuspense, getContainerType(parentNode(node)), slotScopeIds, optimized, rendererInternals, hydrateNode ); } else if (true) { warn$1("Invalid HostVNode type:", type, `(${typeof type})`); } } if (ref2 != null) { setRef(ref2, null, parentSuspense, vnode); } return nextNode; }; const hydrateElement = (el, vnode, parentComponent, parentSuspense, slotScopeIds, optimized) => { optimized = optimized || !!vnode.dynamicChildren; const { type, props, patchFlag, shapeFlag, dirs, transition } = vnode; const forcePatch = type === "input" || type === "option"; if (true) { if (dirs) { invokeDirectiveHook(vnode, null, parentComponent, "created"); } let needCallTransitionHooks = false; if (isTemplateNode(el)) { needCallTransitionHooks = needTransition( null, // no need check parentSuspense in hydration transition ) && parentComponent && parentComponent.vnode.props && parentComponent.vnode.props.appear; const content = el.content.firstChild; if (needCallTransitionHooks) { transition.beforeEnter(content); } replaceNode(content, el, parentComponent); vnode.el = el = content; } if (shapeFlag & 16 && // skip if element has innerHTML / textContent !(props && (props.innerHTML || props.textContent))) { let next = hydrateChildren( el.firstChild, vnode, el, parentComponent, parentSuspense, slotScopeIds, optimized ); let hasWarned2 = false; while (next) { if (!isMismatchAllowed( el, 1 /* CHILDREN */ )) { if (!hasWarned2) { warn$1( `Hydration children mismatch on`, el, ` Server rendered element contains more child nodes than client vdom.` ); hasWarned2 = true; } logMismatchError(); } const cur = next; next = next.nextSibling; remove2(cur); } } else if (shapeFlag & 8) { let clientText = vnode.children; if (clientText[0] === "\n" && (el.tagName === "PRE" || el.tagName === "TEXTAREA")) { clientText = clientText.slice(1); } if (el.textContent !== clientText) { if (!isMismatchAllowed( el, 0 /* TEXT */ )) { warn$1( `Hydration text content mismatch on`, el, ` - rendered on server: ${el.textContent} - expected on client: ${vnode.children}` ); logMismatchError(); } el.textContent = vnode.children; } } if (props) { if (true) { const isCustomElement = el.tagName.includes("-"); for (const key in props) { if (// #11189 skip if this node has directives that have created hooks // as it could have mutated the DOM in any possible way !(dirs && dirs.some((d) => d.dir.created)) && propHasMismatch(el, key, props[key], vnode, parentComponent)) { logMismatchError(); } if (forcePatch && (key.endsWith("value") || key === "indeterminate") || isOn(key) && !isReservedProp(key) || // force hydrate v-bind with .prop modifiers key[0] === "." || isCustomElement) { patchProp2(el, key, null, props[key], void 0, parentComponent); } } } else if (props.onClick) { patchProp2( el, "onClick", null, props.onClick, void 0, parentComponent ); } else if (patchFlag & 4 && isReactive(props.style)) { for (const key in props.style) props.style[key]; } } let vnodeHooks; if (vnodeHooks = props && props.onVnodeBeforeMount) { invokeVNodeHook(vnodeHooks, parentComponent, vnode); } if (dirs) { invokeDirectiveHook(vnode, null, parentComponent, "beforeMount"); } if ((vnodeHooks = props && props.onVnodeMounted) || dirs || needCallTransitionHooks) { queueEffectWithSuspense(() => { vnodeHooks && invokeVNodeHook(vnodeHooks, parentComponent, vnode); needCallTransitionHooks && transition.enter(el); dirs && invokeDirectiveHook(vnode, null, parentComponent, "mounted"); }, parentSuspense); } } return el.nextSibling; }; const hydrateChildren = (node, parentVNode, container, parentComponent, parentSuspense, slotScopeIds, optimized) => { optimized = optimized || !!parentVNode.dynamicChildren; const children = parentVNode.children; const l = children.length; let hasWarned2 = false; for (let i = 0; i < l; i++) { const vnode = optimized ? children[i] : children[i] = normalizeVNode(children[i]); const isText = vnode.type === Text; if (node) { if (isText && !optimized) { if (i + 1 < l && normalizeVNode(children[i + 1]).type === Text) { insert( createText( node.data.slice(vnode.children.length) ), container, nextSibling(node) ); node.data = vnode.children; } } node = hydrateNode( node, vnode, parentComponent, parentSuspense, slotScopeIds, optimized ); } else if (isText && !vnode.children) { insert(vnode.el = createText(""), container); } else { if (!isMismatchAllowed( container, 1 /* CHILDREN */ )) { if (!hasWarned2) { warn$1( `Hydration children mismatch on`, container, ` Server rendered element contains fewer child nodes than client vdom.` ); hasWarned2 = true; } logMismatchError(); } patch( null, vnode, container, null, parentComponent, parentSuspense, getContainerType(container), slotScopeIds ); } } return node; }; const hydrateFragment = (node, vnode, parentComponent, parentSuspense, slotScopeIds, optimized) => { const { slotScopeIds: fragmentSlotScopeIds } = vnode; if (fragmentSlotScopeIds) { slotScopeIds = slotScopeIds ? slotScopeIds.concat(fragmentSlotScopeIds) : fragmentSlotScopeIds; } const container = parentNode(node); const next = hydrateChildren( nextSibling(node), vnode, container, parentComponent, parentSuspense, slotScopeIds, optimized ); if (next && isComment(next) && next.data === "]") { return nextSibling(vnode.anchor = next); } else { logMismatchError(); insert(vnode.anchor = createComment(`]`), container, next); return next; } }; const handleMismatch = (node, vnode, parentComponent, parentSuspense, slotScopeIds, isFragment) => { if (!isMismatchAllowed( node.parentElement, 1 /* CHILDREN */ )) { warn$1( `Hydration node mismatch: - rendered on server:`, node, node.nodeType === 3 ? `(text)` : isComment(node) && node.data === "[" ? `(start of fragment)` : ``, ` - expected on client:`, vnode.type ); logMismatchError(); } vnode.el = null; if (isFragment) { const end = locateClosingAnchor(node); while (true) { const next2 = nextSibling(node); if (next2 && next2 !== end) { remove2(next2); } else { break; } } } const next = nextSibling(node); const container = parentNode(node); remove2(node); patch( null, vnode, container, next, parentComponent, parentSuspense, getContainerType(container), slotScopeIds ); if (parentComponent) { parentComponent.vnode.el = vnode.el; updateHOCHostEl(parentComponent, vnode.el); } return next; }; const locateClosingAnchor = (node, open = "[", close = "]") => { let match = 0; while (node) { node = nextSibling(node); if (node && isComment(node)) { if (node.data === open) match++; if (node.data === close) { if (match === 0) { return nextSibling(node); } else { match--; } } } } return node; }; const replaceNode = (newNode, oldNode, parentComponent) => { const parentNode2 = oldNode.parentNode; if (parentNode2) { parentNode2.replaceChild(newNode, oldNode); } let parent = parentComponent; while (parent) { if (parent.vnode.el === oldNode) { parent.vnode.el = parent.subTree.el = newNode; } parent = parent.parent; } }; const isTemplateNode = (node) => { return node.nodeType === 1 && node.tagName === "TEMPLATE"; }; return [hydrate2, hydrateNode]; } function propHasMismatch(el, key, clientValue, vnode, instance) { let mismatchType; let mismatchKey; let actual; let expected; if (key === "class") { actual = el.getAttribute("class"); expected = normalizeClass(clientValue); if (!isSetEqual(toClassSet(actual || ""), toClassSet(expected))) { mismatchType = 2; mismatchKey = `class`; } } else if (key === "style") { actual = el.getAttribute("style") || ""; expected = isString(clientValue) ? clientValue : stringifyStyle(normalizeStyle(clientValue)); const actualMap = toStyleMap(actual); const expectedMap = toStyleMap(expected); if (vnode.dirs) { for (const { dir, value } of vnode.dirs) { if (dir.name === "show" && !value) { expectedMap.set("display", "none"); } } } if (instance) { resolveCssVars(instance, vnode, expectedMap); } if (!isMapEqual(actualMap, expectedMap)) { mismatchType = 3; mismatchKey = "style"; } } else if (el instanceof SVGElement && isKnownSvgAttr(key) || el instanceof HTMLElement && (isBooleanAttr(key) || isKnownHtmlAttr(key))) { if (isBooleanAttr(key)) { actual = el.hasAttribute(key); expected = includeBooleanAttr(clientValue); } else if (clientValue == null) { actual = el.hasAttribute(key); expected = false; } else { if (el.hasAttribute(key)) { actual = el.getAttribute(key); } else if (key === "value" && el.tagName === "TEXTAREA") { actual = el.value; } else { actual = false; } expected = isRenderableAttrValue(clientValue) ? String(clientValue) : false; } if (actual !== expected) { mismatchType = 4; mismatchKey = key; } } if (mismatchType != null && !isMismatchAllowed(el, mismatchType)) { const format = (v) => v === false ? `(not rendered)` : `${mismatchKey}="${v}"`; const preSegment = `Hydration ${MismatchTypeString[mismatchType]} mismatch on`; const postSegment = ` - rendered on server: ${format(actual)} - expected on client: ${format(expected)} Note: this mismatch is check-only. The DOM will not be rectified in production due to performance overhead. You should fix the source of the mismatch.`; { warn$1(preSegment, el, postSegment); } return true; } return false; } function toClassSet(str) { return new Set(str.trim().split(/\s+/)); } function isSetEqual(a, b) { if (a.size !== b.size) { return false; } for (const s of a) { if (!b.has(s)) { return false; } } return true; } function toStyleMap(str) { const styleMap = /* @__PURE__ */ new Map(); for (const item of str.split(";")) { let [key, value] = item.split(":"); key = key.trim(); value = value && value.trim(); if (key && value) { styleMap.set(key, value); } } return styleMap; } function isMapEqual(a, b) { if (a.size !== b.size) { return false; } for (const [key, value] of a) { if (value !== b.get(key)) { return false; } } return true; } function resolveCssVars(instance, vnode, expectedMap) { const root = instance.subTree; if (instance.getCssVars && (vnode === root || root && root.type === Fragment && root.children.includes(vnode))) { const cssVars = instance.getCssVars(); for (const key in cssVars) { expectedMap.set( `--${getEscapedCssVarName(key, false)}`, String(cssVars[key]) ); } } if (vnode === root && instance.parent) { resolveCssVars(instance.parent, instance.vnode, expectedMap); } } var allowMismatchAttr = "data-allow-mismatch"; var MismatchTypeString = { [ 0 /* TEXT */ ]: "text", [ 1 /* CHILDREN */ ]: "children", [ 2 /* CLASS */ ]: "class", [ 3 /* STYLE */ ]: "style", [ 4 /* ATTRIBUTE */ ]: "attribute" }; function isMismatchAllowed(el, allowedType) { if (allowedType === 0 || allowedType === 1) { while (el && !el.hasAttribute(allowMismatchAttr)) { el = el.parentElement; } } const allowedAttr = el && el.getAttribute(allowMismatchAttr); if (allowedAttr == null) { return false; } else if (allowedAttr === "") { return true; } else { const list = allowedAttr.split(","); if (allowedType === 0 && list.includes("children")) { return true; } return allowedAttr.split(",").includes(MismatchTypeString[allowedType]); } } var requestIdleCallback = getGlobalThis().requestIdleCallback || ((cb) => setTimeout(cb, 1)); var cancelIdleCallback = getGlobalThis().cancelIdleCallback || ((id) => clearTimeout(id)); var hydrateOnIdle = (timeout = 1e4) => (hydrate2) => { const id = requestIdleCallback(hydrate2, { timeout }); return () => cancelIdleCallback(id); }; function elementIsVisibleInViewport(el) { const { top, left, bottom, right } = el.getBoundingClientRect(); const { innerHeight, innerWidth } = window; return (top > 0 && top < innerHeight || bottom > 0 && bottom < innerHeight) && (left > 0 && left < innerWidth || right > 0 && right < innerWidth); } var hydrateOnVisible = (opts) => (hydrate2, forEach) => { const ob = new IntersectionObserver((entries) => { for (const e of entries) { if (!e.isIntersecting) continue; ob.disconnect(); hydrate2(); break; } }, opts); forEach((el) => { if (!(el instanceof Element)) return; if (elementIsVisibleInViewport(el)) { hydrate2(); ob.disconnect(); return false; } ob.observe(el); }); return () => ob.disconnect(); }; var hydrateOnMediaQuery = (query) => (hydrate2) => { if (query) { const mql = matchMedia(query); if (mql.matches) { hydrate2(); } else { mql.addEventListener("change", hydrate2, { once: true }); return () => mql.removeEventListener("change", hydrate2); } } }; var hydrateOnInteraction = (interactions = []) => (hydrate2, forEach) => { if (isString(interactions)) interactions = [interactions]; let hasHydrated = false; const doHydrate = (e) => { if (!hasHydrated) { hasHydrated = true; teardown(); hydrate2(); e.target.dispatchEvent(new e.constructor(e.type, e)); } }; const teardown = () => { forEach((el) => { for (const i of interactions) { el.removeEventListener(i, doHydrate); } }); }; forEach((el) => { for (const i of interactions) { el.addEventListener(i, doHydrate, { once: true }); } }); return teardown; }; function forEachElement(node, cb) { if (isComment(node) && node.data === "[") { let depth = 1; let next = node.nextSibling; while (next) { if (next.nodeType === 1) { const result = cb(next); if (result === false) { break; } } else if (isComment(next)) { if (next.data === "]") { if (--depth === 0) break; } else if (next.data === "[") { depth++; } } next = next.nextSibling; } } else { cb(node); } } var isAsyncWrapper = (i) => !!i.type.__asyncLoader; function defineAsyncComponent(source) { if (isFunction(source)) { source = { loader: source }; } const { loader, loadingComponent, errorComponent, delay = 200, hydrate: hydrateStrategy, timeout, // undefined = never times out suspensible = true, onError: userOnError } = source; let pendingRequest = null; let resolvedComp; let retries = 0; const retry = () => { retries++; pendingRequest = null; return load(); }; const load = () => { let thisRequest; return pendingRequest || (thisRequest = pendingRequest = loader().catch((err) => { err = err instanceof Error ? err : new Error(String(err)); if (userOnError) { return new Promise((resolve2, reject) => { const userRetry = () => resolve2(retry()); const userFail = () => reject(err); userOnError(err, userRetry, userFail, retries + 1); }); } else { throw err; } }).then((comp) => { if (thisRequest !== pendingRequest && pendingRequest) { return pendingRequest; } if (!comp) { warn$1( `Async component loader resolved to undefined. If you are using retry(), make sure to return its return value.` ); } if (comp && (comp.__esModule || comp[Symbol.toStringTag] === "Module")) { comp = comp.default; } if (comp && !isObject(comp) && !isFunction(comp)) { throw new Error(`Invalid async component load result: ${comp}`); } resolvedComp = comp; return comp; })); }; return defineComponent({ name: "AsyncComponentWrapper", __asyncLoader: load, __asyncHydrate(el, instance, hydrate2) { const doHydrate = hydrateStrategy ? () => { const teardown = hydrateStrategy( hydrate2, (cb) => forEachElement(el, cb) ); if (teardown) { (instance.bum || (instance.bum = [])).push(teardown); } } : hydrate2; if (resolvedComp) { doHydrate(); } else { load().then(() => !instance.isUnmounted && doHydrate()); } }, get __asyncResolved() { return resolvedComp; }, setup() { const instance = currentInstance; markAsyncBoundary(instance); if (resolvedComp) { return () => createInnerComp(resolvedComp, instance); } const onError = (err) => { pendingRequest = null; handleError( err, instance, 13, !errorComponent ); }; if (suspensible && instance.suspense || isInSSRComponentSetup) { return load().then((comp) => { return () => createInnerComp(comp, instance); }).catch((err) => { onError(err); return () => errorComponent ? createVNode(errorComponent, { error: err }) : null; }); } const loaded = ref(false); const error = ref(); const delayed = ref(!!delay); if (delay) { setTimeout(() => { delayed.value = false; }, delay); } if (timeout != null) { setTimeout(() => { if (!loaded.value && !error.value) { const err = new Error( `Async component timed out after ${timeout}ms.` ); onError(err); error.value = err; } }, timeout); } load().then(() => { loaded.value = true; if (instance.parent && isKeepAlive(instance.parent.vnode)) { instance.parent.update(); } }).catch((err) => { onError(err); error.value = err; }); return () => { if (loaded.value && resolvedComp) { return createInnerComp(resolvedComp, instance); } else if (error.value && errorComponent) { return createVNode(errorComponent, { error: error.value }); } else if (loadingComponent && !delayed.value) { return createVNode(loadingComponent); } }; } }); } function createInnerComp(comp, parent) { const { ref: ref2, props, children, ce } = parent.vnode; const vnode = createVNode(comp, props, children); vnode.ref = ref2; vnode.ce = ce; delete parent.vnode.ce; return vnode; } var isKeepAlive = (vnode) => vnode.type.__isKeepAlive; var KeepAliveImpl = { name: `KeepAlive`, // Marker for special handling inside the renderer. We are not using a === // check directly on KeepAlive in the renderer, because importing it directly // would prevent it from being tree-shaken. __isKeepAlive: true, props: { include: [String, RegExp, Array], exclude: [String, RegExp, Array], max: [String, Number] }, setup(props, { slots }) { const instance = getCurrentInstance(); const sharedContext = instance.ctx; if (!sharedContext.renderer) { return () => { const children = slots.default && slots.default(); return children && children.length === 1 ? children[0] : children; }; } const cache = /* @__PURE__ */ new Map(); const keys = /* @__PURE__ */ new Set(); let current = null; if (true) { instance.__v_cache = cache; } const parentSuspense = instance.suspense; const { renderer: { p: patch, m: move, um: _unmount, o: { createElement } } } = sharedContext; const storageContainer = createElement("div"); sharedContext.activate = (vnode, container, anchor, namespace, optimized) => { const instance2 = vnode.component; move(vnode, container, anchor, 0, parentSuspense); patch( instance2.vnode, vnode, container, anchor, instance2, parentSuspense, namespace, vnode.slotScopeIds, optimized ); queuePostRenderEffect(() => { instance2.isDeactivated = false; if (instance2.a) { invokeArrayFns(instance2.a); } const vnodeHook = vnode.props && vnode.props.onVnodeMounted; if (vnodeHook) { invokeVNodeHook(vnodeHook, instance2.parent, vnode); } }, parentSuspense); if (true) { devtoolsComponentAdded(instance2); } }; sharedContext.deactivate = (vnode) => { const instance2 = vnode.component; invalidateMount(instance2.m); invalidateMount(instance2.a); move(vnode, storageContainer, null, 1, parentSuspense); queuePostRenderEffect(() => { if (instance2.da) { invokeArrayFns(instance2.da); } const vnodeHook = vnode.props && vnode.props.onVnodeUnmounted; if (vnodeHook) { invokeVNodeHook(vnodeHook, instance2.parent, vnode); } instance2.isDeactivated = true; }, parentSuspense); if (true) { devtoolsComponentAdded(instance2); } }; function unmount(vnode) { resetShapeFlag(vnode); _unmount(vnode, instance, parentSuspense, true); } function pruneCache(filter) { cache.forEach((vnode, key) => { const name = getComponentName(vnode.type); if (name && !filter(name)) { pruneCacheEntry(key); } }); } function pruneCacheEntry(key) { const cached = cache.get(key); if (cached && (!current || !isSameVNodeType(cached, current))) { unmount(cached); } else if (current) { resetShapeFlag(current); } cache.delete(key); keys.delete(key); } watch2( () => [props.include, props.exclude], ([include, exclude]) => { include && pruneCache((name) => matches(include, name)); exclude && pruneCache((name) => !matches(exclude, name)); }, // prune post-render after `current` has been updated { flush: "post", deep: true } ); let pendingCacheKey = null; const cacheSubtree = () => { if (pendingCacheKey != null) { if (isSuspense(instance.subTree.type)) { queuePostRenderEffect(() => { cache.set(pendingCacheKey, getInnerChild(instance.subTree)); }, instance.subTree.suspense); } else { cache.set(pendingCacheKey, getInnerChild(instance.subTree)); } } }; onMounted(cacheSubtree); onUpdated(cacheSubtree); onBeforeUnmount(() => { cache.forEach((cached) => { const { subTree, suspense } = instance; const vnode = getInnerChild(subTree); if (cached.type === vnode.type && cached.key === vnode.key) { resetShapeFlag(vnode); const da = vnode.component.da; da && queuePostRenderEffect(da, suspense); return; } unmount(cached); }); }); return () => { pendingCacheKey = null; if (!slots.default) { return current = null; } const children = slots.default(); const rawVNode = children[0]; if (children.length > 1) { if (true) { warn$1(`KeepAlive should contain exactly one component child.`); } current = null; return children; } else if (!isVNode(rawVNode) || !(rawVNode.shapeFlag & 4) && !(rawVNode.shapeFlag & 128)) { current = null; return rawVNode; } let vnode = getInnerChild(rawVNode); if (vnode.type === Comment) { current = null; return vnode; } const comp = vnode.type; const name = getComponentName( isAsyncWrapper(vnode) ? vnode.type.__asyncResolved || {} : comp ); const { include, exclude, max } = props; if (include && (!name || !matches(include, name)) || exclude && name && matches(exclude, name)) { vnode.shapeFlag &= ~256; current = vnode; return rawVNode; } const key = vnode.key == null ? comp : vnode.key; const cachedVNode = cache.get(key); if (vnode.el) { vnode = cloneVNode(vnode); if (rawVNode.shapeFlag & 128) { rawVNode.ssContent = vnode; } } pendingCacheKey = key; if (cachedVNode) { vnode.el = cachedVNode.el; vnode.component = cachedVNode.component; if (vnode.transition) { setTransitionHooks(vnode, vnode.transition); } vnode.shapeFlag |= 512; keys.delete(key); keys.add(key); } else { keys.add(key); if (max && keys.size > parseInt(max, 10)) { pruneCacheEntry(keys.values().next().value); } } vnode.shapeFlag |= 256; current = vnode; return isSuspense(rawVNode.type) ? rawVNode : vnode; }; } }; var KeepAlive = KeepAliveImpl; function matches(pattern, name) { if (isArray(pattern)) { return pattern.some((p2) => matches(p2, name)); } else if (isString(pattern)) { return pattern.split(",").includes(name); } else if (isRegExp(pattern)) { pattern.lastIndex = 0; return pattern.test(name); } return false; } function onActivated(hook, target) { registerKeepAliveHook(hook, "a", target); } function onDeactivated(hook, target) { registerKeepAliveHook(hook, "da", target); } function registerKeepAliveHook(hook, type, target = currentInstance) { const wrappedHook = hook.__wdc || (hook.__wdc = () => { let current = target; while (current) { if (current.isDeactivated) { return; } current = current.parent; } return hook(); }); injectHook(type, wrappedHook, target); if (target) { let current = target.parent; while (current && current.parent) { if (isKeepAlive(current.parent.vnode)) { injectToKeepAliveRoot(wrappedHook, type, target, current); } current = current.parent; } } } function injectToKeepAliveRoot(hook, type, target, keepAliveRoot) { const injected = injectHook( type, hook, keepAliveRoot, true /* prepend */ ); onUnmounted(() => { remove(keepAliveRoot[type], injected); }, target); } function resetShapeFlag(vnode) { vnode.shapeFlag &= ~256; vnode.shapeFlag &= ~512; } function getInnerChild(vnode) { return vnode.shapeFlag & 128 ? vnode.ssContent : vnode; } function injectHook(type, hook, target = currentInstance, prepend = false) { if (target) { const hooks = target[type] || (target[type] = []); const wrappedHook = hook.__weh || (hook.__weh = (...args) => { pauseTracking(); const reset = setCurrentInstance(target); const res = callWithAsyncErrorHandling(hook, target, type, args); reset(); resetTracking(); return res; }); if (prepend) { hooks.unshift(wrappedHook); } else { hooks.push(wrappedHook); } return wrappedHook; } else if (true) { const apiName = toHandlerKey(ErrorTypeStrings$1[type].replace(/ hook$/, "")); warn$1( `${apiName} is called when there is no active component instance to be associated with. Lifecycle injection APIs can only be used during execution of setup(). If you are using async setup(), make sure to register lifecycle hooks before the first await statement.` ); } } var createHook = (lifecycle) => (hook, target = currentInstance) => { if (!isInSSRComponentSetup || lifecycle === "sp") { injectHook(lifecycle, (...args) => hook(...args), target); } }; var onBeforeMount = createHook("bm"); var onMounted = createHook("m"); var onBeforeUpdate = createHook( "bu" ); var onUpdated = createHook("u"); var onBeforeUnmount = createHook( "bum" ); var onUnmounted = createHook("um"); var onServerPrefetch = createHook( "sp" ); var onRenderTriggered = createHook("rtg"); var onRenderTracked = createHook("rtc"); function onErrorCaptured(hook, target = currentInstance) { injectHook("ec", hook, target); } var COMPONENTS = "components"; var DIRECTIVES = "directives"; function resolveComponent(name, maybeSelfReference) { return resolveAsset(COMPONENTS, name, true, maybeSelfReference) || name; } var NULL_DYNAMIC_COMPONENT = Symbol.for("v-ndc"); function resolveDynamicComponent(component) { if (isString(component)) { return resolveAsset(COMPONENTS, component, false) || component; } else { return component || NULL_DYNAMIC_COMPONENT; } } function resolveDirective(name) { return resolveAsset(DIRECTIVES, name); } function resolveAsset(type, name, warnMissing = true, maybeSelfReference = false) { const instance = currentRenderingInstance || currentInstance; if (instance) { const Component = instance.type; if (type === COMPONENTS) { const selfName = getComponentName( Component, false ); if (selfName && (selfName === name || selfName === camelize(name) || selfName === capitalize(camelize(name)))) { return Component; } } const res = ( // local registration // check instance[type] first which is resolved for options API resolve(instance[type] || Component[type], name) || // global registration resolve(instance.appContext[type], name) ); if (!res && maybeSelfReference) { return Component; } if (warnMissing && !res) { const extra = type === COMPONENTS ? ` If this is a native custom element, make sure to exclude it from component resolution via compilerOptions.isCustomElement.` : ``; warn$1(`Failed to resolve ${type.slice(0, -1)}: ${name}${extra}`); } return res; } else if (true) { warn$1( `resolve${capitalize(type.slice(0, -1))} can only be used in render() or setup().` ); } } function resolve(registry, name) { return registry && (registry[name] || registry[camelize(name)] || registry[capitalize(camelize(name))]); } function renderList(source, renderItem, cache, index) { let ret; const cached = cache && cache[index]; const sourceIsArray = isArray(source); if (sourceIsArray || isString(source)) { const sourceIsReactiveArray = sourceIsArray && isReactive(source); let needsWrap = false; if (sourceIsReactiveArray) { needsWrap = !isShallow(source); source = shallowReadArray(source); } ret = new Array(source.length); for (let i = 0, l = source.length; i < l; i++) { ret[i] = renderItem( needsWrap ? toReactive(source[i]) : source[i], i, void 0, cached && cached[i] ); } } else if (typeof source === "number") { if (!Number.isInteger(source)) { warn$1(`The v-for range expect an integer value but got ${source}.`); } ret = new Array(source); for (let i = 0; i < source; i++) { ret[i] = renderItem(i + 1, i, void 0, cached && cached[i]); } } else if (isObject(source)) { if (source[Symbol.iterator]) { ret = Array.from( source, (item, i) => renderItem(item, i, void 0, cached && cached[i]) ); } else { const keys = Object.keys(source); ret = new Array(keys.length); for (let i = 0, l = keys.length; i < l; i++) { const key = keys[i]; ret[i] = renderItem(source[key], key, i, cached && cached[i]); } } } else { ret = []; } if (cache) { cache[index] = ret; } return ret; } function createSlots(slots, dynamicSlots) { for (let i = 0; i < dynamicSlots.length; i++) { const slot = dynamicSlots[i]; if (isArray(slot)) { for (let j = 0; j < slot.length; j++) { slots[slot[j].name] = slot[j].fn; } } else if (slot) { slots[slot.name] = slot.key ? (...args) => { const res = slot.fn(...args); if (res) res.key = slot.key; return res; } : slot.fn; } } return slots; } function renderSlot(slots, name, props = {}, fallback, noSlotted) { if (currentRenderingInstance.ce || currentRenderingInstance.parent && isAsyncWrapper(currentRenderingInstance.parent) && currentRenderingInstance.parent.ce) { if (name !== "default") props.name = name; return openBlock(), createBlock( Fragment, null, [createVNode("slot", props, fallback && fallback())], 64 ); } let slot = slots[name]; if (slot && slot.length > 1) { warn$1( `SSR-optimized slot function detected in a non-SSR-optimized render function. You need to mark this component with $dynamic-slots in the parent template.` ); slot = () => []; } if (slot && slot._c) { slot._d = false; } openBlock(); const validSlotContent = slot && ensureValidVNode(slot(props)); const slotKey = props.key || // slot content array of a dynamic conditional slot may have a branch // key attached in the `createSlots` helper, respect that validSlotContent && validSlotContent.key; const rendered = createBlock( Fragment, { key: (slotKey && !isSymbol(slotKey) ? slotKey : `_${name}`) + // #7256 force differentiate fallback content from actual content (!validSlotContent && fallback ? "_fb" : "") }, validSlotContent || (fallback ? fallback() : []), validSlotContent && slots._ === 1 ? 64 : -2 ); if (!noSlotted && rendered.scopeId) { rendered.slotScopeIds = [rendered.scopeId + "-s"]; } if (slot && slot._c) { slot._d = true; } return rendered; } function ensureValidVNode(vnodes) { return vnodes.some((child) => { if (!isVNode(child)) return true; if (child.type === Comment) return false; if (child.type === Fragment && !ensureValidVNode(child.children)) return false; return true; }) ? vnodes : null; } function toHandlers(obj, preserveCaseIfNecessary) { const ret = {}; if (!isObject(obj)) { warn$1(`v-on with no argument expects an object value.`); return ret; } for (const key in obj) { ret[preserveCaseIfNecessary && /[A-Z]/.test(key) ? `on:${key}` : toHandlerKey(key)] = obj[key]; } return ret; } var getPublicInstance = (i) => { if (!i) return null; if (isStatefulComponent(i)) return getComponentPublicInstance(i); return getPublicInstance(i.parent); }; var publicPropertiesMap = ( // Move PURE marker to new line to workaround compiler discarding it // due to type annotation extend(/* @__PURE__ */ Object.create(null), { $: (i) => i, $el: (i) => i.vnode.el, $data: (i) => i.data, $props: (i) => true ? shallowReadonly(i.props) : i.props, $attrs: (i) => true ? shallowReadonly(i.attrs) : i.attrs, $slots: (i) => true ? shallowReadonly(i.slots) : i.slots, $refs: (i) => true ? shallowReadonly(i.refs) : i.refs, $parent: (i) => getPublicInstance(i.parent), $root: (i) => getPublicInstance(i.root), $host: (i) => i.ce, $emit: (i) => i.emit, $options: (i) => __VUE_OPTIONS_API__ ? resolveMergedOptions(i) : i.type, $forceUpdate: (i) => i.f || (i.f = () => { queueJob(i.update); }), $nextTick: (i) => i.n || (i.n = nextTick.bind(i.proxy)), $watch: (i) => __VUE_OPTIONS_API__ ? instanceWatch.bind(i) : NOOP }) ); var isReservedPrefix = (key) => key === "_" || key === "$"; var hasSetupBinding = (state, key) => state !== EMPTY_OBJ && !state.__isScriptSetup && hasOwn(state, key); var PublicInstanceProxyHandlers = { get({ _: instance }, key) { if (key === "__v_skip") { return true; } const { ctx, setupState, data, props, accessCache, type, appContext } = instance; if (key === "__isVue") { return true; } let normalizedProps; if (key[0] !== "$") { const n = accessCache[key]; if (n !== void 0) { switch (n) { case 1: return setupState[key]; case 2: return data[key]; case 4: return ctx[key]; case 3: return props[key]; } } else if (hasSetupBinding(setupState, key)) { accessCache[key] = 1; return setupState[key]; } else if (data !== EMPTY_OBJ && hasOwn(data, key)) { accessCache[key] = 2; return data[key]; } else if ( // only cache other properties when instance has declared (thus stable) // props (normalizedProps = instance.propsOptions[0]) && hasOwn(normalizedProps, key) ) { accessCache[key] = 3; return props[key]; } else if (ctx !== EMPTY_OBJ && hasOwn(ctx, key)) { accessCache[key] = 4; return ctx[key]; } else if (!__VUE_OPTIONS_API__ || shouldCacheAccess) { accessCache[key] = 0; } } const publicGetter = publicPropertiesMap[key]; let cssModule, globalProperties; if (publicGetter) { if (key === "$attrs") { track(instance.attrs, "get", ""); markAttrsAccessed(); } else if (key === "$slots") { track(instance, "get", key); } return publicGetter(instance); } else if ( // css module (injected by vue-loader) (cssModule = type.__cssModules) && (cssModule = cssModule[key]) ) { return cssModule; } else if (ctx !== EMPTY_OBJ && hasOwn(ctx, key)) { accessCache[key] = 4; return ctx[key]; } else if ( // global properties globalProperties = appContext.config.globalProperties, hasOwn(globalProperties, key) ) { { return globalProperties[key]; } } else if (currentRenderingInstance && (!isString(key) || // #1091 avoid internal isRef/isVNode checks on component instance leading // to infinite warning loop key.indexOf("__v") !== 0)) { if (data !== EMPTY_OBJ && isReservedPrefix(key[0]) && hasOwn(data, key)) { warn$1( `Property ${JSON.stringify( key )} must be accessed via $data because it starts with a reserved character ("$" or "_") and is not proxied on the render context.` ); } else if (instance === currentRenderingInstance) { warn$1( `Property ${JSON.stringify(key)} was accessed during render but is not defined on instance.` ); } } }, set({ _: instance }, key, value) { const { data, setupState, ctx } = instance; if (hasSetupBinding(setupState, key)) { setupState[key] = value; return true; } else if (setupState.__isScriptSetup && hasOwn(setupState, key)) { warn$1(`Cannot mutate ``` --- ## 注意事项 1. **会话有效期**: 上传会话默认 15 分钟后过期,请在有效期内完成上传 2. **文件大小限制**: 受系统配置 `uploadSize` 限制 3. **过期类型**: 支持 `day`、`hour`、`minute`、`forever`、`count` 4. **CORS**: 直传模式下,S3 需要配置正确的 CORS 策略 5. **重试机制**: 建议实现上传失败重试逻辑 ================================================ FILE: docs/changelog.md ================================================ ================================================ FILE: docs/contributing.md ================================================ ================================================ FILE: docs/en/api/index.md ================================================ # FileCodeBox API Documentation ## API Version: 2.1.0 ## Table of Contents - [Authentication](#authentication) - [Share API](#share-api) - [Admin API](#admin-api) ## Authentication Some APIs require `Authorization` header for authentication: ``` Authorization: Bearer ``` ### Get Token When guest upload is disabled in admin panel (`openUpload=0`), you need to login first: ```bash curl -X POST "http://localhost:12345/admin/login" \ -H "Content-Type: application/json" \ -d '{"password": "FileCodeBox2023"}' ``` **Response Example:** ```json { "code": 200, "msg": "success", "detail": { "token": "xxx.xxx.xxx", "token_type": "Bearer" } } ``` ## Share API ### Share Text **POST** `/share/text/` Share text content and get a share code. **Parameters:** | Parameter | Type | Required | Default | Description | |-----------|------|----------|---------|-------------| | text | string | Yes | - | Text content to share | | expire_value | integer | No | 1 | Expiration time value | | expire_style | string | No | "day" | Expiration time unit(day/hour/minute/count/forever) | **cURL Example:** ```bash # Guest upload (when openUpload=1) curl -X POST "http://localhost:12345/share/text/" \ -F "text=This is the text content to share" \ -F "expire_value=1" \ -F "expire_style=day" # When authentication required (openUpload=0) curl -X POST "http://localhost:12345/share/text/" \ -H "Authorization: Bearer xxx.xxx.xxx" \ -F "text=This is the text content to share" ``` **Response Example:** ```json { "code": 200, "msg": "success", "detail": { "code": "abc123" } } ``` ### Share File **POST** `/share/file/` Upload and share a file, get a share code. **Parameters:** | Parameter | Type | Required | Default | Description | |-----------|------|----------|---------|-------------| | file | file | Yes | - | File to upload | | expire_value | integer | No | 1 | Expiration time value | | expire_style | string | No | "day" | Expiration time unit(day/hour/minute/count/forever) | **cURL Example:** ```bash # Upload file (default 1 day expiration) curl -X POST "http://localhost:12345/share/file/" \ -F "file=@/path/to/file.txt" # Upload file (7 days expiration) curl -X POST "http://localhost:12345/share/file/" \ -F "file=@/path/to/file.txt" \ -F "expire_value=7" \ -F "expire_style=day" # Upload file (10 downloads limit) curl -X POST "http://localhost:12345/share/file/" \ -F "file=@/path/to/file.txt" \ -F "expire_value=10" \ -F "expire_style=count" # When authentication required curl -X POST "http://localhost:12345/share/file/" \ -H "Authorization: Bearer xxx.xxx.xxx" \ -F "file=@/path/to/file.txt" ``` **Response Example:** ```json { "code": 200, "msg": "success", "detail": { "code": "abc123", "name": "example.txt" } } ``` ### Get File Info **GET** `/share/select/` Get file information by share code (direct file download). **Parameters:** | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | code | string | Yes | File share code | **cURL Example:** ```bash # Download file by extraction code curl -L "http://localhost:12345/share/select/?code=abc123" -o downloaded_file ``` **Response Example:** ```json { "code": 200, "msg": "success", "detail": { "code": "abc123", "name": "example.txt", "size": 1024, "text": "File content or download link" } } ``` ### Select File **POST** `/share/select/` Select file by share code. **Parameters:** | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | code | string | Yes | File share code | **Response Example:** ```json { "code": 200, "msg": "success", "detail": { "code": "abc123", "name": "example.txt", "size": 1024, "text": "File content or download link" } } ``` ### Download File **GET** `/share/download` Download shared file. **Parameters:** | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | key | string | Yes | Download key | | code | string | Yes | File share code | ## Admin API ### Admin Login **POST** `/admin/login` Admin login to get token. **Parameters:** | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | password | string | Yes | Admin password | ### Dashboard Data **GET** `/admin/dashboard` Get system dashboard data. **Response Example:** ```json { "code": 200, "msg": "success", "detail": { "totalFiles": 100, "storageUsed": "1.5GB", "sysUptime": "10 days", "yesterdayCount": 50, "yesterdaySize": "500MB", "todayCount": 30, "todaySize": "300MB" } } ``` ### File List **GET** `/admin/file/list` Get system file list. **Parameters:** | Parameter | Type | Required | Default | Description | |-----------|------|----------|---------|-------------| | page | integer | No | 1 | Current page | | size | integer | No | 10 | Page size | | keyword | string | No | "" | Search keyword | **Response Example:** ```json { "code": 200, "msg": "success", "detail": { "page": 1, "size": 10, "total": 100, "data": [ { "id": 1, "name": "example.txt", "size": 1024, "created_at": "2024-01-01 12:00:00" } ] } } ``` ### Delete File **DELETE** `/admin/file/delete` Delete file from system. **Parameters:** | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | id | integer | Yes | File ID | ### Get Config **GET** `/admin/config/get` Get system configuration. ### Update Config **PATCH** `/admin/config/update` Update system configuration. ## Error Response When an error occurs, the API will return corresponding error message: ```json { "code": 422, "detail": [ { "loc": ["body", "password"], "msg": "Password cannot be empty", "type": "value_error" } ] } ``` ## Status Codes - 200: Success - 401: Unauthorized - 403: Forbidden - 404: Not Found - 422: Validation Error - 500: Internal Server Error ================================================ FILE: docs/en/changelog.md ================================================ ================================================ FILE: docs/en/contributing.md ================================================ ================================================ FILE: docs/en/guide/configuration.md ================================================ # Configuration Guide FileCodeBox provides rich configuration options that can be customized through the admin panel or by directly modifying the configuration. This document details all available configuration options. ## Configuration Methods FileCodeBox supports two configuration methods: 1. **Admin Panel Configuration** (Recommended): Access `/admin` to enter the admin panel and modify settings on the settings page 2. **Database Configuration**: Configuration is stored in the `data/filecodebox.db` database ::: tip Note On first startup, the system uses default configuration from `core/settings.py`. Modified configurations are saved to the database. ::: ## Basic Settings ### Site Information | Setting | Type | Default | Description | |---------|------|---------|-------------| | `name` | string | `文件快递柜 - FileCodeBox` | Site name, displayed in page title and navigation bar | | `description` | string | `开箱即用的文件快传系统` | Site description, used for SEO | | `keywords` | string | `FileCodeBox, 文件快递柜...` | Site keywords, used for SEO | | `port` | int | `12345` | Service listening port | ### Notification Settings | Setting | Type | Default | Description | |---------|------|---------|-------------| | `notify_title` | string | `系统通知` | Notification title | | `notify_content` | string | Welcome message | Notification content, supports HTML | | `page_explain` | string | Legal disclaimer | Footer explanation text | | `robotsText` | string | `User-agent: *\nDisallow: /` | robots.txt content | ## Upload Settings ### File Upload Limits | Setting | Type | Default | Description | |---------|------|---------|-------------| | `openUpload` | int | `1` | Enable upload functionality (1=enabled, 0=disabled) | | `uploadSize` | int | `10485760` | Maximum single file upload size (bytes), default 10MB | | `enableChunk` | int | `0` | Enable chunked upload (1=enabled, 0=disabled) | ::: warning Note `uploadSize` is in bytes. 10MB = 10 * 1024 * 1024 = 10485760 bytes ::: ### Upload Rate Limiting | Setting | Type | Default | Description | |---------|------|---------|-------------| | `uploadMinute` | int | `1` | Upload limit time window (minutes) | | `uploadCount` | int | `10` | Maximum uploads allowed within the time window | Example: Default configuration allows up to 10 uploads per minute. ### File Expiration Settings | Setting | Type | Default | Description | |---------|------|---------|-------------| | `expireStyle` | list | `["day","hour","minute","forever","count"]` | Available expiration methods | | `max_save_seconds` | int | `0` | Maximum file retention time (seconds), 0 means no limit | Expiration methods explained: - `day` - Expire by days - `hour` - Expire by hours - `minute` - Expire by minutes - `forever` - Never expire - `count` - Expire by download count ## Theme Settings ### Theme Selection | Setting | Type | Default | Description | |---------|------|---------|-------------| | `themesSelect` | string | `themes/2024` | Currently active theme | | `themesChoices` | list | See below | Available themes list | Default available themes: ```json [ { "name": "2023", "key": "themes/2023", "author": "Lan", "version": "1.0" }, { "name": "2024", "key": "themes/2024", "author": "Lan", "version": "1.0" } ] ``` ### Interface Style | Setting | Type | Default | Description | |---------|------|---------|-------------| | `opacity` | float | `0.9` | Interface opacity (0-1) | | `background` | string | `""` | Custom background image URL, empty uses default background | ## Admin Settings | Setting | Type | Default | Description | |---------|------|---------|-------------| | `admin_token` | string | `FileCodeBox2023` | Admin login password | | `showAdminAddr` | int | `0` | Show admin panel entry on homepage (1=show, 0=hide) | ::: danger Security Warning Always change the default `admin_token` in production environments! Using the default password poses serious security risks. ::: ## Security Settings ### Error Rate Limiting | Setting | Type | Default | Description | |---------|------|---------|-------------| | `errorMinute` | int | `1` | Error limit time window (minutes) | | `errorCount` | int | `1` | Maximum errors allowed within the time window | This setting prevents brute-force attacks on extraction codes. ## Storage Settings ### Storage Type | Setting | Type | Default | Description | |---------|------|---------|-------------| | `file_storage` | string | `local` | Storage backend type | | `storage_path` | string | `""` | Custom storage path | Supported storage types: - `local` - Local storage - `s3` - S3-compatible storage (AWS S3, Aliyun OSS, MinIO, etc.) - `onedrive` - OneDrive storage - `webdav` - WebDAV storage - `opendal` - OpenDAL storage For detailed storage configuration, see [Storage Configuration](/en/guide/storage). ## Configuration Examples ### Example 1: Small Personal Use Suitable for personal or small team use with relaxed limits: ```python { "name": "My File Share", "uploadSize": 52428800, # 50MB "uploadMinute": 5, # 5 minutes "uploadCount": 20, # Max 20 uploads "expireStyle": ["day", "hour", "forever"], "admin_token": "your-secure-password", "showAdminAddr": 1 } ``` ### Example 2: Public Service Suitable for public services requiring stricter limits: ```python { "name": "Public File Box", "uploadSize": 10485760, # 10MB "uploadMinute": 1, # 1 minute "uploadCount": 5, # Max 5 uploads "errorMinute": 5, # 5 minutes "errorCount": 3, # Max 3 errors "expireStyle": ["hour", "minute", "count"], "max_save_seconds": 86400, # Max retention 1 day "admin_token": "very-secure-password-123", "showAdminAddr": 0 } ``` ### Example 3: Enterprise Internal Use Suitable for enterprise internal use with large file and chunked upload support: ```python { "name": "Enterprise File Transfer", "uploadSize": 1073741824, # 1GB "enableChunk": 1, # Enable chunked upload "uploadMinute": 10, # 10 minutes "uploadCount": 100, # Max 100 uploads "expireStyle": ["day", "forever"], "file_storage": "s3", # Use S3 storage "admin_token": "enterprise-secure-token", "showAdminAddr": 1 } ``` ## Next Steps - [Storage Configuration](/en/guide/storage) - Learn how to configure different storage backends - [Security Settings](/en/guide/security) - Learn how to enhance system security - [File Sharing](/en/guide/share) - Learn about file sharing features ================================================ FILE: docs/en/guide/getting-started.md ================================================ # Getting Started ## Introduction FileCodeBox is a simple and efficient file sharing tool that supports temporary file transfer, sharing, and management. This guide will help you quickly deploy and use FileCodeBox. ## Features - 🚀 Quick Deployment: Support Docker one-click deployment - 🔒 Secure & Reliable: File access requires extraction code - ⏱️ Time Control: Support setting file expiration time - 📊 Download Limit: Can limit file download times - 🖼️ File Preview: Support preview of images, videos, audio, and other formats - 📱 Responsive Design: Perfect adaptation for mobile and desktop ## Deployment Methods ### Docker Deployment ```bash docker run -d --restart=always -p 12345:12345 -v /opt/FileCodeBox/:/app/data --name filecodebox lanol/filecodebox:beta ``` ### Manual Deployment 1. Clone the repository ```bash git clone https://github.com/vastsa/FileCodeBox.git ``` 2. Install dependencies ```bash cd FileCodeBox pip install -r requirements.txt ``` 3. Start the service ```bash python main.py ``` ## Usage 1. Access the System Open browser and visit `http://localhost:12345` 2. Upload Files - Click upload button or drag files to upload area - Set file expiration time and download limit - Get share link and extraction code 3. Download Files - Visit share link - Enter extraction code - Download file 4. Admin Panel - Visit `http://localhost:12345/#/admin` - Enter admin password: `FileCodeBox2023` - Enter admin panel - View system information, file list, user management, etc. ## Next Steps - [Storage Configuration](/en/guide/storage) - Learn how to configure different storage methods - [Security Settings](/en/guide/security) - Learn how to enhance system security - [API Documentation](/en/api/) - Learn how to integrate through API ================================================ FILE: docs/en/guide/introduction.md ================================================
FileCodeBox Logo

Share text and files anonymously with a passcode, like picking up a package

[![GitHub stars](https://img.shields.io/github/stars/vastsa/FileCodeBox)](https://github.com/vastsa/FileCodeBox/stargazers) [![GitHub forks](https://img.shields.io/github/forks/vastsa/FileCodeBox)](https://github.com/vastsa/FileCodeBox/network) [![GitHub issues](https://img.shields.io/github/issues/vastsa/FileCodeBox)](https://github.com/vastsa/FileCodeBox/issues) [![GitHub license](https://img.shields.io/github/license/vastsa/FileCodeBox)](https://github.com/vastsa/FileCodeBox/blob/master/LICENSE) [![QQ Group](https://img.shields.io/badge/QQ%20Group-739673698-blue.svg)](https://qm.qq.com/q/PemPzhdEIM) [![Python Version](https://img.shields.io/badge/Python-3.8+-blue.svg)](https://www.python.org) [![FastAPI](https://img.shields.io/badge/FastAPI-0.68+-green.svg)](https://fastapi.tiangolo.com) [![Vue Version](https://img.shields.io/badge/Vue.js-3.x-brightgreen.svg)](https://v3.vuejs.org)
## 📝 Introduction FileCodeBox is a lightweight file sharing tool developed with FastAPI + Vue3. It allows users to share text and files easily, where recipients only need a passcode to retrieve the files, just like picking up a package from a delivery locker. ## 🖼️ Preview

Frontend Repository     Demo Site

## 🎯 Use Cases

📁 Temporary File Sharing

Quick file sharing without registration

📝 Quick Text Sharing

Share code snippets and text content

🕶️ Anonymous Transfer

Privacy-protected file transfer

💾 Temporary Storage

File storage with expiration time

🔄 Cross-platform Transfer

Quick file transfer between devices

🌐 Private Share Service

Build your own file sharing service
## ✨ Core Features

🚀 Lightweight

Based on FastAPI + SQLite3 + Vue3 + ElementUI

📤 Easy Upload

Support copy-paste and drag-drop

📦 Multiple Types

Support text and various file types

🔒 Security

- IP upload limits - Error attempt limits - File expiration

🎫 Passcode Sharing

Random codes with customizable limits

🌍 Multi-language

Support for Simplified Chinese, Traditional Chinese, and English

🎭 Anonymous

No registration required

🛠 Admin Panel

File and system management

🐳 Docker

One-click deployment

💾 Storage Options

Local, S3, OneDrive support

📱 Responsive

Mobile-friendly design

💻 CLI Support

Command-line download
## 🚀 Quick Start ### Docker Deployment ```bash docker run -d --restart=always -p 12345:12345 -v /opt/FileCodeBox/:/app/data --name filecodebox lanol/filecodebox:beta ``` ### Manual Deployment 1. Clone the repository ```bash git clone https://github.com/vastsa/FileCodeBox.git ``` 2. Install dependencies ```bash cd FileCodeBox pip install -r requirements.txt ``` 3. Start the service ```bash python main.py ``` ## 📖 Usage Guide ### Share Files 1. Open the website, click "Share File" 2. Select or drag files 3. Set expiration time and count 4. Get the passcode ### Retrieve Files 1. Open the website, enter passcode 2. Click retrieve 3. Download file or view text ### Admin Panel 1. Visit `/admin` 2. Enter admin password 3. Manage files and settings ## 🛠 Development Guide ### Project Structure ``` FileCodeBox/ ├── apps/ # Application code │ ├── admin/ # Admin backend │ └── base/ # Base functions ├── core/ # Core functions ├── data/ # Data directory └── fcb-fronted/ # Frontend code ``` ### Development Environment - Python 3.8+ - Node.js 14+ - Vue 3 - FastAPI ### Local Development 1. Backend development ```bash python main.py ``` 2. Frontend development ```bash cd fcb-fronted npm install npm run dev ``` ## 🤝 Contributing 1. Fork the project 2. Create your feature branch `git checkout -b feature/xxx` 3. Commit your changes `git commit -m 'Add xxx'` 4. Push to the branch `git push origin feature/xxx` 5. Open a Pull Request ## ❓ FAQ ### Q: How to modify upload size limit? A: Change `uploadSize` in admin panel ### Q: How to configure storage engine? A: Select storage engine and configure parameters in admin panel ### Q: How to backup data? A: Backup the `data` directory For more questions, visit [Wiki](https://github.com/vastsa/FileCodeBox/wiki/常见问题) ## 😀 Project Statistics and Analytics
Featured|HelloGitHub ![Repobeats](https://repobeats.axiom.co/api/embed/7a6c92f1d96ee57e6fb67f0df371528397b0c9ac.svg) [![Star History](https://api.star-history.com/svg?repos=vastsa/FileCodeBox&type=Date)](https://star-history.com/#vastsa/FileCodeBox&Date)
## 📜 Disclaimer This project is open-source for learning purposes only. It should not be used for any illegal purposes. The author is not responsible for any consequences. Please retain the project address and copyright information when using it. ================================================ FILE: docs/en/guide/management.md ================================================ # Admin Panel FileCodeBox provides a fully-featured admin panel that allows administrators to conveniently manage files, view system status, and modify configurations. This document introduces the various features and usage of the admin panel. ## Accessing the Admin Panel ### Login Method The admin panel is located at the `/admin` path. Access method: 1. Visit `http://your-domain.com/admin` in your browser 2. Enter the admin password (the value of the `admin_token` configuration) 3. Click the login button ::: tip Tip The default admin password is `FileCodeBox2023`. Be sure to change this password in production environments. See [Security Settings](/en/guide/security) for details. ::: ### Show Admin Entry By default, the admin panel entry is not shown on the homepage. You can control whether to show it via configuration: | Setting | Type | Default | Description | |---------|------|---------|-------------| | `showAdminAddr` | int | `0` | Show admin entry on homepage (1=show, 0=hide) | ::: warning Security Recommendation For public services, it's recommended to keep `showAdminAddr` at `0` and access the admin panel directly via the `/admin` path to reduce the risk of malicious scanning. ::: ### Authentication Mechanism The admin panel uses JWT (JSON Web Token) for authentication: 1. After successful login, the server returns a Token containing admin identity 2. Subsequent requests carry the Token via `Authorization: Bearer ` header 3. Token is used to verify admin identity, ensuring only authorized users can access admin functions ## Dashboard After logging in, you first see the dashboard page, which displays the overall system status. ### Statistics The dashboard displays the following key metrics: | Metric | Description | |--------|-------------| | **Total Files** (`totalFiles`) | Total number of files stored in the system | | **Storage Used** (`storageUsed`) | Total storage space occupied by all files (bytes) | | **System Uptime** (`sysUptime`) | Time when the system was first started | | **Yesterday's Uploads** (`yesterdayCount`) | Number of files uploaded yesterday | | **Yesterday's Upload Size** (`yesterdaySize`) | Total size of files uploaded yesterday (bytes) | | **Today's Uploads** (`todayCount`) | Number of files uploaded today so far | | **Today's Upload Size** (`todaySize`) | Total size of files uploaded today (bytes) | ### Metric Notes - **Total Files**: Includes all unexpired files and text shares - **Storage Used**: Shows actual storage space occupied by files, excluding database and other system files - **Yesterday/Today Statistics**: Calculated based on file creation time, useful for understanding system usage trends ::: tip Tip Storage usage is displayed in bytes. For example, `10485760` represents approximately 10MB. ::: ## File Management ### File List The file management page displays all shared files in the system, supporting pagination and search. **List information includes:** - File ID - Extraction code (code) - Filename prefix (prefix) - File extension (suffix) - File size - Creation time - Expiration time - Remaining download count ### Search Files Use the search function to quickly find specific files: 1. Enter keywords in the search box 2. System performs fuzzy matching based on filename prefix (prefix) 3. Search results update in real-time **Search examples:** - Enter `report` to find all files with "report" in the filename - Enter `.pdf` to find all PDF files (if the filename contains this string) ### Pagination The file list supports paginated display: | Parameter | Default | Description | |-----------|---------|-------------| | `page` | `1` | Current page number | | `size` | `10` | Items per page | ### Delete Files Administrators can delete any file: 1. Find the file to delete in the file list 2. Click the delete button 3. Confirm the delete operation ::: danger Warning Delete operations are irreversible! Files will be permanently deleted from the storage backend, and database records will also be removed. ::: **Delete process:** 1. System first deletes the actual file from the storage backend (local/S3/OneDrive, etc.) 2. Then deletes the file record from the database 3. After deletion, the corresponding extraction code becomes invalid ### Download Files Administrators can directly download any file: 1. Find the target file in the file list 2. Click the download button 3. File will be downloaded via browser For text shares, the system returns text content directly instead of downloading a file. ### Modify File Information Administrators can modify some information of shared files: | Modifiable Field | Description | |------------------|-------------| | `code` | Extraction code (must be unique, cannot duplicate other files) | | `prefix` | Filename prefix | | `suffix` | File extension | | `expired_at` | Expiration time | | `expired_count` | Remaining download count | **Modify extraction code:** ``` Original code: abc123 New code: myfile2024 ``` ::: warning Note When modifying extraction codes, the system checks if the new code is already in use. If an identical extraction code exists, the modification will fail. ::: ## Local File Management In addition to managing shared files, the admin panel also provides local file management functionality for managing files in the `data/local` directory. ### View Local Files The local file list displays all files in the `data/local` directory: | Information | Description | |-------------|-------------| | Filename | Complete filename | | Creation Time | File creation time | | File Size | File size (bytes) | ### Share Local Files You can quickly share local files: 1. Select the file to share in the local file list 2. Set expiration method and value 3. Click the share button 4. System generates extraction code **Share parameters:** | Parameter | Description | |-----------|-------------| | `filename` | Filename to share | | `expire_style` | Expiration method (day/hour/minute/forever/count) | | `expire_value` | Expiration value (days/hours/minutes/download count) | ### Delete Local Files You can delete files in the `data/local` directory: 1. Find the file to delete in the local file list 2. Click the delete button 3. Confirm deletion ::: tip Use Cases Local file management is useful for: - Sharing files after batch uploading to the server - Managing files uploaded to the server through other means - Cleaning up unnecessary local files ::: ## System Settings ### View Configuration On the system settings page, you can view the current values of all configuration items. Configuration items are grouped by category: - Basic settings (site name, description, etc.) - Upload settings (file size limits, rate limits, etc.) - Storage settings (storage type, path, etc.) - Theme settings (theme selection, opacity, etc.) - Security settings (admin password, error limits, etc.) ### Modify Configuration Administrators can modify most configurations through the admin panel: 1. Go to the system settings page 2. Find the configuration item to modify 3. Enter the new value 4. Click the save button **Modifiable configuration items:** | Category | Example Settings | |----------|------------------| | Basic Settings | `name`, `description`, `keywords`, `notify_title`, `notify_content` | | Upload Settings | `uploadSize`, `uploadMinute`, `uploadCount`, `openUpload`, `enableChunk` | | Expiration Settings | `expireStyle`, `max_save_seconds` | | Theme Settings | `themesSelect`, `opacity`, `background` | | Security Settings | `admin_token`, `showAdminAddr`, `errorMinute`, `errorCount` | | Storage Settings | `file_storage`, `storage_path` and storage backend-specific configurations | ::: warning Note - `admin_token` (admin password) cannot be set to empty - `themesChoices` (theme list) cannot be modified through the admin panel - After modifying storage settings, existing files will not be automatically migrated ::: ### Configuration Effect Configuration changes take effect immediately without restarting the service. Configurations are saved in the database and persist after restart. **Configuration storage location:** - Database: `data/filecodebox.db` - Table name: `keyvalue` - Key name: `settings` ## API Endpoints All admin panel functions are implemented through REST APIs. Here are the main endpoints: ### Authentication Endpoint **Login** ``` POST /admin/login Content-Type: application/json { "password": "your-admin-password" } ``` Response: ```json { "code": 200, "detail": { "token": "eyJhbGciOiJIUzI1NiIs...", "token_type": "Bearer" } } ``` ### Dashboard Endpoint **Get Statistics** ``` GET /admin/dashboard Authorization: Bearer ``` ### File Management Endpoints **Get File List** ``` GET /admin/file/list?page=1&size=10&keyword= Authorization: Bearer ``` **Delete File** ``` DELETE /admin/file/delete Authorization: Bearer Content-Type: application/json { "id": 123 } ``` **Download File** ``` GET /admin/file/download?id=123 Authorization: Bearer ``` **Modify File Information** ``` PATCH /admin/file/update Authorization: Bearer Content-Type: application/json { "id": 123, "code": "newcode", "expired_at": "2024-12-31T23:59:59" } ``` ### Local File Endpoints **Get Local File List** ``` GET /admin/local/lists Authorization: Bearer ``` **Delete Local File** ``` DELETE /admin/local/delete Authorization: Bearer Content-Type: application/json { "filename": "example.txt" } ``` **Share Local File** ``` POST /admin/local/share Authorization: Bearer Content-Type: application/json { "filename": "example.txt", "expire_style": "day", "expire_value": 7 } ``` ### Configuration Endpoints **Get Configuration** ``` GET /admin/config/get Authorization: Bearer ``` **Update Configuration** ``` PATCH /admin/config/update Authorization: Bearer Content-Type: application/json { "admin_token": "new-password", "uploadSize": 52428800 } ``` ## Common Issues ### Forgot Admin Password If you forgot the admin password, you can reset it through the following methods: 1. Stop the FileCodeBox service 2. Open `data/filecodebox.db` using an SQLite tool 3. Query the record with `key='settings'` in the `keyvalue` table 4. Modify the `admin_token` value in the JSON 5. Restart the service ```sql -- View current configuration SELECT * FROM keyvalue WHERE key = 'settings'; -- Or delete configuration to restore default password DELETE FROM keyvalue WHERE key = 'settings'; ``` ### File Deletion Failed If an error occurs when deleting files, possible reasons: 1. **Storage backend connection failed**: Check if storage configuration is correct 2. **File no longer exists**: File may have been manually deleted 3. **Insufficient permissions**: Check write permissions for storage directory ### Configuration Changes Not Taking Effect If configuration changes don't take effect: 1. Check if you clicked the save button 2. Refresh the page to see if configuration was saved 3. Check browser console for error messages 4. Confirm configuration value format is correct (e.g., don't enter strings for numeric types) ## Next Steps - [Configuration Guide](/en/guide/configuration) - Learn detailed descriptions of all configuration options - [Security Settings](/en/guide/security) - Learn how to enhance system security - [Storage Configuration](/en/guide/storage) - Configure different storage backends ================================================ FILE: docs/en/guide/security.md ================================================ # Security Settings FileCodeBox provides multiple layers of security mechanisms to protect your file sharing service. This document explains how to properly configure security options to ensure secure system operation. ## Admin Password ### Change Default Password ::: danger Important Security Warning FileCodeBox's default admin password is `FileCodeBox2023`. **You must change this password immediately in production environments!** Using the default password allows anyone to access your admin panel. ::: There are two ways to change the admin password: **Method 1: Via Admin Panel (Recommended)** 1. Access `/admin` to enter the admin panel 2. Log in with the current password 3. Go to the "System Settings" page 4. Find the `admin_token` configuration item 5. Enter a new secure password and save **Method 2: Via Database** Configuration is stored in the `keyvalue` table of the `data/filecodebox.db` database. You can directly modify the `admin_token` value. ### Password Security Recommendations - Use a strong password with at least 16 characters - Include uppercase and lowercase letters, numbers, and special characters - Avoid common words or personal information - Change password regularly ```python # Recommended password format example "admin_token": "Xk9#mP2$vL5@nQ8&wR3" ``` ### Hide Admin Entry By default, the admin panel entry is hidden. You can control whether to show the admin entry on the homepage via the `showAdminAddr` configuration: | Setting | Type | Default | Description | |---------|------|---------|-------------| | `showAdminAddr` | int | `0` | Show admin entry (1=show, 0=hide) | ::: tip Recommendation For public services, it's recommended to keep `showAdminAddr` at `0` and access the admin panel directly via the `/admin` path. ::: ## IP Rate Limiting FileCodeBox has built-in IP-based rate limiting mechanisms to effectively prevent abuse and attacks. ### Upload Rate Limiting Limit the number of uploads from a single IP within a specified time: | Setting | Type | Default | Description | |---------|------|---------|-------------| | `uploadMinute` | int | `1` | Upload limit time window (minutes) | | `uploadCount` | int | `10` | Maximum uploads allowed within the time window | **How it works:** - System records upload requests from each IP - When an IP's upload count reaches `uploadCount` within `uploadMinute` minutes - Subsequent upload requests from that IP will be rejected with HTTP 423 error - Counter resets after the time window expires **Configuration examples:** ```python # Relaxed configuration: Max 20 uploads in 5 minutes { "uploadMinute": 5, "uploadCount": 20 } # Strict configuration: Max 3 uploads in 1 minute { "uploadMinute": 1, "uploadCount": 3 } ``` ### Error Rate Limiting Limit the number of error attempts from a single IP to prevent brute-force attacks on extraction codes: | Setting | Type | Default | Description | |---------|------|---------|-------------| | `errorMinute` | int | `1` | Error limit time window (minutes) | | `errorCount` | int | `1` | Maximum errors allowed within the time window | **How it works:** - When a user enters an incorrect extraction code, the system records the error count for that IP - When error count reaches `errorCount`, that IP will be temporarily locked - Lock duration is `errorMinute` minutes - During lockout, all extraction requests from that IP will be rejected **Configuration example:** ```python # Anti-brute-force configuration: Max 3 errors in 5 minutes { "errorMinute": 5, "errorCount": 3 } ``` ::: warning Note The default configuration `errorMinute=1, errorCount=1` is very strict, meaning you need to wait 1 minute after entering one incorrect extraction code before retrying. Adjust this configuration based on actual needs. ::: ## Upload Restrictions ### File Size Limit | Setting | Type | Default | Description | |---------|------|---------|-------------| | `uploadSize` | int | `10485760` | Maximum single file upload size (bytes), default 10MB | | `openUpload` | int | `1` | Enable upload functionality (1=enabled, 0=disabled) | **Common size conversions:** - 10MB = 10 * 1024 * 1024 = `10485760` - 50MB = 50 * 1024 * 1024 = `52428800` - 100MB = 100 * 1024 * 1024 = `104857600` - 1GB = 1024 * 1024 * 1024 = `1073741824` ### File Expiration Settings Through file expiration mechanisms, you can automatically clean up expired files, reducing storage usage and security risks: | Setting | Type | Default | Description | |---------|------|---------|-------------| | `expireStyle` | list | `["day","hour","minute","forever","count"]` | Available expiration methods | | `max_save_seconds` | int | `0` | Maximum file retention time (seconds), 0 means no limit | **Expiration methods explained:** - `day` - Expire by days - `hour` - Expire by hours - `minute` - Expire by minutes - `forever` - Never expire (requires alphanumeric extraction code) - `count` - Expire by download count **Security recommendations:** For public services, it's recommended to: 1. Remove the `forever` option to avoid permanent file storage 2. Set `max_save_seconds` to limit maximum retention time 3. Prefer using `count` method for automatic deletion after download ```python # Recommended configuration for public services { "expireStyle": ["hour", "minute", "count"], "max_save_seconds": 86400 # Max retention 1 day } ``` ### Disable Upload Functionality In some cases, you may need to temporarily disable upload functionality: ```python { "openUpload": 0 # Disable upload functionality } ``` ## Reverse Proxy Security Configuration In production environments, Nginx or other reverse proxy servers are typically used. Here are security configuration recommendations: ### Nginx Configuration Example ```nginx server { listen 80; server_name your-domain.com; # Force HTTPS redirect return 301 https://$server_name$request_uri; } server { listen 443 ssl http2; server_name your-domain.com; # SSL certificate configuration ssl_certificate /path/to/cert.pem; ssl_certificate_key /path/to/key.pem; ssl_protocols TLSv1.2 TLSv1.3; ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256; ssl_prefer_server_ciphers on; # Security headers add_header X-Frame-Options "SAMEORIGIN" always; add_header X-Content-Type-Options "nosniff" always; add_header X-XSS-Protection "1; mode=block" always; add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; # Limit request body size (match uploadSize configuration) client_max_body_size 100M; # Pass real IP location / { proxy_pass http://127.0.0.1:12345; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; } # Static resource caching location /assets { proxy_pass http://127.0.0.1:12345; proxy_cache_valid 200 7d; add_header Cache-Control "public, max-age=604800"; } } ``` ### Key Security Configuration Notes **1. Pass Real IP** FileCodeBox's IP limiting functionality depends on obtaining the client's real IP. The system obtains IP in the following order: 1. `X-Real-IP` request header 2. `X-Forwarded-For` request header 3. Direct client connection IP Ensure the reverse proxy correctly sets these headers: ```nginx proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; ``` **2. Request Body Size Limit** Nginx's `client_max_body_size` should match or be slightly larger than FileCodeBox's `uploadSize` configuration: ```nginx client_max_body_size 100M; # Allow max 100MB uploads ``` **3. HTTPS Encryption** It's strongly recommended to enable HTTPS in production environments: - Protect uploaded file content - Protect admin login credentials - Prevent man-in-the-middle attacks ### Caddy Configuration Example ```nginx your-domain.com { reverse_proxy localhost:12345 header { X-Frame-Options "SAMEORIGIN" X-Content-Type-Options "nosniff" X-XSS-Protection "1; mode=block" Strict-Transport-Security "max-age=31536000; includeSubDomains" } } ``` ## Security Checklist Before deploying FileCodeBox, confirm the following security configurations: - [ ] Changed default admin password `admin_token` - [ ] Hidden admin entry `showAdminAddr: 0` - [ ] Configured appropriate upload rate limiting - [ ] Configured error rate limiting to prevent brute-force attacks - [ ] Set reasonable file size limits - [ ] Configured file expiration policy - [ ] Enabled HTTPS encryption - [ ] Reverse proxy correctly passes real IP - [ ] Set security response headers ## Recommended Security Configurations ### Public Service Configuration ```python { "admin_token": "your-very-secure-password", "showAdminAddr": 0, "uploadSize": 10485760, # 10MB "uploadMinute": 1, "uploadCount": 5, "errorMinute": 5, "errorCount": 3, "expireStyle": ["hour", "minute", "count"], "max_save_seconds": 86400, # Max 1 day "openUpload": 1 } ``` ### Internal Service Configuration ```python { "admin_token": "internal-secure-password", "showAdminAddr": 1, "uploadSize": 104857600, # 100MB "uploadMinute": 5, "uploadCount": 50, "errorMinute": 1, "errorCount": 5, "expireStyle": ["day", "hour", "forever"], "max_save_seconds": 0, # No limit "openUpload": 1 } ``` ## Next Steps - [Configuration Guide](/en/guide/configuration) - Learn about all configuration options - [Storage Configuration](/en/guide/storage) - Configure secure storage backends - [File Sharing](/en/guide/share) - Learn about file sharing features ================================================ FILE: docs/en/guide/share.md ================================================ # File Sharing FileCodeBox provides simple and easy-to-use file and text sharing functionality. Users can securely share and retrieve files using extraction codes. ## Sharing Methods FileCodeBox supports two sharing methods: 1. **Text Sharing** - Share text content directly, suitable for code snippets, configuration files, etc. 2. **File Sharing** - Upload files for sharing, supports various file formats ## Text Sharing ### How to Use 1. Select the "Text Share" tab on the homepage 2. Enter or paste the content to share in the text box 3. Select expiration method and time 4. Click the "Share" button 5. Get the extraction code ### Text Size Limit ::: warning Note The maximum content size for text sharing is **222KB** (227,328 bytes). If content exceeds this limit, it's recommended to use file sharing instead. ::: Text content size is calculated using UTF-8 encoding. Chinese characters typically occupy 3 bytes. ### API Endpoint **POST** `/share/text/` Request parameters: | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | `text` | string | Yes | Text content to share | | `expire_value` | int | No | Expiration value, default 1 | | `expire_style` | string | No | Expiration method, default `day` | Response example: ```json { "code": 200, "detail": { "code": "123456" } } ``` ## File Sharing ### How to Use 1. Select the "File Share" tab on the homepage 2. Click the upload area or drag files to the upload area 3. Select expiration method and time 4. Click the "Upload" button 5. Get the extraction code ### File Size Limit The default maximum single file upload size is **10MB**. Administrators can modify this limit via the `uploadSize` configuration. ::: tip Tip If you need to upload large files, contact the administrator to enable chunked upload functionality or adjust the `uploadSize` configuration. ::: ### Supported Upload Methods - **Click Upload** - Click the upload area to select files - **Drag Upload** - Drag files to the upload area - **Paste Upload** - Paste images from clipboard (supported by some themes) ### API Endpoint **POST** `/share/file/` Request parameters (multipart/form-data): | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | `file` | file | Yes | File to upload | | `expire_value` | int | No | Expiration value, default 1 | | `expire_style` | string | No | Expiration method, default `day` | Response example: ```json { "code": 200, "detail": { "code": "654321", "name": "example.pdf" } } ``` ## Expiration Settings FileCodeBox supports multiple flexible expiration methods: | Expiration Method | Parameter Value | Description | |-------------------|-----------------|-------------| | By Days | `day` | File expires after specified days | | By Hours | `hour` | File expires after specified hours | | By Minutes | `minute` | File expires after specified minutes | | Never Expire | `forever` | File is permanently valid | | By Count | `count` | File expires after specified download count | ::: info Note - Administrators can control available expiration methods via the `expireStyle` configuration - Administrators can limit maximum file retention time via the `max_save_seconds` configuration ::: ### Expiration Method Examples ```bash # File expires after 3 days expire_value=3, expire_style=day # File expires after 12 hours expire_value=12, expire_style=hour # File expires after 30 minutes expire_value=30, expire_style=minute # File never expires expire_value=1, expire_style=forever # File expires after 5 downloads expire_value=5, expire_style=count ``` ## Retrieving Files ### How to Use 1. Enter the extraction code in the "Retrieve File" area on the homepage 2. Click the "Retrieve" button 3. System displays file information (filename, size, etc.) 4. Click the "Download" button to download the file, or view text content directly ### Extraction Code Notes - Extraction codes are typically **6-digit numbers** - Files that never expire use **alphanumeric** extraction codes - Extraction codes are case-sensitive (for alphanumeric codes) ### API Endpoints **Query File Information** **POST** `/share/select/` Request parameters: ```json { "code": "123456" } ``` Response example (file): ```json { "code": 200, "detail": { "code": "123456", "name": "example.pdf", "size": 1048576, "text": "https://example.com/download/..." } } ``` Response example (text): ```json { "code": 200, "detail": { "code": "123456", "name": "Text", "size": 1024, "text": "This is the shared text content..." } } ``` **Direct File Download** **GET** `/share/select/?code=123456` This endpoint returns file content directly, suitable for direct browser access. ## Chunked Upload (Large Files) For large file uploads, FileCodeBox supports chunked upload functionality. This feature requires administrator enablement (`enableChunk=1`). ### Chunked Upload Flow ```mermaid sequenceDiagram participant C as Client participant S as Server C->>S: 1. Initialize upload (POST /chunk/upload/init/) S-->>C: Return upload_id and chunk info loop Each chunk C->>S: 2. Upload chunk (POST /chunk/upload/chunk/{upload_id}/{chunk_index}) S-->>C: Return chunk hash end C->>S: 3. Complete upload (POST /chunk/upload/complete/{upload_id}) S-->>C: Return extraction code ``` ### 1. Initialize Upload **POST** `/chunk/upload/init/` Request parameters: ```json { "file_name": "large_file.zip", "file_size": 104857600, "chunk_size": 5242880, "file_hash": "sha256_hash_of_file" } ``` | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | `file_name` | string | Yes | Filename | | `file_size` | int | Yes | Total file size (bytes) | | `chunk_size` | int | No | Chunk size, default 5MB | | `file_hash` | string | Yes | SHA256 hash of the file | Response example: ```json { "code": 200, "detail": { "existed": false, "upload_id": "abc123def456", "chunk_size": 5242880, "total_chunks": 20, "uploaded_chunks": [] } } ``` ### 2. Upload Chunk **POST** `/chunk/upload/chunk/{upload_id}/{chunk_index}` - `upload_id` - Upload session ID returned during initialization - `chunk_index` - Chunk index, starting from 0 Request body: Chunk file data (multipart/form-data) Response example: ```json { "code": 200, "detail": { "chunk_hash": "sha256_hash_of_chunk" } } ``` ### 3. Complete Upload **POST** `/chunk/upload/complete/{upload_id}` Request parameters: ```json { "expire_value": 1, "expire_style": "day" } ``` Response example: ```json { "code": 200, "detail": { "code": "789012", "name": "large_file.zip" } } ``` ### Resume Upload Chunked upload supports resume functionality. If upload is interrupted: 1. Call the initialization endpoint again with the same `file_hash` 2. Server returns `uploaded_chunks` list containing already uploaded chunk indices 3. Client only needs to upload chunks not in the list ## Error Handling ### Common Error Codes | Error Code | Description | Solution | |------------|-------------|----------| | 403 | File size exceeds limit | Reduce file size or contact administrator to adjust limit | | 403 | Content too large | Text exceeds 222KB, use file sharing instead | | 403 | Upload rate limit | Wait a while before retrying | | 404 | File not found | Check if extraction code is correct | | 404 | File expired | File has expired or download count exhausted | ### Rate Limiting To prevent abuse, the system has rate limits on upload and retrieval operations: - **Upload limit**: Default max 10 uploads per minute - **Error limit**: Default max 1 error attempt per minute ::: tip Tip If you encounter rate limiting, wait for the limit time window to pass before retrying. ::: ## Next Steps - [Configuration Guide](/en/guide/configuration) - Learn how to configure sharing-related settings - [Storage Configuration](/en/guide/storage) - Learn about file storage methods - [Security Settings](/en/guide/security) - Learn about security-related configurations - [Admin Panel](/en/guide/management) - Learn how to manage shared files ================================================ FILE: docs/en/guide/storage.md ================================================ # Storage Configuration FileCodeBox supports multiple storage backends. You can choose the appropriate storage method based on your needs. This document details the configuration methods for various storage backends. ## Storage Types Overview | Storage Type | Config Value | Description | |--------------|--------------|-------------| | Local Storage | `local` | Default storage method, files saved on local server | | S3-Compatible Storage | `s3` | Supports AWS S3, Aliyun OSS, MinIO, etc. | | OneDrive | `onedrive` | Microsoft OneDrive cloud storage (work/school accounts only) | | WebDAV | `webdav` | Storage services supporting WebDAV protocol | | OpenDAL | `opendal` | Integrate more storage services via OpenDAL | ## Local Storage Local storage is the default storage method. Files are saved in the server's `data/` directory. ### Configuration Parameters | Parameter | Type | Default | Description | |-----------|------|---------|-------------| | `file_storage` | string | `local` | Storage type | | `storage_path` | string | `""` | Custom storage path (optional) | ### Configuration Example ```bash file_storage=local storage_path= ``` ### Notes - Files are stored by default in `data/share/data/` directory - Subdirectories are automatically created by date: `year/month/day/fileID/` - In production, it's recommended to mount the `data/` directory to persistent storage ## S3-Compatible Storage Supports all S3-compatible object storage services, including AWS S3, Aliyun OSS, MinIO, Tencent Cloud COS, etc. ### Configuration Parameters | Parameter | Type | Default | Description | |-----------|------|---------|-------------| | `file_storage` | string | - | Set to `s3` | | `s3_access_key_id` | string | `""` | Access Key ID | | `s3_secret_access_key` | string | `""` | Secret Access Key | | `s3_bucket_name` | string | `""` | Bucket name | | `s3_endpoint_url` | string | `""` | S3 endpoint URL | | `s3_region_name` | string | `auto` | Region name | | `s3_signature_version` | string | `s3v2` | Signature version (`s3v2` or `s3v4`) | | `s3_hostname` | string | `""` | S3 hostname (alternative) | | `s3_proxy` | int | `0` | Download through server proxy (1=yes, 0=no) | | `aws_session_token` | string | `""` | AWS session token (optional) | ### AWS S3 Configuration Example ```bash file_storage=s3 s3_access_key_id=AKIAIOSFODNN7EXAMPLE s3_secret_access_key=wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY s3_bucket_name=my-filecodebox-bucket s3_endpoint_url=https://s3.amazonaws.com s3_region_name=us-east-1 s3_signature_version=s3v4 ``` ### Aliyun OSS Configuration Example ```bash file_storage=s3 s3_access_key_id=YourAccessKeyId s3_secret_access_key=YourSecretAccessKey s3_bucket_name=bucket-name s3_endpoint_url=https://bucket-name.oss-cn-hangzhou.aliyuncs.com s3_region_name=oss-cn-hangzhou s3_signature_version=s3v4 ``` ::: tip Aliyun OSS Endpoint Format Endpoint URL format: `https://..aliyuncs.com` Common regions: - Hangzhou: `oss-cn-hangzhou` - Shanghai: `oss-cn-shanghai` - Beijing: `oss-cn-beijing` - Shenzhen: `oss-cn-shenzhen` ::: ### MinIO Configuration Example ```bash file_storage=s3 s3_access_key_id=minioadmin s3_secret_access_key=minioadmin s3_bucket_name=filecodebox s3_endpoint_url=http://localhost:9000 s3_region_name=us-east-1 s3_signature_version=s3v4 ``` ::: warning MinIO Notes - `s3_endpoint_url` should be the MinIO API interface address - `s3_region_name` should match the `Server Location` in MinIO configuration - Ensure the bucket is created and has correct access permissions ::: ### Tencent Cloud COS Configuration Example ```bash file_storage=s3 s3_access_key_id=YourSecretId s3_secret_access_key=YourSecretKey s3_bucket_name=bucket-name-1250000000 s3_endpoint_url=https://cos.ap-guangzhou.myqcloud.com s3_region_name=ap-guangzhou s3_signature_version=s3v4 ``` ### Proxy Download When `s3_proxy=1`, file downloads are proxied through the server instead of directly from S3. This is useful when: - S3 bucket doesn't allow public access - Need to hide the actual storage address - Network environment restricts direct S3 access ## OneDrive Storage OneDrive storage supports saving files to Microsoft OneDrive cloud storage. ::: warning Important Limitation OneDrive storage **only supports work or school accounts** and requires admin permissions to authorize the API. Personal accounts cannot use this feature. ::: ### Configuration Parameters | Parameter | Type | Default | Description | |-----------|------|---------|-------------| | `file_storage` | string | - | Set to `onedrive` | | `onedrive_domain` | string | `""` | Azure AD domain | | `onedrive_client_id` | string | `""` | Application (client) ID | | `onedrive_username` | string | `""` | Account email | | `onedrive_password` | string | `""` | Account password | | `onedrive_root_path` | string | `filebox_storage` | Storage root directory in OneDrive | | `onedrive_proxy` | int | `0` | Download through server proxy | ### Configuration Example ```bash file_storage=onedrive onedrive_domain=contoso.onmicrosoft.com onedrive_client_id=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx onedrive_username=user@contoso.onmicrosoft.com onedrive_password=your_password onedrive_root_path=filebox_storage ``` ### Azure App Registration Steps To use OneDrive storage, you need to register an application in the Azure portal: #### 1. Get Domain Log in to [Azure Portal](https://portal.azure.com/#view/Microsoft_AAD_RegisteredApps/ApplicationsListBlade), hover over your account in the top right corner, and the **Domain** shown in the popup is the `onedrive_domain` value. #### 2. Register Application 1. Click **+ New registration** in the top left 2. Enter application name (e.g., FileCodeBox) 3. **Supported account types**: Select "Accounts in any organizational directory (Any Azure AD directory - Multitenant) and personal Microsoft accounts" 4. **Redirect URI**: Select `Web`, enter `http://localhost` 5. Click **Register** #### 3. Get Client ID After registration, find the **Application (client) ID** in the **Essentials** section on the app overview page. This is the `onedrive_client_id` value. #### 4. Configure Authentication 1. Select **Authentication** in the left menu 2. Find **Allow public client flows**, select **Yes** 3. Click **Save** #### 5. Configure API Permissions 1. Select **API permissions** in the left menu 2. Click **+ Add a permission** 3. Select **Microsoft Graph** → **Delegated permissions** 4. Check the following permissions: - `openid` - `Files.Read` - `Files.Read.All` - `Files.ReadWrite` - `Files.ReadWrite.All` - `User.Read` 5. Click **Add permissions** 6. Click **Grant admin consent for xxx** 7. After confirmation, permission status should show **Granted** ### Install Dependencies Using OneDrive storage requires additional Python dependencies: ```bash pip install msal Office365-REST-Python-Client ``` ### Verify Configuration You can use the following code to test if the configuration is correct: ```python import msal from office365.graph_client import GraphClient domain = 'your_domain' client_id = 'your_client_id' username = 'your_username' password = 'your_password' def acquire_token_pwd(): authority_url = f'https://login.microsoftonline.com/{domain}' app = msal.PublicClientApplication( authority=authority_url, client_id=client_id ) result = app.acquire_token_by_username_password( username=username, password=password, scopes=['https://graph.microsoft.com/.default'] ) return result # Test connection client = GraphClient(acquire_token_pwd) me = client.me.get().execute_query() print(f"Login successful: {me.user_principal_name}") ``` ## WebDAV Storage WebDAV storage supports saving files to any service that supports the WebDAV protocol, such as Nextcloud, ownCloud, Nutstore, etc. ### Configuration Parameters | Parameter | Type | Default | Description | |-----------|------|---------|-------------| | `file_storage` | string | - | Set to `webdav` | | `webdav_url` | string | `""` | WebDAV server URL | | `webdav_username` | string | `""` | WebDAV username | | `webdav_password` | string | `""` | WebDAV password | | `webdav_root_path` | string | `filebox_storage` | Storage root directory in WebDAV | | `webdav_proxy` | int | `0` | Download through server proxy | ### Configuration Example ```bash file_storage=webdav webdav_url=https://dav.example.com/remote.php/dav/files/username/ webdav_username=your_username webdav_password=your_password webdav_root_path=filebox_storage ``` ### Nextcloud Configuration Example ```bash file_storage=webdav webdav_url=https://your-nextcloud.com/remote.php/dav/files/username/ webdav_username=your_username webdav_password=your_app_password webdav_root_path=FileCodeBox ``` ::: tip Nextcloud App Password It's recommended to create an app password in Nextcloud instead of using your main password: 1. Log in to Nextcloud 2. Go to **Settings** → **Security** 3. Create a new app password in **Devices & sessions** ::: ### Nutstore Configuration Example ```bash file_storage=webdav webdav_url=https://dav.jianguoyun.com/dav/ webdav_username=your_email@example.com webdav_password=your_app_password webdav_root_path=FileCodeBox ``` ::: tip Nutstore App Password Nutstore requires an app password: 1. Log in to Nutstore web version 2. Go to **Account Info** → **Security Options** 3. Add an app password ::: ## OpenDAL Storage OpenDAL is a unified data access layer that supports multiple storage services. Through OpenDAL, you can use Google Cloud Storage, Azure Blob Storage, and more. ### Configuration Parameters | Parameter | Type | Description | |-----------|------|-------------| | `file_storage` | string | Set to `opendal` | | `opendal_scheme` | string | Storage service type (e.g., `gcs`, `azblob`) | | `opendal__` | string | Service-specific configuration parameters | ### Install Dependencies ```bash pip install opendal ``` ### Google Cloud Storage Configuration Example ```bash file_storage=opendal opendal_scheme=gcs opendal_gcs_root=/filecodebox opendal_gcs_bucket=your-bucket-name opendal_gcs_credential=base64_encoded_credential ``` ### Azure Blob Storage Configuration Example ```bash file_storage=opendal opendal_scheme=azblob opendal_azblob_root=/filecodebox opendal_azblob_container=your-container opendal_azblob_account_name=your_account opendal_azblob_account_key=your_key ``` ### Supported Services OpenDAL supports numerous storage services. For the complete list, see the [OpenDAL Official Documentation](https://opendal.apache.org/docs/rust/opendal/services/index.html). Common services include: - `gcs` - Google Cloud Storage - `azblob` - Azure Blob Storage - `obs` - Huawei Cloud OBS - `oss` - Aliyun OSS (via OpenDAL) - `cos` - Tencent Cloud COS (via OpenDAL) - `hdfs` - Hadoop HDFS - `ftp` - FTP server - `sftp` - SFTP server ::: warning OpenDAL Notes 1. Services integrated via OpenDAL download through server proxy, consuming both storage service and server bandwidth 2. Compared to native S3/OneDrive support, OpenDAL may lack some debugging information 3. OpenDAL is written in Rust with good performance ::: ## Storage Selection Recommendations | Scenario | Recommended Storage | Reason | |----------|---------------------|--------| | Personal/Small deployment | Local storage | Simple and easy, no extra configuration needed | | Enterprise intranet | MinIO + S3 | Self-hosted object storage, data control | | Public cloud deployment | Corresponding cloud provider S3 | Fast same-region access, low cost | | Existing OneDrive | OneDrive | Utilize existing resources | | Existing WebDAV | WebDAV | Good compatibility | | Special storage needs | OpenDAL | Supports more storage services | ## Common Issues ### S3 Upload Failure 1. Check if Access Key and Secret Key are correct 2. Confirm bucket name and region configuration are correct 3. Check bucket access permission settings 4. Confirm signature version (`s3v2` or `s3v4`) matches service provider requirements ### OneDrive Authentication Failure 1. Confirm using a work/school account, not a personal account 2. Check if Azure app has been granted admin consent 3. Confirm API permissions are fully configured 4. Verify username and password are correct ### WebDAV Connection Failure 1. Check if WebDAV URL format is correct 2. Confirm username and password (or app password) are correct 3. Check if server supports WebDAV protocol 4. Confirm network connection is normal ================================================ FILE: docs/en/guide/upload.md ================================================ # File Upload FileCodeBox provides multiple flexible file upload methods, supporting both regular upload and chunked upload to meet different scenario requirements. ## Upload Methods FileCodeBox supports the following upload methods: ### Drag and Drop Upload Drag files directly to the upload area to start uploading. This is the most convenient upload method. 1. Open the FileCodeBox homepage 2. Drag files from your file manager to the upload area 3. Release the mouse, file upload begins 4. Get the extraction code after upload completes ::: tip Tip Drag and drop upload supports dragging multiple files simultaneously (depending on theme support). ::: ### Click Upload Click the upload area to select files through the system file picker. 1. Click the "Select File" button in the upload area 2. Select the file to upload in the popup file picker 3. File upload begins after confirming selection 4. Get the extraction code after upload completes ### Paste Upload Supports pasting images directly from clipboard for upload (supported by some themes). 1. Copy an image to clipboard (screenshot or copy image) 2. Use `Ctrl+V` (Windows/Linux) or `Cmd+V` (macOS) to paste in the upload area 3. Image upload starts automatically 4. Get the extraction code after upload completes ::: warning Note Paste upload only supports image formats, not other file types. Specific support depends on the theme being used. ::: ## File Size Limits ### Default Limits | Setting | Default | Description | |---------|---------|-------------| | `uploadSize` | 10MB | Maximum single file upload size | ### Modify Upload Limits Administrators can modify upload size limits through the admin panel or configuration file: ```python # Set maximum upload size to 100MB uploadSize = 104857600 # 100 * 1024 * 1024 ``` ::: info Note `uploadSize` is in bytes. Common conversions: - 10MB = 10485760 - 50MB = 52428800 - 100MB = 104857600 - 500MB = 524288000 - 1GB = 1073741824 ::: ### Exceeding Limit Handling When an uploaded file exceeds the size limit, the system returns a 403 error: ```json { "detail": "Size exceeds limit, maximum is 10.00 MB" } ``` ## Regular Upload API ### File Upload Endpoint **POST** `/share/file/` Content-Type: `multipart/form-data` **Request parameters:** | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | `file` | file | Yes | File to upload | | `expire_value` | int | No | Expiration value, default 1 | | `expire_style` | string | No | Expiration method, default `day` | **Expiration method options:** | Value | Description | |-------|-------------| | `day` | Expire by days | | `hour` | Expire by hours | | `minute` | Expire by minutes | | `forever` | Never expire | | `count` | Expire by download count | **Response example:** ```json { "code": 200, "detail": { "code": "654321", "name": "example.pdf" } } ``` **cURL example:** ```bash # Upload file (default 1 day expiration) curl -X POST "http://localhost:12345/share/file/" \ -F "file=@/path/to/file.pdf" # Upload file with 7 days expiration curl -X POST "http://localhost:12345/share/file/" \ -F "file=@/path/to/file.pdf" \ -F "expire_value=7" \ -F "expire_style=day" # Upload file with 10 downloads limit curl -X POST "http://localhost:12345/share/file/" \ -F "file=@/path/to/file.pdf" \ -F "expire_value=10" \ -F "expire_style=count" # Share text curl -X POST "http://localhost:12345/share/text/" \ -F "text=This is the text content to share" # Download file by extraction code curl -L "http://localhost:12345/share/select/?code=YOUR_CODE" -o downloaded_file ``` ::: tip When Authentication Required If guest upload is disabled in admin panel (`openUpload=0`), you need to login first: ```bash # 1. Login to get token curl -X POST "http://localhost:12345/admin/login" \ -H "Content-Type: application/json" \ -d '{"password": "FileCodeBox2023"}' # Returns: {"code":200,"msg":"success","detail":{"token":"xxx.xxx.xxx","token_type":"Bearer"}} # 2. Upload file with token curl -X POST "http://localhost:12345/share/file/" \ -H "Authorization: Bearer xxx.xxx.xxx" \ -F "file=@/path/to/file.pdf" # 3. Share text with token curl -X POST "http://localhost:12345/share/text/" \ -H "Authorization: Bearer xxx.xxx.xxx" \ -F "text=This is the text content to share" ``` ::: ## Chunked Upload API For large files, FileCodeBox supports chunked upload functionality. Chunked upload splits large files into multiple small chunks for separate uploading, supporting resume capability. ::: warning Prerequisite Chunked upload functionality requires administrator enablement: `enableChunk=1` ::: ### Chunked Upload Flow ``` ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ Initialize │ ──▶ │Upload Chunks│ ──▶ │ Complete │ │ /init/ │ │ /chunk/ │ │ /complete/ │ └─────────────┘ └─────────────┘ └─────────────┘ │ ▼ ┌───────────┐ │ Loop │ │each chunk │ └───────────┘ ``` ### 1. Initialize Upload **POST** `/chunk/upload/init/` **Request parameters:** ```json { "file_name": "large_file.zip", "file_size": 104857600, "chunk_size": 5242880, "file_hash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" } ``` | Parameter | Type | Required | Default | Description | |-----------|------|----------|---------|-------------| | `file_name` | string | Yes | - | Filename | | `file_size` | int | Yes | - | Total file size (bytes) | | `chunk_size` | int | No | 5MB | Chunk size (bytes) | | `file_hash` | string | Yes | - | SHA256 hash of the file | **Response example:** ```json { "code": 200, "detail": { "existed": false, "upload_id": "abc123def456789", "chunk_size": 5242880, "total_chunks": 20, "uploaded_chunks": [] } } ``` | Field | Description | |-------|-------------| | `existed` | Whether file already exists (instant upload) | | `upload_id` | Upload session ID | | `chunk_size` | Chunk size | | `total_chunks` | Total number of chunks | | `uploaded_chunks` | List of already uploaded chunk indices | ### 2. Upload Chunk **POST** `/chunk/upload/chunk/{upload_id}/{chunk_index}` **Path parameters:** | Parameter | Description | |-----------|-------------| | `upload_id` | Upload session ID returned during initialization | | `chunk_index` | Chunk index, starting from 0 | **Request body:** Content-Type: `multipart/form-data` | Parameter | Type | Description | |-----------|------|-------------| | `chunk` | file | Chunk data | **Response example:** ```json { "code": 200, "detail": { "chunk_hash": "a1b2c3d4e5f6..." } } ``` **cURL example:** ```bash # Upload first chunk (index 0) curl -X POST "http://localhost:12345/chunk/upload/chunk/abc123def456789/0" \ -F "chunk=@/path/to/chunk_0" ``` ### 3. Complete Upload **POST** `/chunk/upload/complete/{upload_id}` **Path parameters:** | Parameter | Description | |-----------|-------------| | `upload_id` | Upload session ID | **Request parameters:** ```json { "expire_value": 7, "expire_style": "day" } ``` | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | `expire_value` | int | Yes | Expiration value | | `expire_style` | string | Yes | Expiration method | **Response example:** ```json { "code": 200, "detail": { "code": "789012", "name": "large_file.zip" } } ``` ### Resume Upload Chunked upload supports resume functionality. When upload is interrupted: 1. Call the initialization endpoint again with the same `file_hash` 2. Server returns `uploaded_chunks` list containing already uploaded chunk indices 3. Client only needs to upload chunks not in the list 4. Call the complete endpoint after all chunks are uploaded **Example flow:** ```javascript // 1. Initialize upload const initResponse = await fetch('/chunk/upload/init/', { method: 'POST', body: JSON.stringify({ file_name: 'large_file.zip', file_size: fileSize, chunk_size: 5 * 1024 * 1024, file_hash: fileHash }) }); const { upload_id, uploaded_chunks, total_chunks } = await initResponse.json(); // 2. Upload incomplete chunks for (let i = 0; i < total_chunks; i++) { if (!uploaded_chunks.includes(i)) { const chunk = file.slice(i * chunkSize, (i + 1) * chunkSize); await fetch(`/chunk/upload/chunk/${upload_id}/${i}`, { method: 'POST', body: chunk }); } } // 3. Complete upload await fetch(`/chunk/upload/complete/${upload_id}`, { method: 'POST', body: JSON.stringify({ expire_value: 7, expire_style: 'day' }) }); ``` ## Error Handling ### Common Errors | HTTP Status | Error Message | Cause | Solution | |-------------|---------------|-------|----------| | 403 | Size exceeds limit | File exceeds `uploadSize` limit | Reduce file size or contact administrator to adjust limit | | 403 | Upload rate limit | Exceeded IP upload rate limit | Wait for limit time window before retrying | | 400 | Invalid expiration type | `expire_style` value not in allowed list | Use a valid expiration method | | 404 | Upload session not found | `upload_id` invalid or expired | Re-initialize upload | | 400 | Invalid chunk index | `chunk_index` out of range | Check if chunk index is correct | | 400 | Incomplete chunks | Chunk count insufficient when completing upload | Ensure all chunks are uploaded | ### Rate Limiting The system has rate limits on upload operations to prevent abuse: | Setting | Default | Description | |---------|---------|-------------| | `uploadMinute` | 1 | Limit time window (minutes) | | `uploadCount` | 10 | Maximum uploads within time window | When rate limit is exceeded, you need to wait for the time window to pass before continuing uploads. ### Error Response Format ```json { "detail": "Error message description" } ``` ## Upload Configuration ### Related Settings | Setting | Type | Default | Description | |---------|------|---------|-------------| | `openUpload` | int | 1 | Enable upload (1=enabled, 0=disabled) | | `uploadSize` | int | 10485760 | Maximum upload size (bytes) | | `enableChunk` | int | 0 | Enable chunked upload (1=enabled, 0=disabled) | | `uploadMinute` | int | 1 | Upload rate limit time window (minutes) | | `uploadCount` | int | 10 | Maximum uploads within time window | | `expireStyle` | list | ["day","hour","minute","forever","count"] | Allowed expiration methods | ### Configuration Example ```python # Allow 100MB file uploads, enable chunked upload uploadSize = 104857600 enableChunk = 1 # Relax upload rate limit: max 50 uploads per 5 minutes uploadMinute = 5 uploadCount = 50 # Only allow expiration by days and count expireStyle = ["day", "count"] ``` ## Next Steps - [File Sharing](/en/guide/share) - Learn the complete sharing process - [Configuration Guide](/en/guide/configuration) - Learn about all configuration options - [Storage Configuration](/en/guide/storage) - Learn about file storage methods - [Security Settings](/en/guide/security) - Learn about security-related configurations ================================================ FILE: docs/en/index.md ================================================ --- layout: home hero: name: "FileCodeBox" text: "File Express Box" tagline: Share text and files anonymously with access codes, just like picking up a package image: src: /logo_small.png alt: FileCodeBox actions: - theme: brand text: Get Started link: /en/guide/getting-started - theme: alt text: Live Demo link: https://share.lanol.cn - theme: alt text: View on GitHub link: https://github.com/vastsa/FileCodeBox features: - icon: 🚀 title: Quick Deployment details: Supports one-click Docker deployment, simple and fast, no complex configuration needed - icon: 🔒 title: Secure & Reliable details: File access requires an access code, supports expiration time and download limit settings - icon: 💻 title: Clean Interface details: Clean user interface with drag-and-drop upload support for excellent user experience - icon: 🛠️ title: Feature Rich details: Supports file preview, online playback, image processing, and many other features - icon: 📦 title: Storage Extensions details: Supports various storage methods including local storage and object storage - icon: 🔌 title: API Support details: Provides complete REST API for easy integration with other systems --- ================================================ FILE: docs/en/showcase.md ================================================ # Showcase Here are some excellent sites built with FileCodeBox. If you've deployed FileCodeBox, feel free to submit a PR to add your site here! ## Official Demo
### 🌟 FileCodeBox Demo - **URL**: [share.lanol.cn](https://share.lanol.cn) - **Description**: Official demo site with the latest features - **Highlights**: Stable, full-featured
## Community Sites ::: tip Submit Your Site If you've built your own file sharing service using FileCodeBox, you can submit it through: 1. Submit a PR on [GitHub](https://github.com/vastsa/FileCodeBox) to edit this page 2. Open an [Issue](https://github.com/vastsa/FileCodeBox/issues) with your site info 3. Join QQ Group 739673698 to contact the admin :::
### QuWenJian - **URL**: [www.quwenjian.cn/fby.html](https://www.quwenjian.cn/fby.html) - **Description**: QuWenJian - Unlimited storage, portable and limitless - **Highlights**: Free file transfer station - **Operator**: QuWenJian
### Pandora Box - **URL**: [pan.duo.la](https://pan.duo.la) - **Description**: Pandora Box - **Highlights**: Classic v1.6 version - **Operator**: WuXingQueXinYan
## Submission Requirements To ensure quality, please make sure your site meets the following criteria: 1. **Stable**: Your site should be stable and accessible 2. **Legal**: Content must be legal and compliant 3. **Attribution**: We recommend keeping FileCodeBox attribution 4. **HTTPS**: We recommend enabling HTTPS ## Submission Template If you want to submit your site, please use the following format: ```markdown ### Site Name - **URL**: [domain](https://domain) - **Description**: One-line description of your site - **Highlights**: Special features or highlights - **Operator**: Optional, your name or organization ``` ================================================ FILE: docs/guide/configuration.md ================================================ # 配置说明 FileCodeBox 提供了丰富的配置选项,可以通过管理面板或直接修改配置来自定义系统行为。本文档详细介绍所有可用的配置项。 ## 配置方式 FileCodeBox 支持两种配置方式: 1. **管理面板配置**(推荐):访问 `/admin` 进入管理面板,在设置页面修改配置 2. **数据库配置**:配置存储在 `data/filecodebox.db` 数据库中 ::: tip 提示 首次启动时,系统会使用 `core/settings.py` 中的默认配置。修改后的配置会保存到数据库中。 ::: ## 基础设置 ### 站点信息 | 配置项 | 类型 | 默认值 | 说明 | |--------|------|--------|------| | `name` | string | `文件快递柜 - FileCodeBox` | 站点名称,显示在页面标题和导航栏 | | `description` | string | `开箱即用的文件快传系统` | 站点描述,用于 SEO | | `keywords` | string | `FileCodeBox, 文件快递柜...` | 站点关键词,用于 SEO | | `port` | int | `12345` | 服务监听端口 | ### 通知设置 | 配置项 | 类型 | 默认值 | 说明 | |--------|------|--------|------| | `notify_title` | string | `系统通知` | 通知标题 | | `notify_content` | string | 欢迎信息 | 通知内容,支持 HTML | | `page_explain` | string | 法律声明 | 页面底部说明文字 | | `robotsText` | string | `User-agent: *\nDisallow: /` | robots.txt 内容 | ## 上传设置 ### 文件上传限制 | 配置项 | 类型 | 默认值 | 说明 | |--------|------|--------|------| | `openUpload` | int | `1` | 是否开启上传功能(1=开启,0=关闭) | | `uploadSize` | int | `10485760` | 单文件最大上传大小(字节),默认 10MB | | `enableChunk` | int | `0` | 是否启用分片上传(1=启用,0=禁用) | ::: warning 注意 `uploadSize` 的单位是字节。10MB = 10 * 1024 * 1024 = 10485760 字节 ::: ### 上传频率限制 | 配置项 | 类型 | 默认值 | 说明 | |--------|------|--------|------| | `uploadMinute` | int | `1` | 上传限制的时间窗口(分钟) | | `uploadCount` | int | `10` | 在时间窗口内允许的最大上传次数 | 例如:默认配置表示每 1 分钟内最多允许上传 10 次。 ### 文件过期设置 | 配置项 | 类型 | 默认值 | 说明 | |--------|------|--------|------| | `expireStyle` | list | `["day","hour","minute","forever","count"]` | 可选的过期方式 | | `max_save_seconds` | int | `0` | 文件最大保存时间(秒),0 表示不限制 | 过期方式说明: - `day` - 按天过期 - `hour` - 按小时过期 - `minute` - 按分钟过期 - `forever` - 永不过期 - `count` - 按下载次数过期 ## 主题设置 ### 主题选择 | 配置项 | 类型 | 默认值 | 说明 | |--------|------|--------|------| | `themesSelect` | string | `themes/2024` | 当前使用的主题 | | `themesChoices` | list | 见下方 | 可用主题列表 | 默认可用主题: ```json [ { "name": "2023", "key": "themes/2023", "author": "Lan", "version": "1.0" }, { "name": "2024", "key": "themes/2024", "author": "Lan", "version": "1.0" } ] ``` ### 界面样式 | 配置项 | 类型 | 默认值 | 说明 | |--------|------|--------|------| | `opacity` | float | `0.9` | 界面透明度(0-1) | | `background` | string | `""` | 自定义背景图片 URL,为空则使用默认背景 | ## 管理员设置 | 配置项 | 类型 | 默认值 | 说明 | |--------|------|--------|------| | `admin_token` | string | `FileCodeBox2023` | 管理员登录密码 | | `showAdminAddr` | int | `0` | 是否在首页显示管理入口(1=显示,0=隐藏) | ::: danger 安全警告 请务必在生产环境中修改默认的 `admin_token`!使用默认密码会导致严重的安全风险。 ::: ## 安全设置 ### 错误次数限制 | 配置项 | 类型 | 默认值 | 说明 | |--------|------|--------|------| | `errorMinute` | int | `1` | 错误限制的时间窗口(分钟) | | `errorCount` | int | `1` | 在时间窗口内允许的最大错误次数 | 此设置用于防止暴力破解提取码。 ## 存储设置 ### 存储类型 | 配置项 | 类型 | 默认值 | 说明 | |--------|------|--------|------| | `file_storage` | string | `local` | 存储后端类型 | | `storage_path` | string | `""` | 自定义存储路径 | 支持的存储类型: - `local` - 本地存储 - `s3` - S3 兼容存储(AWS S3、阿里云 OSS、MinIO 等) - `onedrive` - OneDrive 存储 - `webdav` - WebDAV 存储 - `opendal` - OpenDAL 存储 详细的存储配置请参考 [存储配置](/guide/storage)。 ## 配置示例 ### 示例 1:小型个人使用 适合个人或小团队使用,限制较宽松: ```python { "name": "我的文件分享", "uploadSize": 52428800, # 50MB "uploadMinute": 5, # 5分钟 "uploadCount": 20, # 最多20次 "expireStyle": ["day", "hour", "forever"], "admin_token": "your-secure-password", "showAdminAddr": 1 } ``` ### 示例 2:公开服务 适合公开服务,需要更严格的限制: ```python { "name": "公共文件快递柜", "uploadSize": 10485760, # 10MB "uploadMinute": 1, # 1分钟 "uploadCount": 5, # 最多5次 "errorMinute": 5, # 5分钟 "errorCount": 3, # 最多3次错误 "expireStyle": ["hour", "minute", "count"], "max_save_seconds": 86400, # 最长保存1天 "admin_token": "very-secure-password-123", "showAdminAddr": 0 } ``` ### 示例 3:企业内部使用 适合企业内部使用,支持大文件和分片上传: ```python { "name": "企业文件中转站", "uploadSize": 1073741824, # 1GB "enableChunk": 1, # 启用分片上传 "uploadMinute": 10, # 10分钟 "uploadCount": 100, # 最多100次 "expireStyle": ["day", "forever"], "file_storage": "s3", # 使用S3存储 "admin_token": "enterprise-secure-token", "showAdminAddr": 1 } ``` ## 下一步 - [存储配置](/guide/storage) - 了解如何配置不同的存储后端 - [安全设置](/guide/security) - 了解如何增强系统安全性 - [文件分享](/guide/share) - 了解文件分享功能 ================================================ FILE: docs/guide/getting-started.md ================================================ # 快速开始 ## 简介 FileCodeBox 是一个简单高效的文件分享工具,支持文件临时中转、分享和管理。本指南将帮助您快速部署和使用 FileCodeBox。 ## 特性 - 🚀 快速部署:支持 Docker 一键部署 - 🔒 安全可靠:文件访问需要提取码 - ⏱️ 时效控制:支持设置文件有效期 - 📊 下载限制:可限制文件下载次数 - 🖼️ 文件预览:支持图片、视频、音频等多种格式预览 - 📱 响应式设计:完美适配移动端和桌面端 ## 部署方式 ### Docker 部署(推荐) #### 快速启动 ```bash docker run -d --restart=always -p 12345:12345 -v /opt/FileCodeBox/:/app/data --name filecodebox lanol/filecodebox:latest ``` #### Docker Compose ```yml version: "3" services: file-code-box: image: lanol/filecodebox:latest volumes: - fcb-data:/app/data:rw restart: unless-stopped ports: - "12345:12345" environment: - WORKERS=4 - LOG_LEVEL=info volumes: fcb-data: external: false ``` #### 环境变量 | 变量 | 默认值 | 说明 | |------|--------|------| | `HOST` | `::` | 监听地址,支持 IPv4/IPv6 双栈 | | `PORT` | `12345` | 服务端口 | | `WORKERS` | `4` | 工作进程数,建议设置为 CPU 核心数 | | `LOG_LEVEL` | `info` | 日志级别:debug/info/warning/error | #### 自定义配置示例 ```bash docker run -d --restart=always \ -p 12345:12345 \ -v /opt/FileCodeBox/:/app/data \ -e WORKERS=8 \ -e LOG_LEVEL=warning \ --name filecodebox \ lanol/filecodebox:latest ``` ### 配置反向代理(Nginx) ```nginx location / { proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_pass http://localhost:12345; } ``` ### 手动部署 1. 克隆项目 ```bash git clone https://github.com/vastsa/FileCodeBox.git ``` 2. 安装依赖 ```bash cd FileCodeBox pip install -r requirements.txt ``` 3. 启动服务 ```bash python main.py ``` ## 使用方法 1. 访问系统 打开浏览器访问 `http://localhost:12345` 2. 上传文件 - 点击上传按钮或拖拽文件到上传区域 - 设置文件有效期和下载次数限制 - 获取分享链接和提取码 3. 下载文件 - 访问分享链接 - 输入提取码 - 下载文件 4. 后台管理 - 访问 `http://localhost:12345/#/admin` - 输入管理员密码:`FileCodeBox2023` - 进入后台管理页面 - 查看系统信息、文件列表、用户管理等 ## 下一步 - [存储配置](/guide/storage) - 了解如何配置不同的存储方式 - [安全设置](/guide/security) - 了解如何增强系统安全性 - [API 文档](/api/) - 了解如何通过 API 集成 ================================================ FILE: docs/guide/introduction.md ================================================
FileCodeBox Logo

匿名口令分享文本和文件,像拿快递一样取文件

[![GitHub stars](https://img.shields.io/github/stars/vastsa/FileCodeBox)](https://github.com/vastsa/FileCodeBox/stargazers) [![GitHub forks](https://img.shields.io/github/forks/vastsa/FileCodeBox)](https://github.com/vastsa/FileCodeBox/network) [![GitHub issues](https://img.shields.io/github/issues/vastsa/FileCodeBox)](https://github.com/vastsa/FileCodeBox/issues) [![GitHub license](https://img.shields.io/github/license/vastsa/FileCodeBox)](https://github.com/vastsa/FileCodeBox/blob/master/LICENSE) [![QQ Group](https://img.shields.io/badge/QQ%20Group-739673698-blue.svg)](https://qm.qq.com/q/PemPzhdEIM) [![Python Version](https://img.shields.io/badge/Python-3.8+-blue.svg)](https://www.python.org) [![FastAPI](https://img.shields.io/badge/FastAPI-0.68+-green.svg)](https://fastapi.tiangolo.com) [![Vue Version](https://img.shields.io/badge/Vue.js-3.x-brightgreen.svg)](https://v3.vuejs.org)
## 🚀 更新计划 - [ ] 切片上传,同文件秒传,断点续传 - [ ] 用户登录重构 - [x] webdav存储 - [x] 存储支持自定义路径 - [x] s3优化,不修改昵称为uuid,新建目录 ## 📝 项目简介 FileCodeBox 是一个基于 FastAPI + Vue3 开发的轻量级文件分享工具。它允许用户通过简单的方式分享文本和文件,接收者只需要一个提取码就可以取得文件,就像从快递柜取出快递一样简单。 ## 🎯 应用场景

📁 临时文件分享

快速分享单个文件,无需注册登录

📝 文本快速分享

分享代码片段、文本内容等

🕶️ 匿名文件传输

保护隐私的文件传输方式

💾 临时文件存储

支持设置过期时间的文件存储

🔄 跨平台传输

在不同设备间快速传输文件

🌐 小型分享服务

搭建私有的文件分享服务
## ✨ 核心特性

🚀 轻量简洁

基于 FastAPI + SQLite3 + Vue3 + ElementUI,部署简单,性能出色

📤 便捷上传

支持复制粘贴、拖拽上传,操作简单直观

📦 多种类型

支持文本和各类文件的分享

🔒 安全机制

- IP 限制上传次数 - 错误次数限制 - 文件过期机制

🎫 提取码分享

随机提取码,可自定义次数及有效期

🌍 多语言支持

支持中文简体、繁体及英文

🎭 匿名分享

无需注册登录,保护隐私

🛠 管理面板

文件管理和系统配置

🐳 容器部署

支持 Docker 一键部署

💾 存储扩展

支持本地存储、S3 协议、OneDrive 等

📱 响应式设计

支持移动端访问

💻 终端支持

支持命令行下载
## 🚀 快速开始 ### Docker 部署 ```bash docker run -d --restart=always -p 12345:12345 -v /opt/FileCodeBox/:/app/data --name filecodebox lanol/filecodebox:beta ``` ### 手动部署 1. 克隆项目 ```bash git clone https://github.com/vastsa/FileCodeBox.git ``` 2. 安装依赖 ```bash cd FileCodeBox pip install -r requirements.txt ``` 3. 启动服务 ```bash python main.py ``` ## 📖 使用说明 ### 分享文件 1. 打开网页,点击"分享文件" 2. 选择或拖拽文件 3. 设置过期时间和次数 4. 获取提取码 ### 获取文件 1. 打开网页,输入提取码 2. 点击获取 3. 下载文件或查看文本 ### 管理面板 1. 访问 `/admin` 2. 输入管理员密码 3. 管理文件和配置 ## 🛠 开发指南 ### 项目结构 ``` FileCodeBox/ ├── apps/ # 应用代码 │ ├── admin/ # 管理后台 │ └── base/ # 基础功能 ├── core/ # 核心功能 ├── data/ # 数据目录 └── fcb-fronted/ # 前端代码 ``` ### 开发环境 - Python 3.8+ - Node.js 14+ - Vue 3 - FastAPI ### 本地开发 1. 后端开发 ```bash python main.py ``` 2. 前端开发 ```bash cd fcb-fronted npm install npm run dev ``` ## 🤝 贡献指南 1. Fork 本项目 2. 创建新分支 `git checkout -b feature/xxx` 3. 提交更改 `git commit -m 'Add xxx'` 4. 推送到分支 `git push origin feature/xxx` 5. 提交 Pull Request ## ❓ 常见问题 ### Q: 如何修改上传大小限制? A: 在管理面板中修改配置项 `uploadSize` ### Q: 如何配置存储引擎? A: 在管理面板中选择存储引擎并配置相应参数 ### Q: 如何备份数据? A: 备份 `data` 目录即可 更多问题请访问 [Wiki](https://github.com/vastsa/FileCodeBox/wiki/常见问题) ## 📊 项目统计
Featured|HelloGitHub ![Repobeats](https://repobeats.axiom.co/api/embed/7a6c92f1d96ee57e6fb67f0df371528397b0c9ac.svg) [![Star History](https://api.star-history.com/svg?repos=vastsa/FileCodeBox&type=Date)](https://star-history.com/#vastsa/FileCodeBox&Date)
## 📜 免责声明 本项目开源仅供学习使用,不得用于任何违法用途,否则后果自负,与作者无关。使用时请保留项目地址和版权信息。 ================================================ FILE: docs/guide/management.md ================================================ # 管理面板 FileCodeBox 提供了功能完善的管理面板,让管理员可以方便地管理文件、查看系统状态和修改配置。本文档介绍管理面板的各项功能和使用方法。 ## 访问管理面板 ### 登录方式 管理面板位于 `/admin` 路径。访问方式: 1. 在浏览器中访问 `http://your-domain.com/admin` 2. 输入管理员密码(`admin_token` 配置项的值) 3. 点击登录按钮 ::: tip 提示 默认管理员密码是 `FileCodeBox2023`。请务必在生产环境中修改此密码,详见 [安全设置](/guide/security)。 ::: ### 显示管理入口 默认情况下,首页不显示管理面板入口。您可以通过配置控制是否显示: | 配置项 | 类型 | 默认值 | 说明 | |--------|------|--------|------| | `showAdminAddr` | int | `0` | 是否在首页显示管理入口(1=显示,0=隐藏) | ::: warning 安全建议 在公开服务中,建议保持 `showAdminAddr` 为 `0`,通过直接访问 `/admin` 路径进入管理面板,减少被恶意扫描的风险。 ::: ### 认证机制 管理面板使用 JWT(JSON Web Token)进行身份认证: 1. 登录成功后,服务器返回一个包含管理员身份的 Token 2. 后续请求通过 `Authorization: Bearer ` 头部携带 Token 3. Token 用于验证管理员身份,确保只有授权用户可以访问管理功能 ## 仪表盘 登录后首先看到的是仪表盘页面,展示系统的整体运行状态。 ### 统计指标 仪表盘显示以下关键指标: | 指标 | 说明 | |------|------| | **文件总数** (`totalFiles`) | 系统中存储的文件总数量 | | **存储使用量** (`storageUsed`) | 所有文件占用的总存储空间(字节) | | **系统运行时间** (`sysUptime`) | 系统首次启动的时间 | | **昨日上传数** (`yesterdayCount`) | 昨天一整天上传的文件数量 | | **昨日上传量** (`yesterdaySize`) | 昨天上传文件的总大小(字节) | | **今日上传数** (`todayCount`) | 今天到目前为止上传的文件数量 | | **今日上传量** (`todaySize`) | 今天上传文件的总大小(字节) | ### 指标说明 - **文件总数**:包括所有未过期的文件和文本分享 - **存储使用量**:显示实际文件占用的存储空间,不包括数据库等系统文件 - **昨日/今日统计**:基于文件创建时间计算,用于了解系统使用趋势 ::: tip 提示 存储使用量显示的是字节数。例如 `10485760` 表示约 10MB。 ::: ## 文件管理 ### 文件列表 文件管理页面展示系统中所有已分享的文件,支持分页浏览和搜索。 **列表信息包括:** - 文件 ID - 提取码(code) - 文件名前缀(prefix) - 文件后缀(suffix) - 文件大小 - 创建时间 - 过期时间 - 剩余下载次数 ### 搜索文件 使用搜索功能可以快速找到特定文件: 1. 在搜索框中输入关键词 2. 系统会根据文件名前缀(prefix)进行模糊匹配 3. 搜索结果实时更新 **搜索示例:** - 输入 `report` 可以找到所有文件名包含 "report" 的文件 - 输入 `.pdf` 可以找到所有 PDF 文件(如果文件名包含此字符串) ### 分页浏览 文件列表支持分页显示: | 参数 | 默认值 | 说明 | |------|--------|------| | `page` | `1` | 当前页码 | | `size` | `10` | 每页显示数量 | ### 删除文件 管理员可以删除任意文件: 1. 在文件列表中找到要删除的文件 2. 点击删除按钮 3. 确认删除操作 ::: danger 警告 删除操作不可恢复!文件将从存储后端永久删除,同时删除数据库中的记录。 ::: **删除流程:** 1. 系统首先从存储后端(本地/S3/OneDrive 等)删除实际文件 2. 然后从数据库中删除文件记录 3. 删除后,对应的提取码将失效 ### 下载文件 管理员可以直接下载任意文件: 1. 在文件列表中找到目标文件 2. 点击下载按钮 3. 文件将通过浏览器下载 对于文本分享,系统会直接返回文本内容而不是下载文件。 ### 修改文件信息 管理员可以修改已分享文件的部分信息: | 可修改字段 | 说明 | |------------|------| | `code` | 提取码(必须唯一,不能与其他文件重复) | | `prefix` | 文件名前缀 | | `suffix` | 文件后缀名 | | `expired_at` | 过期时间 | | `expired_count` | 剩余下载次数 | **修改提取码:** ``` 原提取码:abc123 新提取码:myfile2024 ``` ::: warning 注意 修改提取码时,系统会检查新提取码是否已被使用。如果已存在相同的提取码,修改将失败。 ::: ## 本地文件管理 除了管理已分享的文件,管理面板还提供了本地文件管理功能,用于管理 `data/local` 目录中的文件。 ### 查看本地文件 本地文件列表显示 `data/local` 目录中的所有文件: | 信息 | 说明 | |------|------| | 文件名 | 文件的完整名称 | | 创建时间 | 文件的创建时间 | | 文件大小 | 文件大小(字节) | ### 分享本地文件 可以将本地文件快速分享: 1. 在本地文件列表中选择要分享的文件 2. 设置过期方式和过期值 3. 点击分享按钮 4. 系统生成提取码 **分享参数:** | 参数 | 说明 | |------|------| | `filename` | 要分享的文件名 | | `expire_style` | 过期方式(day/hour/minute/forever/count) | | `expire_value` | 过期值(天数/小时数/分钟数/下载次数) | ### 删除本地文件 可以删除 `data/local` 目录中的文件: 1. 在本地文件列表中找到要删除的文件 2. 点击删除按钮 3. 确认删除 ::: tip 使用场景 本地文件管理功能适用于: - 批量上传文件到服务器后进行分享 - 管理通过其他方式上传到服务器的文件 - 清理不需要的本地文件 ::: ## 系统设置 ### 查看配置 在系统设置页面可以查看当前所有配置项的值。配置项按类别分组显示: - 基础设置(站点名称、描述等) - 上传设置(文件大小限制、频率限制等) - 存储设置(存储类型、路径等) - 主题设置(主题选择、透明度等) - 安全设置(管理员密码、错误限制等) ### 修改配置 管理员可以通过管理面板修改大部分配置: 1. 进入系统设置页面 2. 找到要修改的配置项 3. 输入新的值 4. 点击保存按钮 **可修改的配置项:** | 类别 | 配置项示例 | |------|------------| | 基础设置 | `name`, `description`, `keywords`, `notify_title`, `notify_content` | | 上传设置 | `uploadSize`, `uploadMinute`, `uploadCount`, `openUpload`, `enableChunk` | | 过期设置 | `expireStyle`, `max_save_seconds` | | 主题设置 | `themesSelect`, `opacity`, `background` | | 安全设置 | `admin_token`, `showAdminAddr`, `errorMinute`, `errorCount` | | 存储设置 | `file_storage`, `storage_path` 及各存储后端的配置 | ::: warning 注意 - `admin_token`(管理员密码)不能设置为空 - `themesChoices`(主题列表)不可通过管理面板修改 - 修改存储设置后,已有文件不会自动迁移 ::: ### 配置生效 配置修改后立即生效,无需重启服务。配置保存在数据库中,重启后仍然有效。 **配置存储位置:** - 数据库:`data/filecodebox.db` - 表名:`keyvalue` - 键名:`settings` ## API 接口 管理面板的所有功能都通过 REST API 实现,以下是主要接口: ### 认证接口 **登录** ``` POST /admin/login Content-Type: application/json { "password": "your-admin-password" } ``` 响应: ```json { "code": 200, "detail": { "token": "eyJhbGciOiJIUzI1NiIs...", "token_type": "Bearer" } } ``` ### 仪表盘接口 **获取统计数据** ``` GET /admin/dashboard Authorization: Bearer ``` ### 文件管理接口 **获取文件列表** ``` GET /admin/file/list?page=1&size=10&keyword= Authorization: Bearer ``` **删除文件** ``` DELETE /admin/file/delete Authorization: Bearer Content-Type: application/json { "id": 123 } ``` **下载文件** ``` GET /admin/file/download?id=123 Authorization: Bearer ``` **修改文件信息** ``` PATCH /admin/file/update Authorization: Bearer Content-Type: application/json { "id": 123, "code": "newcode", "expired_at": "2024-12-31T23:59:59" } ``` ### 本地文件接口 **获取本地文件列表** ``` GET /admin/local/lists Authorization: Bearer ``` **删除本地文件** ``` DELETE /admin/local/delete Authorization: Bearer Content-Type: application/json { "filename": "example.txt" } ``` **分享本地文件** ``` POST /admin/local/share Authorization: Bearer Content-Type: application/json { "filename": "example.txt", "expire_style": "day", "expire_value": 7 } ``` ### 配置接口 **获取配置** ``` GET /admin/config/get Authorization: Bearer ``` **更新配置** ``` PATCH /admin/config/update Authorization: Bearer Content-Type: application/json { "admin_token": "new-password", "uploadSize": 52428800 } ``` ## 常见问题 ### 忘记管理员密码 如果忘记了管理员密码,可以通过以下方式重置: 1. 停止 FileCodeBox 服务 2. 使用 SQLite 工具打开 `data/filecodebox.db` 3. 查询 `keyvalue` 表中 `key='settings'` 的记录 4. 修改 JSON 中的 `admin_token` 值 5. 重启服务 ```sql -- 查看当前配置 SELECT * FROM keyvalue WHERE key = 'settings'; -- 或者删除配置,恢复默认密码 DELETE FROM keyvalue WHERE key = 'settings'; ``` ### 文件删除失败 如果删除文件时出现错误,可能的原因: 1. **存储后端连接失败**:检查存储配置是否正确 2. **文件已不存在**:文件可能已被手动删除 3. **权限不足**:检查存储目录的写入权限 ### 配置修改不生效 如果修改配置后没有生效: 1. 检查是否点击了保存按钮 2. 刷新页面查看配置是否已保存 3. 检查浏览器控制台是否有错误信息 4. 确认配置值的格式是否正确(如数字类型不要输入字符串) ## 下一步 - [配置说明](/guide/configuration) - 了解所有配置选项的详细说明 - [安全设置](/guide/security) - 了解如何增强系统安全性 - [存储配置](/guide/storage) - 配置不同的存储后端 ================================================ FILE: docs/guide/security.md ================================================ # 安全设置 FileCodeBox 提供了多层安全机制来保护您的文件分享服务。本文档介绍如何正确配置安全选项,确保系统安全运行。 ## 管理员密码 ### 修改默认密码 ::: danger 重要安全警告 FileCodeBox 的默认管理员密码是 `FileCodeBox2023`。**在生产环境中必须立即修改此密码!**使用默认密码会导致任何人都可以访问您的管理面板。 ::: 修改管理员密码有两种方式: **方式一:通过管理面板修改(推荐)** 1. 访问 `/admin` 进入管理面板 2. 使用当前密码登录 3. 进入「系统设置」页面 4. 找到 `admin_token` 配置项 5. 输入新的安全密码并保存 **方式二:通过数据库修改** 配置存储在 `data/filecodebox.db` 数据库的 `keyvalue` 表中,可以直接修改 `admin_token` 的值。 ### 密码安全建议 - 使用至少 16 个字符的强密码 - 包含大小写字母、数字和特殊字符 - 避免使用常见词汇或个人信息 - 定期更换密码 ```python # 推荐的密码格式示例 "admin_token": "Xk9#mP2$vL5@nQ8&wR3" ``` ### 隐藏管理入口 默认情况下,管理面板入口是隐藏的。您可以通过 `showAdminAddr` 配置控制是否在首页显示管理入口: | 配置项 | 类型 | 默认值 | 说明 | |--------|------|--------|------| | `showAdminAddr` | int | `0` | 是否显示管理入口(1=显示,0=隐藏) | ::: tip 建议 在公开服务中,建议保持 `showAdminAddr` 为 `0`,通过直接访问 `/admin` 路径进入管理面板。 ::: ## IP 速率限制 FileCodeBox 内置了基于 IP 的速率限制机制,可以有效防止滥用和攻击。 ### 上传频率限制 限制单个 IP 在指定时间内的上传次数: | 配置项 | 类型 | 默认值 | 说明 | |--------|------|--------|------| | `uploadMinute` | int | `1` | 上传限制的时间窗口(分钟) | | `uploadCount` | int | `10` | 在时间窗口内允许的最大上传次数 | **工作原理:** - 系统记录每个 IP 的上传请求 - 当某 IP 在 `uploadMinute` 分钟内的上传次数达到 `uploadCount` 时 - 该 IP 的后续上传请求将被拒绝,返回 HTTP 423 错误 - 等待时间窗口过期后,计数器重置 **配置示例:** ```python # 宽松配置:5分钟内最多上传20次 { "uploadMinute": 5, "uploadCount": 20 } # 严格配置:1分钟内最多上传3次 { "uploadMinute": 1, "uploadCount": 3 } ``` ### 错误次数限制 限制单个 IP 的错误尝试次数,防止暴力破解提取码: | 配置项 | 类型 | 默认值 | 说明 | |--------|------|--------|------| | `errorMinute` | int | `1` | 错误限制的时间窗口(分钟) | | `errorCount` | int | `1` | 在时间窗口内允许的最大错误次数 | **工作原理:** - 当用户输入错误的提取码时,系统记录该 IP 的错误次数 - 当错误次数达到 `errorCount` 时,该 IP 将被暂时锁定 - 锁定时间为 `errorMinute` 分钟 - 锁定期间,该 IP 的所有提取请求都将被拒绝 **配置示例:** ```python # 防暴力破解配置:5分钟内最多允许3次错误 { "errorMinute": 5, "errorCount": 3 } ``` ::: warning 注意 默认配置 `errorMinute=1, errorCount=1` 非常严格,意味着输入一次错误的提取码后需要等待1分钟才能重试。根据实际需求调整此配置。 ::: ## 上传限制 ### 文件大小限制 | 配置项 | 类型 | 默认值 | 说明 | |--------|------|--------|------| | `uploadSize` | int | `10485760` | 单文件最大上传大小(字节),默认 10MB | | `openUpload` | int | `1` | 是否开启上传功能(1=开启,0=关闭) | **常用大小换算:** - 10MB = 10 * 1024 * 1024 = `10485760` - 50MB = 50 * 1024 * 1024 = `52428800` - 100MB = 100 * 1024 * 1024 = `104857600` - 1GB = 1024 * 1024 * 1024 = `1073741824` ### 文件过期设置 通过文件过期机制,可以自动清理过期文件,减少存储占用和安全风险: | 配置项 | 类型 | 默认值 | 说明 | |--------|------|--------|------| | `expireStyle` | list | `["day","hour","minute","forever","count"]` | 可选的过期方式 | | `max_save_seconds` | int | `0` | 文件最大保存时间(秒),0 表示不限制 | **过期方式说明:** - `day` - 按天数过期 - `hour` - 按小时过期 - `minute` - 按分钟过期 - `forever` - 永不过期(需要字符串提取码) - `count` - 按下载次数过期 **安全建议:** 对于公开服务,建议: 1. 移除 `forever` 选项,避免文件永久存储 2. 设置 `max_save_seconds` 限制最长保存时间 3. 优先使用 `count` 方式,下载后自动删除 ```python # 公开服务推荐配置 { "expireStyle": ["hour", "minute", "count"], "max_save_seconds": 86400 # 最长保存1天 } ``` ### 关闭上传功能 在某些情况下,您可能需要临时关闭上传功能: ```python { "openUpload": 0 # 关闭上传功能 } ``` ## 反向代理安全配置 在生产环境中,通常会使用 Nginx 或其他反向代理服务器。以下是安全配置建议: ### Nginx 配置示例 ```nginx server { listen 80; server_name your-domain.com; # 强制 HTTPS 重定向 return 301 https://$server_name$request_uri; } server { listen 443 ssl http2; server_name your-domain.com; # SSL 证书配置 ssl_certificate /path/to/cert.pem; ssl_certificate_key /path/to/key.pem; ssl_protocols TLSv1.2 TLSv1.3; ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256; ssl_prefer_server_ciphers on; # 安全头部 add_header X-Frame-Options "SAMEORIGIN" always; add_header X-Content-Type-Options "nosniff" always; add_header X-XSS-Protection "1; mode=block" always; add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; # 限制请求体大小(与 uploadSize 配置一致) client_max_body_size 100M; # 传递真实 IP location / { proxy_pass http://127.0.0.1:12345; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; } # 静态资源缓存 location /assets { proxy_pass http://127.0.0.1:12345; proxy_cache_valid 200 7d; add_header Cache-Control "public, max-age=604800"; } } ``` ### 关键安全配置说明 **1. 传递真实 IP** FileCodeBox 的 IP 限制功能依赖于获取客户端真实 IP。系统会按以下顺序获取 IP: 1. `X-Real-IP` 请求头 2. `X-Forwarded-For` 请求头 3. 直接连接的客户端 IP 确保反向代理正确设置这些头部: ```nginx proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; ``` **2. 请求体大小限制** Nginx 的 `client_max_body_size` 应该与 FileCodeBox 的 `uploadSize` 配置一致或略大: ```nginx client_max_body_size 100M; # 允许上传最大 100MB ``` **3. HTTPS 加密** 强烈建议在生产环境中启用 HTTPS: - 保护用户上传的文件内容 - 保护管理员登录凭据 - 防止中间人攻击 ### Caddy 配置示例 ```nginx your-domain.com { reverse_proxy localhost:12345 header { X-Frame-Options "SAMEORIGIN" X-Content-Type-Options "nosniff" X-XSS-Protection "1; mode=block" Strict-Transport-Security "max-age=31536000; includeSubDomains" } } ``` ## 安全检查清单 部署 FileCodeBox 前,请确认以下安全配置: - [ ] 已修改默认管理员密码 `admin_token` - [ ] 已隐藏管理入口 `showAdminAddr: 0` - [ ] 已配置合适的上传频率限制 - [ ] 已配置错误次数限制防止暴力破解 - [ ] 已设置合理的文件大小限制 - [ ] 已配置文件过期策略 - [ ] 已启用 HTTPS 加密 - [ ] 反向代理已正确传递真实 IP - [ ] 已设置安全响应头部 ## 推荐安全配置 ### 公开服务配置 ```python { "admin_token": "your-very-secure-password", "showAdminAddr": 0, "uploadSize": 10485760, # 10MB "uploadMinute": 1, "uploadCount": 5, "errorMinute": 5, "errorCount": 3, "expireStyle": ["hour", "minute", "count"], "max_save_seconds": 86400, # 最长1天 "openUpload": 1 } ``` ### 内部服务配置 ```python { "admin_token": "internal-secure-password", "showAdminAddr": 1, "uploadSize": 104857600, # 100MB "uploadMinute": 5, "uploadCount": 50, "errorMinute": 1, "errorCount": 5, "expireStyle": ["day", "hour", "forever"], "max_save_seconds": 0, # 不限制 "openUpload": 1 } ``` ## 下一步 - [配置说明](/guide/configuration) - 了解所有配置选项 - [存储配置](/guide/storage) - 配置安全的存储后端 - [文件分享](/guide/share) - 了解文件分享功能 ================================================ FILE: docs/guide/share.md ================================================ # 文件分享 FileCodeBox 提供了简单易用的文件和文本分享功能。用户可以通过提取码安全地分享和获取文件。 ## 分享方式 FileCodeBox 支持两种分享方式: 1. **文本分享** - 直接分享文本内容,适合代码片段、配置文件等 2. **文件分享** - 上传文件进行分享,支持各种文件格式 ## 文本分享 ### 使用方法 1. 在首页选择「文本分享」标签 2. 在文本框中输入或粘贴要分享的内容 3. 选择过期方式和时间 4. 点击「分享」按钮 5. 获取提取码 ### 文本大小限制 ::: warning 注意 文本分享的最大内容大小为 **222KB**(227,328 字节)。如果内容超过此限制,建议使用文件分享方式。 ::: 文本内容大小按 UTF-8 编码计算,中文字符通常占用 3 个字节。 ### API 接口 **POST** `/share/text/` 请求参数: | 参数 | 类型 | 必填 | 说明 | |------|------|------|------| | `text` | string | 是 | 要分享的文本内容 | | `expire_value` | int | 否 | 过期数值,默认 1 | | `expire_style` | string | 否 | 过期方式,默认 `day` | 响应示例: ```json { "code": 200, "detail": { "code": "123456" } } ``` ## 文件分享 ### 使用方法 1. 在首页选择「文件分享」标签 2. 点击上传区域或拖拽文件到上传区域 3. 选择过期方式和时间 4. 点击「上传」按钮 5. 获取提取码 ### 文件大小限制 默认单文件最大上传大小为 **10MB**。管理员可以通过 `uploadSize` 配置项修改此限制。 ::: tip 提示 如果需要上传大文件,请联系管理员启用分片上传功能,或调整 `uploadSize` 配置。 ::: ### 支持的上传方式 - **点击上传** - 点击上传区域选择文件 - **拖拽上传** - 将文件拖拽到上传区域 - **粘贴上传** - 从剪贴板粘贴图片(部分主题支持) ### API 接口 **POST** `/share/file/` 请求参数(multipart/form-data): | 参数 | 类型 | 必填 | 说明 | |------|------|------|------| | `file` | file | 是 | 要上传的文件 | | `expire_value` | int | 否 | 过期数值,默认 1 | | `expire_style` | string | 否 | 过期方式,默认 `day` | 响应示例: ```json { "code": 200, "detail": { "code": "654321", "name": "example.pdf" } } ``` ## 过期设置 FileCodeBox 支持多种灵活的过期方式: | 过期方式 | 参数值 | 说明 | |----------|--------|------| | 按天过期 | `day` | 文件在指定天数后过期 | | 按小时过期 | `hour` | 文件在指定小时后过期 | | 按分钟过期 | `minute` | 文件在指定分钟后过期 | | 永不过期 | `forever` | 文件永久有效 | | 按次数过期 | `count` | 文件在被下载指定次数后过期 | ::: info 说明 - 管理员可以通过 `expireStyle` 配置项控制用户可选的过期方式 - 管理员可以通过 `max_save_seconds` 配置项限制文件的最长保存时间 ::: ### 过期方式示例 ```bash # 文件 3 天后过期 expire_value=3, expire_style=day # 文件 12 小时后过期 expire_value=12, expire_style=hour # 文件 30 分钟后过期 expire_value=30, expire_style=minute # 文件永不过期 expire_value=1, expire_style=forever # 文件被下载 5 次后过期 expire_value=5, expire_style=count ``` ## 提取文件 ### 使用方法 1. 在首页的「提取文件」区域输入提取码 2. 点击「提取」按钮 3. 系统会显示文件信息(文件名、大小等) 4. 点击「下载」按钮下载文件,或直接查看文本内容 ### 提取码说明 - 提取码通常为 **6 位数字** - 永不过期的文件使用 **字母数字混合** 的提取码 - 提取码区分大小写(针对字母数字混合的情况) ### API 接口 **查询文件信息** **POST** `/share/select/` 请求参数: ```json { "code": "123456" } ``` 响应示例(文件): ```json { "code": 200, "detail": { "code": "123456", "name": "example.pdf", "size": 1048576, "text": "https://example.com/download/..." } } ``` 响应示例(文本): ```json { "code": 200, "detail": { "code": "123456", "name": "Text", "size": 1024, "text": "这是分享的文本内容..." } } ``` **直接下载文件** **GET** `/share/select/?code=123456` 此接口会直接返回文件内容,适合在浏览器中直接访问。 ## 分片上传(大文件) 对于大文件上传,FileCodeBox 支持分片上传功能。此功能需要管理员启用(`enableChunk=1`)。 ### 分片上传流程 ```mermaid sequenceDiagram participant C as 客户端 participant S as 服务器 C->>S: 1. 初始化上传 (POST /chunk/upload/init/) S-->>C: 返回 upload_id 和分片信息 loop 每个分片 C->>S: 2. 上传分片 (POST /chunk/upload/chunk/{upload_id}/{chunk_index}) S-->>C: 返回分片哈希 end C->>S: 3. 完成上传 (POST /chunk/upload/complete/{upload_id}) S-->>C: 返回提取码 ``` ### 1. 初始化上传 **POST** `/chunk/upload/init/` 请求参数: ```json { "file_name": "large_file.zip", "file_size": 104857600, "chunk_size": 5242880, "file_hash": "sha256_hash_of_file" } ``` | 参数 | 类型 | 必填 | 说明 | |------|------|------|------| | `file_name` | string | 是 | 文件名 | | `file_size` | int | 是 | 文件总大小(字节) | | `chunk_size` | int | 否 | 分片大小,默认 5MB | | `file_hash` | string | 是 | 文件的 SHA256 哈希值 | 响应示例: ```json { "code": 200, "detail": { "existed": false, "upload_id": "abc123def456", "chunk_size": 5242880, "total_chunks": 20, "uploaded_chunks": [] } } ``` ### 2. 上传分片 **POST** `/chunk/upload/chunk/{upload_id}/{chunk_index}` - `upload_id` - 初始化时返回的上传会话 ID - `chunk_index` - 分片索引,从 0 开始 请求体:分片文件数据(multipart/form-data) 响应示例: ```json { "code": 200, "detail": { "chunk_hash": "sha256_hash_of_chunk" } } ``` ### 3. 完成上传 **POST** `/chunk/upload/complete/{upload_id}` 请求参数: ```json { "expire_value": 1, "expire_style": "day" } ``` 响应示例: ```json { "code": 200, "detail": { "code": "789012", "name": "large_file.zip" } } ``` ### 断点续传 分片上传支持断点续传。如果上传中断,可以: 1. 重新调用初始化接口,使用相同的 `file_hash` 2. 服务器会返回已上传的分片列表 `uploaded_chunks` 3. 客户端只需上传未完成的分片 ## 错误处理 ### 常见错误码 | 错误码 | 说明 | 解决方案 | |--------|------|----------| | 403 | 文件大小超过限制 | 减小文件大小或联系管理员调整限制 | | 403 | 内容过多 | 文本超过 222KB,请使用文件分享 | | 403 | 上传频率限制 | 等待一段时间后重试 | | 404 | 文件不存在 | 检查提取码是否正确 | | 404 | 文件已过期 | 文件已过期或下载次数已用完 | ### 频率限制 为防止滥用,系统对上传和提取操作有频率限制: - **上传限制**:默认每分钟最多 10 次上传 - **错误限制**:默认每分钟最多 1 次错误尝试 ::: tip 提示 如果遇到频率限制,请等待限制时间窗口过后再重试。 ::: ## 下一步 - [配置说明](/guide/configuration) - 了解如何配置分享相关设置 - [存储配置](/guide/storage) - 了解文件存储方式 - [安全设置](/guide/security) - 了解安全相关配置 - [管理面板](/guide/management) - 了解如何管理分享的文件 ================================================ FILE: docs/guide/storage-onedrive.md ================================================ # OneDrive作为存储的配置方法 **仅支持工作或学校账户,并且需要有管理员权限以授权API** ## 1. 需要配置的参数 ``` file_storage=onedrive onedrive_domain=XXXXXX onedrive_client_id=XXXXXX-XXXXXX-XXXXXX-XXXXXX onedrive_username=XXXXXX@XXXXXX onedrive_password=XXXXXX ``` `onedrive_username`和`onedrive_password`是你的账户名(邮箱)和密码,另外两个参数需要在[微软Azure门户](https://portal.azure.com/#view/Microsoft_AAD_RegisteredApps/ApplicationsListBlade)中注册应用后获取。 ## 2. 应用注册 1. 登录[https://portal.azure.com/#view/Microsoft_AAD_RegisteredApps/ApplicationsListBlade](https://portal.azure.com/#view/Microsoft_AAD_RegisteredApps/ApplicationsListBlade),鼠标置于右上角账号处,浮窗将显示的`域`即为`onedrive_domain`的值。 ![onedrive_domain](https://api.onedrive.com/v1.0/shares/s!Au-BDzXcM6_VmGCiErO85doq9Tcu/root/content) 2. 点击左上角的`+新注册`,输入名称, * 受支持的帐户类型:选择任何组织目录(任何 Azure AD 目录 - 多租户)中的帐户和个人 Microsoft 帐户(例如,Skype、Xbox) * 重定向 URI (可选):选择`Web`,并输入`http://localhost` 3. 完成注册后进入概述页面,在概要中找到`应用程序(客户端)ID`,即为`onedrive_client_id`的值。 ![onedrive_client_id](https://api.onedrive.com/v1.0/shares/s!Au-BDzXcM6_VmGHD4CNyJxm_QBb8/root/content) 4. 此时还需要配置允许公共客户端流和API权限 * 在左侧选择`身份验证`,找到`允许的客户端流`,选择`是`,并**点击`保存`**。 ![允许的客户端流](https://api.onedrive.com/v1.0/shares/s!Au-BDzXcM6_VmGJQMOlOCb2-L0Lh/root/content) * 在左侧选择`API权限`,点击`+添加权限`,选择`Microsoft Graph`->`委托的权限`,并勾选下述权限:openid、Files中所有权限、User.Read,如下图所示。最后**点击下方的`添加权限`**。 ![添加权限](https://api.onedrive.com/v1.0/shares/s!Au-BDzXcM6_VmGOZzz7sIrdXkD4w/root/content) * 最后点击`授予管理员同意`,并**点击`是`**,最终状态变为`已授予`。 ![授予管理员同意](https://api.onedrive.com/v1.0/shares/s!Au-BDzXcM6_VmGSOAnjnHUlbirbU/root/content) ## 3. 使用下述代码测试是否配置成功 安装依赖:`pip install Office365-REST-Python-Client` ```python # common.py import msal domain = 'XXXXXX' client_id = 'XXXXXX' username = 'XXXXXX' password = 'XXXXXX' def acquire_token_pwd(): authority_url = f'https://login.microsoftonline.com/{domain}' app = msal.PublicClientApplication( authority=authority_url, client_id=client_id ) result = app.acquire_token_by_username_password( username=username, password=password, scopes=['https://graph.microsoft.com/.default'] ) return result ``` 测试登录,如果成功打印出账户名,说明配置成功。 ```python from common import acquire_token_pwd from office365.graph_client import GraphClient try: client = GraphClient(acquire_token_pwd) me = client.me.get().execute_query() print(me.user_principal_name) except Exception as e: print(e) ``` 测试文件上传 ```python import os from office365.graph_client import GraphClient from common import acquire_token_pwd remote_path = 'tmp' local_path = '.tmp/1689843925000.png' def convert_link_to_download_link(link): import re p1 = re.search(r'https:\/\/(.+)\.sharepoint\.com', link).group(1) p2 = re.search(r'personal\/(.+)\/', link).group(1) p3 = re.search(rf'{p2}\/(.+)', link).group(1) return f'https://{p1}.sharepoint.com/personal/{p2}/_layouts/52/download.aspx?share={p3}' client = GraphClient(acquire_token_pwd) folder = client.me.drive.root.get_by_path(remote_path) # 1. upload file = folder.upload_file(local_path).execute_query() print(f'File {file.web_url} has been uploaded') # 2. create sharing link remote_file = folder.get_by_path(os.path.basename(local_path)) permission = remote_file.create_link("view", "anonymous").execute_query() print(f"sharing link: {convert_link_to_download_link(permission.link.webUrl)}") ``` 测试文件下载 ```python import os from office365.graph_client import GraphClient from common import acquire_token_pwd remote_path = 'tmp/1689843925000.png' local_path = '.tmp' if not os.path.exists(local_path): os.makedirs(local_path) client = GraphClient(acquire_token_pwd) remote_file = client.me.drive.root.get_by_path(remote_path).get().execute_query() with open(os.path.join(local_path, os.path.basename(remote_path)), 'wb') as local_file: remote_file.download(local_file).execute_query() print(f'{remote_file.name} has been downloaded into {local_file.name}') ``` 测试删除文件 ```python from office365.graph_client import GraphClient from common import acquire_token_pwd remote_path = 'tmp/1689843925000.png' client = GraphClient(acquire_token_pwd) file = client.me.drive.root.get_by_path(remote_path) file.delete_object().execute_query() ``` ================================================ FILE: docs/guide/storage-opendal.md ================================================ # 通过 OpenDAL 集成存储的配置方法 ## 需要配置的参数 ```dotenv file_storage=opendal opendal_scheme= opendal__=... ``` 以 Gcs 为例,需要配置的参数如下: ```dotenv file_storage=opendal opendal_scheme=gcs opendal_gcs_root= opendal_gcs_bucket= opendal_gcs_credential= ``` 所有支持的服务可以在[此处](https://opendal.apache.org/docs/rust/opendal/services/index.html)查看。 具体服务的配置参数与 OpenDAL 文档一致。 ## 补充说明 通过 OpenDAL 集成的服务均通过服务器中转下载。因此,每次下载既消耗存储服务的流量,也消耗服务器的流量。 OpenDAL 和该项目本身都支持本地存储、`s3`、`onedrive`。不同之处有以下几点: 1. 项目的支持通过预签名实现,不消耗服务器流量。而 OpenDAL 通过服务器中转下载,消耗服务器流量。(本地存储除外) 2. 项目的支持对于异常情况可能会有更多的调试信息,方便排查问题。 3. OpenDAL 项目本身采用 Rust 编写,性能更好。 ================================================ FILE: docs/guide/storage.md ================================================ # 存储配置 FileCodeBox 支持多种存储后端,您可以根据需求选择合适的存储方式。本文档将详细介绍各种存储后端的配置方法。 ## 存储类型概览 | 存储类型 | 配置值 | 说明 | |---------|--------|------| | 本地存储 | `local` | 默认存储方式,文件保存在服务器本地 | | S3 兼容存储 | `s3` | 支持 AWS S3、阿里云 OSS、MinIO 等 | | OneDrive | `onedrive` | 微软 OneDrive 云存储(仅支持工作/学校账户) | | WebDAV | `webdav` | 支持 WebDAV 协议的存储服务 | | OpenDAL | `opendal` | 通过 OpenDAL 集成更多存储服务 | ## 本地存储 本地存储是默认的存储方式,文件将保存在服务器的 `data/` 目录下。 ### 配置参数 | 参数 | 类型 | 默认值 | 说明 | |------|------|--------|------| | `file_storage` | string | `local` | 存储类型 | | `storage_path` | string | `""` | 自定义存储路径(可选) | ### 配置示例 ```bash file_storage=local storage_path= ``` ### 说明 - 文件默认存储在 `data/share/data/` 目录下 - 按日期自动创建子目录:`年/月/日/文件ID/` - 建议在生产环境中将 `data/` 目录挂载到持久化存储 ## S3 兼容存储 支持所有 S3 兼容的对象存储服务,包括 AWS S3、阿里云 OSS、MinIO、腾讯云 COS 等。 ### 配置参数 | 参数 | 类型 | 默认值 | 说明 | |------|------|--------|------| | `file_storage` | string | - | 设置为 `s3` | | `s3_access_key_id` | string | `""` | Access Key ID | | `s3_secret_access_key` | string | `""` | Secret Access Key | | `s3_bucket_name` | string | `""` | 存储桶名称 | | `s3_endpoint_url` | string | `""` | S3 端点 URL | | `s3_region_name` | string | `auto` | 区域名称 | | `s3_signature_version` | string | `s3v2` | 签名版本(`s3v2` 或 `s3v4`) | | `s3_hostname` | string | `""` | S3 主机名(备用) | | `s3_proxy` | int | `0` | 是否通过服务器代理下载(1=是,0=否) | | `aws_session_token` | string | `""` | AWS 会话令牌(可选) | ### AWS S3 配置示例 ```bash file_storage=s3 s3_access_key_id=AKIAIOSFODNN7EXAMPLE s3_secret_access_key=wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY s3_bucket_name=my-filecodebox-bucket s3_endpoint_url=https://s3.amazonaws.com s3_region_name=us-east-1 s3_signature_version=s3v4 ``` ### 阿里云 OSS 配置示例 ```bash file_storage=s3 s3_access_key_id=您的AccessKeyId s3_secret_access_key=您的SecretAccessKey s3_bucket_name=bucket-name s3_endpoint_url=https://bucket-name.oss-cn-hangzhou.aliyuncs.com s3_region_name=oss-cn-hangzhou s3_signature_version=s3v4 ``` ::: tip 阿里云 OSS 端点格式 端点 URL 格式为:`https://..aliyuncs.com` 常用区域: - 杭州:`oss-cn-hangzhou` - 上海:`oss-cn-shanghai` - 北京:`oss-cn-beijing` - 深圳:`oss-cn-shenzhen` ::: ### MinIO 配置示例 ```bash file_storage=s3 s3_access_key_id=minioadmin s3_secret_access_key=minioadmin s3_bucket_name=filecodebox s3_endpoint_url=http://localhost:9000 s3_region_name=us-east-1 s3_signature_version=s3v4 ``` ::: warning MinIO 注意事项 - `s3_endpoint_url` 填写 MinIO 的 API 接口地址 - `s3_region_name` 根据 MinIO 配置中的 `Server Location` 设置 - 确保存储桶已创建且有正确的访问权限 ::: ### 腾讯云 COS 配置示例 ```bash file_storage=s3 s3_access_key_id=您的SecretId s3_secret_access_key=您的SecretKey s3_bucket_name=bucket-name-1250000000 s3_endpoint_url=https://cos.ap-guangzhou.myqcloud.com s3_region_name=ap-guangzhou s3_signature_version=s3v4 ``` ### 代理下载 当 `s3_proxy=1` 时,文件下载将通过服务器中转,而不是直接从 S3 下载。这在以下情况下有用: - S3 存储桶不允许公开访问 - 需要隐藏实际的存储地址 - 网络环境限制直接访问 S3 ## OneDrive 存储 OneDrive 存储支持将文件保存到微软 OneDrive 云存储。 ::: warning 重要限制 OneDrive 存储**仅支持工作或学校账户**,并且需要有管理员权限以授权 API。个人账户无法使用此功能。 ::: ### 配置参数 | 参数 | 类型 | 默认值 | 说明 | |------|------|--------|------| | `file_storage` | string | - | 设置为 `onedrive` | | `onedrive_domain` | string | `""` | Azure AD 域名 | | `onedrive_client_id` | string | `""` | 应用程序(客户端)ID | | `onedrive_username` | string | `""` | 账户邮箱 | | `onedrive_password` | string | `""` | 账户密码 | | `onedrive_root_path` | string | `filebox_storage` | OneDrive 中的存储根目录 | | `onedrive_proxy` | int | `0` | 是否通过服务器代理下载 | ### 配置示例 ```bash file_storage=onedrive onedrive_domain=contoso.onmicrosoft.com onedrive_client_id=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx onedrive_username=user@contoso.onmicrosoft.com onedrive_password=your_password onedrive_root_path=filebox_storage ``` ### Azure 应用注册步骤 要使用 OneDrive 存储,您需要在 Azure 门户中注册应用程序: #### 1. 获取域名 登录 [Azure 门户](https://portal.azure.com/#view/Microsoft_AAD_RegisteredApps/ApplicationsListBlade),将鼠标置于右上角账号处,浮窗显示的**域**即为 `onedrive_domain` 的值。 #### 2. 注册应用 1. 点击左上角的 **+ 新注册** 2. 输入应用名称(如:FileCodeBox) 3. **受支持的帐户类型**:选择"任何组织目录(任何 Azure AD 目录 - 多租户)中的帐户和个人 Microsoft 帐户" 4. **重定向 URI**:选择 `Web`,输入 `http://localhost` 5. 点击**注册** #### 3. 获取客户端 ID 注册完成后,在应用概述页面的**概要**中找到**应用程序(客户端)ID**,即为 `onedrive_client_id` 的值。 #### 4. 配置身份验证 1. 在左侧菜单选择**身份验证** 2. 找到**允许公共客户端流**,选择**是** 3. 点击**保存** #### 5. 配置 API 权限 1. 在左侧菜单选择 **API 权限** 2. 点击 **+ 添加权限** 3. 选择 **Microsoft Graph** → **委托的权限** 4. 勾选以下权限: - `openid` - `Files.Read` - `Files.Read.All` - `Files.ReadWrite` - `Files.ReadWrite.All` - `User.Read` 5. 点击**添加权限** 6. 点击**代表 xxx 授予管理员同意** 7. 确认后,权限状态应显示为**已授予** ### 安装依赖 使用 OneDrive 存储需要安装额外的 Python 依赖: ```bash pip install msal Office365-REST-Python-Client ``` ### 验证配置 您可以使用以下代码测试配置是否正确: ```python import msal from office365.graph_client import GraphClient domain = 'your_domain' client_id = 'your_client_id' username = 'your_username' password = 'your_password' def acquire_token_pwd(): authority_url = f'https://login.microsoftonline.com/{domain}' app = msal.PublicClientApplication( authority=authority_url, client_id=client_id ) result = app.acquire_token_by_username_password( username=username, password=password, scopes=['https://graph.microsoft.com/.default'] ) return result # 测试连接 client = GraphClient(acquire_token_pwd) me = client.me.get().execute_query() print(f"登录成功:{me.user_principal_name}") ``` ## WebDAV 存储 WebDAV 存储支持将文件保存到任何支持 WebDAV 协议的服务,如 Nextcloud、ownCloud、坚果云等。 ### 配置参数 | 参数 | 类型 | 默认值 | 说明 | |------|------|--------|------| | `file_storage` | string | - | 设置为 `webdav` | | `webdav_url` | string | `""` | WebDAV 服务器 URL | | `webdav_username` | string | `""` | WebDAV 用户名 | | `webdav_password` | string | `""` | WebDAV 密码 | | `webdav_root_path` | string | `filebox_storage` | WebDAV 中的存储根目录 | | `webdav_proxy` | int | `0` | 是否通过服务器代理下载 | ### 配置示例 ```bash file_storage=webdav webdav_url=https://dav.example.com/remote.php/dav/files/username/ webdav_username=your_username webdav_password=your_password webdav_root_path=filebox_storage ``` ### Nextcloud 配置示例 ```bash file_storage=webdav webdav_url=https://your-nextcloud.com/remote.php/dav/files/username/ webdav_username=your_username webdav_password=your_app_password webdav_root_path=FileCodeBox ``` ::: tip Nextcloud 应用密码 建议在 Nextcloud 中创建应用密码,而不是使用主密码: 1. 登录 Nextcloud 2. 进入**设置** → **安全** 3. 在**设备与会话**中创建新的应用密码 ::: ### 坚果云配置示例 ```bash file_storage=webdav webdav_url=https://dav.jianguoyun.com/dav/ webdav_username=your_email@example.com webdav_password=your_app_password webdav_root_path=FileCodeBox ``` ::: tip 坚果云应用密码 坚果云需要使用应用密码: 1. 登录坚果云网页版 2. 进入**账户信息** → **安全选项** 3. 添加应用密码 ::: ## OpenDAL 存储 OpenDAL 是一个统一的数据访问层,支持多种存储服务。通过 OpenDAL,您可以使用 Google Cloud Storage、Azure Blob Storage 等更多存储服务。 ### 配置参数 | 参数 | 类型 | 说明 | |------|------|------| | `file_storage` | string | 设置为 `opendal` | | `opendal_scheme` | string | 存储服务类型(如 `gcs`、`azblob`) | | `opendal__` | string | 服务特定的配置参数 | ### 安装依赖 ```bash pip install opendal ``` ### Google Cloud Storage 配置示例 ```bash file_storage=opendal opendal_scheme=gcs opendal_gcs_root=/filecodebox opendal_gcs_bucket=your-bucket-name opendal_gcs_credential=base64_encoded_credential ``` ### Azure Blob Storage 配置示例 ```bash file_storage=opendal opendal_scheme=azblob opendal_azblob_root=/filecodebox opendal_azblob_container=your-container opendal_azblob_account_name=your_account opendal_azblob_account_key=your_key ``` ### 支持的服务 OpenDAL 支持众多存储服务,完整列表请参考 [OpenDAL 官方文档](https://opendal.apache.org/docs/rust/opendal/services/index.html)。 常用服务包括: - `gcs` - Google Cloud Storage - `azblob` - Azure Blob Storage - `obs` - 华为云 OBS - `oss` - 阿里云 OSS(通过 OpenDAL) - `cos` - 腾讯云 COS(通过 OpenDAL) - `hdfs` - Hadoop HDFS - `ftp` - FTP 服务器 - `sftp` - SFTP 服务器 ::: warning OpenDAL 注意事项 1. 通过 OpenDAL 集成的服务均通过服务器中转下载,会同时消耗存储服务和服务器的流量 2. 相比原生 S3/OneDrive 支持,OpenDAL 方式可能缺少一些调试信息 3. OpenDAL 采用 Rust 编写,性能较好 ::: ## 存储选择建议 | 场景 | 推荐存储 | 原因 | |------|----------|------| | 个人/小型部署 | 本地存储 | 简单易用,无需额外配置 | | 企业内网 | MinIO + S3 | 自建对象存储,数据可控 | | 公有云部署 | 对应云厂商 S3 | 同区域访问快,成本低 | | 已有 OneDrive | OneDrive | 利用现有资源 | | 已有 WebDAV | WebDAV | 兼容性好 | | 特殊存储需求 | OpenDAL | 支持更多存储服务 | ## 常见问题 ### S3 上传失败 1. 检查 Access Key 和 Secret Key 是否正确 2. 确认存储桶名称和区域配置正确 3. 检查存储桶的访问权限设置 4. 确认签名版本(`s3v2` 或 `s3v4`)与服务商要求一致 ### OneDrive 认证失败 1. 确认使用的是工作/学校账户,而非个人账户 2. 检查 Azure 应用是否已授予管理员同意 3. 确认 API 权限配置完整 4. 验证用户名和密码是否正确 ### WebDAV 连接失败 1. 检查 WebDAV URL 格式是否正确 2. 确认用户名和密码(或应用密码)正确 3. 检查服务器是否支持 WebDAV 协议 4. 确认网络连接正常 ================================================ FILE: docs/guide/upload.md ================================================ # 文件上传 FileCodeBox 提供了多种灵活的文件上传方式,支持普通上传和分片上传,满足不同场景的需求。 ## 上传方式 FileCodeBox 支持以下几种上传方式: ### 拖拽上传 将文件直接拖拽到上传区域即可开始上传。这是最便捷的上传方式。 1. 打开 FileCodeBox 首页 2. 将文件从文件管理器拖拽到上传区域 3. 松开鼠标,文件开始上传 4. 上传完成后获取提取码 ::: tip 提示 拖拽上传支持同时拖拽多个文件(取决于主题支持)。 ::: ### 点击上传 点击上传区域,通过系统文件选择器选择文件。 1. 点击上传区域的「选择文件」按钮 2. 在弹出的文件选择器中选择要上传的文件 3. 确认选择后文件开始上传 4. 上传完成后获取提取码 ### 粘贴上传 支持从剪贴板直接粘贴图片进行上传(部分主题支持)。 1. 复制图片到剪贴板(截图或复制图片) 2. 在上传区域使用 `Ctrl+V`(Windows/Linux)或 `Cmd+V`(macOS)粘贴 3. 图片自动开始上传 4. 上传完成后获取提取码 ::: warning 注意 粘贴上传仅支持图片格式,不支持其他文件类型。具体支持情况取决于所使用的主题。 ::: ## 文件大小限制 ### 默认限制 | 配置项 | 默认值 | 说明 | |--------|--------|------| | `uploadSize` | 10MB | 单文件最大上传大小 | ### 修改上传限制 管理员可以通过管理面板或配置文件修改上传大小限制: ```python # 设置最大上传大小为 100MB uploadSize = 104857600 # 100 * 1024 * 1024 ``` ::: info 说明 `uploadSize` 的单位是字节。常用换算: - 10MB = 10485760 - 50MB = 52428800 - 100MB = 104857600 - 500MB = 524288000 - 1GB = 1073741824 ::: ### 超出限制的处理 当上传文件超过大小限制时,系统会返回 403 错误: ```json { "detail": "大小超过限制,最大为10.00 MB" } ``` ## 普通上传 API ### 文件上传接口 **POST** `/share/file/` Content-Type: `multipart/form-data` **请求参数:** | 参数 | 类型 | 必填 | 说明 | |------|------|------|------| | `file` | file | 是 | 要上传的文件 | | `expire_value` | int | 否 | 过期数值,默认 1 | | `expire_style` | string | 否 | 过期方式,默认 `day` | **过期方式选项:** | 值 | 说明 | |----|------| | `day` | 按天过期 | | `hour` | 按小时过期 | | `minute` | 按分钟过期 | | `forever` | 永不过期 | | `count` | 按下载次数过期 | **响应示例:** ```json { "code": 200, "detail": { "code": "654321", "name": "example.pdf" } } ``` **cURL 示例:** ```bash # 上传文件(默认1天有效期) curl -X POST "http://localhost:12345/share/file/" \ -F "file=@/path/to/file.pdf" # 上传文件并指定有效期(7天) curl -X POST "http://localhost:12345/share/file/" \ -F "file=@/path/to/file.pdf" \ -F "expire_value=7" \ -F "expire_style=day" # 上传文件并指定有效期(可下载10次) curl -X POST "http://localhost:12345/share/file/" \ -F "file=@/path/to/file.pdf" \ -F "expire_value=10" \ -F "expire_style=count" # 分享文本 curl -X POST "http://localhost:12345/share/text/" \ -F "text=这是要分享的文本内容" # 通过取件码下载文件 curl -L "http://localhost:12345/share/select/?code=取件码" -o downloaded_file ``` ::: tip 需要认证时 如果管理面板关闭了游客上传(`openUpload=0`),需要先登录获取 token: ```bash # 1. 登录获取 token curl -X POST "http://localhost:12345/admin/login" \ -H "Content-Type: application/json" \ -d '{"password": "FileCodeBox2023"}' # 返回: {"code":200,"msg":"success","detail":{"token":"xxx.xxx.xxx","token_type":"Bearer"}} # 2. 使用 token 上传文件 curl -X POST "http://localhost:12345/share/file/" \ -H "Authorization: Bearer xxx.xxx.xxx" \ -F "file=@/path/to/file.pdf" # 3. 使用 token 分享文本 curl -X POST "http://localhost:12345/share/text/" \ -H "Authorization: Bearer xxx.xxx.xxx" \ -F "text=这是要分享的文本内容" ``` ::: ## 分片上传 API 对于大文件,FileCodeBox 支持分片上传功能。分片上传将大文件分割成多个小块分别上传,支持断点续传。 ::: warning 前提条件 分片上传功能需要管理员启用:`enableChunk=1` ::: ### 分片上传流程 ``` ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ 初始化上传 │ ──▶ │ 上传分片 │ ──▶ │ 完成上传 │ │ /init/ │ │ /chunk/ │ │ /complete/ │ └─────────────┘ └─────────────┘ └─────────────┘ │ ▼ ┌───────────┐ │ 循环上传 │ │ 每个分片 │ └───────────┘ ``` ### 1. 初始化上传 **POST** `/chunk/upload/init/` **请求参数:** ```json { "file_name": "large_file.zip", "file_size": 104857600, "chunk_size": 5242880, "file_hash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" } ``` | 参数 | 类型 | 必填 | 默认值 | 说明 | |------|------|------|--------|------| | `file_name` | string | 是 | - | 文件名 | | `file_size` | int | 是 | - | 文件总大小(字节) | | `chunk_size` | int | 否 | 5MB | 分片大小(字节) | | `file_hash` | string | 是 | - | 文件的 SHA256 哈希值 | **响应示例:** ```json { "code": 200, "detail": { "existed": false, "upload_id": "abc123def456789", "chunk_size": 5242880, "total_chunks": 20, "uploaded_chunks": [] } } ``` | 字段 | 说明 | |------|------| | `existed` | 文件是否已存在(秒传) | | `upload_id` | 上传会话 ID | | `chunk_size` | 分片大小 | | `total_chunks` | 总分片数 | | `uploaded_chunks` | 已上传的分片索引列表 | ### 2. 上传分片 **POST** `/chunk/upload/chunk/{upload_id}/{chunk_index}` **路径参数:** | 参数 | 说明 | |------|------| | `upload_id` | 初始化时返回的上传会话 ID | | `chunk_index` | 分片索引,从 0 开始 | **请求体:** Content-Type: `multipart/form-data` | 参数 | 类型 | 说明 | |------|------|------| | `chunk` | file | 分片数据 | **响应示例:** ```json { "code": 200, "detail": { "chunk_hash": "a1b2c3d4e5f6..." } } ``` **cURL 示例:** ```bash # 上传第一个分片(索引为 0) curl -X POST "http://localhost:12345/chunk/upload/chunk/abc123def456789/0" \ -F "chunk=@/path/to/chunk_0" ``` ### 3. 完成上传 **POST** `/chunk/upload/complete/{upload_id}` **路径参数:** | 参数 | 说明 | |------|------| | `upload_id` | 上传会话 ID | **请求参数:** ```json { "expire_value": 7, "expire_style": "day" } ``` | 参数 | 类型 | 必填 | 说明 | |------|------|------|------| | `expire_value` | int | 是 | 过期数值 | | `expire_style` | string | 是 | 过期方式 | **响应示例:** ```json { "code": 200, "detail": { "code": "789012", "name": "large_file.zip" } } ``` ### 断点续传 分片上传支持断点续传。当上传中断后: 1. 使用相同的 `file_hash` 重新调用初始化接口 2. 服务器返回 `uploaded_chunks` 列表,包含已上传的分片索引 3. 客户端只需上传不在列表中的分片 4. 所有分片上传完成后调用完成接口 **示例流程:** ```javascript // 1. 初始化上传 const initResponse = await fetch('/chunk/upload/init/', { method: 'POST', body: JSON.stringify({ file_name: 'large_file.zip', file_size: fileSize, chunk_size: 5 * 1024 * 1024, file_hash: fileHash }) }); const { upload_id, uploaded_chunks, total_chunks } = await initResponse.json(); // 2. 上传未完成的分片 for (let i = 0; i < total_chunks; i++) { if (!uploaded_chunks.includes(i)) { const chunk = file.slice(i * chunkSize, (i + 1) * chunkSize); await fetch(`/chunk/upload/chunk/${upload_id}/${i}`, { method: 'POST', body: chunk }); } } // 3. 完成上传 await fetch(`/chunk/upload/complete/${upload_id}`, { method: 'POST', body: JSON.stringify({ expire_value: 7, expire_style: 'day' }) }); ``` ## 错误处理 ### 常见错误 | HTTP 状态码 | 错误信息 | 原因 | 解决方案 | |-------------|----------|------|----------| | 403 | 大小超过限制 | 文件超过 `uploadSize` 限制 | 减小文件大小或联系管理员调整限制 | | 403 | 上传频率限制 | 超过 IP 上传频率限制 | 等待限制时间窗口后重试 | | 400 | 过期时间类型错误 | `expire_style` 值不在允许列表中 | 使用有效的过期方式 | | 404 | 上传会话不存在 | `upload_id` 无效或已过期 | 重新初始化上传 | | 400 | 无效的分片索引 | `chunk_index` 超出范围 | 检查分片索引是否正确 | | 400 | 分片不完整 | 完成上传时分片数量不足 | 确保所有分片都已上传 | ### 频率限制 系统对上传操作有频率限制,防止滥用: | 配置项 | 默认值 | 说明 | |--------|--------|------| | `uploadMinute` | 1 | 限制时间窗口(分钟) | | `uploadCount` | 10 | 时间窗口内最大上传次数 | 当超过频率限制时,需要等待时间窗口过后才能继续上传。 ### 错误响应格式 ```json { "detail": "错误信息描述" } ``` ## 上传配置 ### 相关配置项 | 配置项 | 类型 | 默认值 | 说明 | |--------|------|--------|------| | `openUpload` | int | 1 | 是否开放上传(1=开放,0=关闭) | | `uploadSize` | int | 10485760 | 最大上传大小(字节) | | `enableChunk` | int | 0 | 是否启用分片上传(1=启用,0=禁用) | | `uploadMinute` | int | 1 | 上传频率限制时间窗口(分钟) | | `uploadCount` | int | 10 | 时间窗口内最大上传次数 | | `expireStyle` | list | ["day","hour","minute","forever","count"] | 允许的过期方式 | ### 配置示例 ```python # 允许上传 100MB 文件,启用分片上传 uploadSize = 104857600 enableChunk = 1 # 放宽上传频率限制:每 5 分钟最多 50 次 uploadMinute = 5 uploadCount = 50 # 只允许按天和按次数过期 expireStyle = ["day", "count"] ``` ## 下一步 - [文件分享](/guide/share) - 了解完整的分享流程 - [配置说明](/guide/configuration) - 了解所有配置选项 - [存储配置](/guide/storage) - 了解文件存储方式 - [安全设置](/guide/security) - 了解安全相关配置 ================================================ FILE: docs/index.md ================================================ --- layout: home hero: name: "FileCodeBox" text: "文件快递柜" tagline: 匿名口令分享文本,文件,像拿快递一样取文件 image: src: /logo_small.png alt: FileCodeBox actions: - theme: brand text: 快速开始 link: /guide/getting-started - theme: alt text: 在线体验 link: https://share.lanol.cn - theme: alt text: 在 GitHub 上查看 link: https://github.com/vastsa/FileCodeBox features: - icon: 🚀 title: 快速部署 details: 支持 Docker 一键部署,简单快捷,无需复杂配置 - icon: 🔒 title: 安全可靠 details: 文件访问需要提取码,支持设置有效期和下载次数限制 - icon: 💻 title: 简洁界面 details: 清爽的用户界面,支持拖拽上传,使用体验极佳 - icon: 🛠️ title: 功能丰富 details: 支持文件预览、在线播放、图片处理等多种功能 - icon: 📦 title: 存储扩展 details: 支持本地存储、对象存储等多种存储方式 - icon: 🔌 title: API 支持 details: 提供完整的 REST API,方便与其他系统集成 --- ================================================ FILE: docs/package.json ================================================ { "devDependencies": { "vitepress": "^1.6.3" }, "scripts": { "docs:dev": "vitepress dev", "docs:build": "vitepress build", "docs:preview": "vitepress preview" } } ================================================ FILE: docs/showcase.md ================================================ # 优秀案例 这里收录了一些使用 FileCodeBox 搭建的优秀站点。如果你也部署了 FileCodeBox,欢迎提交 PR 将你的站点添加到这里! ## 官方演示站
### 🌟 FileCodeBox Demo - **网址**:[share.lanol.cn](https://share.lanol.cn) - **简介**:官方演示站点,体验最新功能 - **特点**:稳定运行,功能完整
## 社区站点 ::: tip 提交你的站点 如果你使用 FileCodeBox 搭建了自己的文件分享服务,欢迎通过以下方式提交: 1. 在 [GitHub](https://github.com/vastsa/FileCodeBox) 提交 PR,编辑此页面 2. 在 [Issues](https://github.com/vastsa/FileCodeBox/issues) 中提交你的站点信息 3. 加入 QQ 群 739673698 联系管理员 :::
### 取文件 - **网址**:[www.quwenjian.cn/fby.html](https://www.quwenjian.cn/fby.html) - **简介**:取文件 - 存储无界,便携无限 - **特点**:永久免费的文件中转站 - **运营者**:取文件&取文件网盘
### 潘多拉盒子 - **网址**:[pan.duo.la](https://pan.duo.la) - **简介**:潘多拉盒子 - **特点**:经典1.6版本 - **运营者**:五行缺心眼
## 提交要求 为了保证收录站点的质量,请确保你的站点满足以下条件: 1. **稳定运行**:站点需要稳定运行,能够正常访问 2. **合法合规**:站点内容需要合法合规,不得包含违法违规内容 3. **保留版权**:建议保留 FileCodeBox 的版权信息 4. **HTTPS 支持**:建议启用 HTTPS 加密访问 ## 案例展示模板 如果你想提交站点,请按照以下格式: ```markdown ### 站点名称 - **网址**:[域名](https://域名) - **简介**:一句话描述站点用途 - **特点**:站点的特色功能或亮点 - **运营者**:可选,你的名字或组织 ``` ================================================ FILE: main.py ================================================ # @Time : 2023/8/9 23:23 # @Author : Lan # @File : main.py # @Software: PyCharm import asyncio import time from contextlib import asynccontextmanager from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import HTMLResponse from fastapi.staticfiles import StaticFiles from tortoise import Tortoise from tortoise.contrib.fastapi import register_tortoise from apps.admin.views import admin_api from apps.base.models import KeyValue from apps.base.utils import ip_limit from apps.base.views import share_api, chunk_api, presign_api from core.config import ensure_settings_row, refresh_settings from core.database import db_startup_lock, get_db_config, init_db from core.logger import logger from core.response import APIResponse from core.settings import settings, BASE_DIR, DEFAULT_CONFIG from core.tasks import delete_expire_files, clean_incomplete_uploads from core.utils import hash_password, is_password_hashed @asynccontextmanager async def lifespan(app: FastAPI): logger.info("正在初始化应用...") # 初始化数据库 await init_db() # 加载配置(多进程下串行化启动写操作) async with db_startup_lock(): await load_config() app.mount( "/assets", StaticFiles(directory=f"./{settings.themesSelect}/assets"), name="assets", ) # 启动后台任务 task = asyncio.create_task(delete_expire_files()) chunk_cleanup_task = asyncio.create_task(clean_incomplete_uploads()) logger.info("应用初始化完成") try: yield finally: # 清理操作 logger.info("正在关闭应用...") task.cancel() chunk_cleanup_task.cancel() await asyncio.gather(task, chunk_cleanup_task, return_exceptions=True) await Tortoise.close_connections() logger.info("应用已关闭") async def load_config(): await ensure_settings_row() await KeyValue.update_or_create( key="sys_start", defaults={"value": int(time.time() * 1000)} ) await refresh_settings() await migrate_password_to_hash() ip_limit["error"].minutes = settings.errorMinute ip_limit["error"].count = settings.errorCount ip_limit["upload"].minutes = settings.uploadMinute ip_limit["upload"].count = settings.uploadCount async def migrate_password_to_hash(): if not is_password_hashed(settings.admin_token): hashed = hash_password(settings.admin_token) settings.admin_token = hashed config_record = await KeyValue.filter(key="settings").first() if config_record and config_record.value: config_record.value["admin_token"] = hashed await config_record.save() logger.info("已将管理员密码迁移为哈希存储") app = FastAPI(lifespan=lifespan) @app.middleware("http") async def refresh_settings_middleware(request, call_next): await refresh_settings() return await call_next(request) app.add_middleware( CORSMiddleware, allow_origins=["*"], allow_credentials=True, allow_methods=["*"], allow_headers=["*"], ) # 使用 register_tortoise 来添加异常处理器 register_tortoise( app, config=get_db_config(), generate_schemas=False, add_exception_handlers=True, ) app.include_router(share_api) app.include_router(chunk_api) app.include_router(presign_api) app.include_router(admin_api) @app.exception_handler(404) @app.get("/") async def index(request=None, exc=None): return HTMLResponse( content=open( BASE_DIR / f"{settings.themesSelect}/index.html", "r", encoding="utf-8" ) .read() .replace("{{title}}", str(settings.name)) .replace("{{description}}", str(settings.description)) .replace("{{keywords}}", str(settings.keywords)) .replace("{{opacity}}", str(settings.opacity)) .replace('"/assets/', '"assets/') .replace("{{background}}", str(settings.background)), media_type="text/html", headers={"Cache-Control": "no-cache"}, ) @app.get("/robots.txt") async def robots(): return HTMLResponse(content=settings.robotsText, media_type="text/plain") @app.post("/") async def get_config(): return APIResponse( detail={ "name": settings.name, "description": settings.description, "explain": settings.page_explain, "uploadSize": settings.uploadSize, "expireStyle": settings.expireStyle, "enableChunk": settings.enableChunk, "openUpload": settings.openUpload, "notify_title": settings.notify_title, "notify_content": settings.notify_content, "show_admin_address": settings.showAdminAddr, "max_save_seconds": settings.max_save_seconds, } ) if __name__ == "__main__": import uvicorn uvicorn.run( app="main:app", host=settings.serverHost, port=settings.serverPort, reload=False, workers=settings.serverWorkers, ) ================================================ FILE: readme.md ================================================
# FileCodeBox ### 文件快递柜 - 匿名口令分享文本和文件 FileCodeBox Logo 像拿快递一样取文件,无需注册,输入口令即可获取 [![GitHub stars](https://img.shields.io/github/stars/vastsa/FileCodeBox?style=flat-square&logo=github)](https://github.com/vastsa/FileCodeBox/stargazers) [![GitHub forks](https://img.shields.io/github/forks/vastsa/FileCodeBox?style=flat-square&logo=github)](https://github.com/vastsa/FileCodeBox/network) [![GitHub issues](https://img.shields.io/github/issues/vastsa/FileCodeBox?style=flat-square&logo=github)](https://github.com/vastsa/FileCodeBox/issues) [![GitHub license](https://img.shields.io/github/license/vastsa/FileCodeBox?style=flat-square)](https://github.com/vastsa/FileCodeBox/blob/master/LICENSE) [![Docker Pulls](https://img.shields.io/docker/pulls/lanol/filecodebox?style=flat-square&logo=docker)](https://hub.docker.com/r/lanol/filecodebox) [![Python](https://img.shields.io/badge/Python-3.8+-3776AB?style=flat-square&logo=python&logoColor=white)](https://www.python.org) [![FastAPI](https://img.shields.io/badge/FastAPI-0.68+-009688?style=flat-square&logo=fastapi&logoColor=white)](https://fastapi.tiangolo.com) [![Vue.js](https://img.shields.io/badge/Vue.js-3.x-4FC08D?style=flat-square&logo=vue.js&logoColor=white)](https://vuejs.org) [English](./readme_en.md) | [在线演示](https://share.lanol.cn) | [部署教程](https://github.com/vastsa/FileCodeBox/wiki/部署教程) | [常见问题](https://github.com/vastsa/FileCodeBox/wiki/常见问题) | [QQ群: 739673698](https://qm.qq.com/q/PemPzhdEIM) ```bash # 🚀 一键部署 docker run -d -p 12345:12345 -v /opt/FileCodeBox:/app/data --name filecodebox lanol/filecodebox:latest # 国内镜像(如果上面拉取缓慢): docker.cnb.cool/aixk/filecodebox ```
--- ## 目录 - [项目简介](#-项目简介) - [功能特性](#-功能特性) - [界面预览](#-界面预览) - [快速开始](#-快速开始) - [使用指南](#-使用指南) - [开发指南](#-开发指南) - [常见问题](#-常见问题) - [贡献指南](#-贡献指南) - [项目统计](#-项目统计) - [免责声明](#-免责声明) --- ## 📝 项目简介 FileCodeBox 是一个轻量级的文件分享工具,基于 **FastAPI + Vue3** 开发。用户可以通过简单的方式匿名分享文本和文件,接收者只需输入提取码即可获取内容——就像从快递柜取出快递一样简单。 ### 应用场景 | 场景 | 描述 | |------|------| | 📁 **临时文件分享** | 快速分享文件,无需注册登录 | | 📝 **代码片段分享** | 分享代码、配置文件等文本内容 | | 🕶️ **匿名文件传输** | 保护隐私的点对点传输 | | 🔄 **跨设备传输** | 在不同设备间快速同步文件 | | 💾 **临时存储** | 支持自定义过期时间的云存储 | | 🌐 **私有服务** | 搭建企业或个人专属分享服务 | --- ## ✨ 功能特性
### 🚀 轻量高效 - FastAPI + SQLite3 后端 - Vue3 + Element Plus 前端 - Docker 一键部署 - 资源占用极低 ### 🔒 安全可靠 - IP 上传频率限制 - 提取码错误次数限制 - 文件自动过期清理 - 支持管理员认证 ### 📤 便捷上传 - 拖拽上传 - 复制粘贴上传 - 命令行 curl 上传 - 批量文件上传
### 🎫 灵活分享 - 随机/自定义提取码 - 可设置有效期(时间/次数) - 支持永久有效 - 文本和文件统一管理 ### 💾 多存储支持 - 本地文件系统 - S3 兼容存储 - [OneDrive](./docs/guide/storage-onedrive.md) - [OpenDAL](./docs/guide/storage-opendal.md) ### 🌍 国际化 - 简体中文 - 繁体中文 - English - 响应式设计 / 深色模式
--- ## 🖼️ 界面预览 > 前端源码仓库:[2024主题](https://github.com/vastsa/FileCodeBoxFronted) | [2023主题](https://github.com/vastsa/FileCodeBoxFronted2023)
🎨 新版界面 (2024)
文件上传 文本分享
文件管理 系统设置
移动端 深色模式
📦 经典界面 (2023)
首页 上传
管理 设置
--- ## 🚀 快速开始 ### Docker 部署(推荐) **方式一:Docker CLI** ```bash # Docker Hub(推荐) docker run -d --restart always -p 12345:12345 -v /opt/FileCodeBox:/app/data --name filecodebox lanol/filecodebox:latest # 国内镜像(如果 Docker Hub 拉取缓慢) docker run -d --restart always -p 12345:12345 -v /opt/FileCodeBox:/app/data --name filecodebox docker.cnb.cool/aixk/filecodebox ``` **方式二:Docker Compose** ```yaml services: filecodebox: image: lanol/filecodebox:latest container_name: filecodebox restart: unless-stopped ports: - "12345:12345" volumes: - ./data:/app/data environment: - WORKERS=4 - LOG_LEVEL=info ``` ```bash docker compose up -d ``` **环境变量说明** | 变量 | 默认值 | 说明 | |------|--------|------| | `HOST` | `::` | 监听地址(支持 IPv4/IPv6 双栈) | | `PORT` | `12345` | 服务端口 | | `WORKERS` | `4` | 工作进程数(建议设为 CPU 核心数) | | `LOG_LEVEL` | `info` | 日志级别:`debug` / `info` / `warning` / `error` | ### 反向代理配置 使用 Nginx 时,请添加以下配置以正确获取客户端 IP: ```nginx location / { proxy_pass http://127.0.0.1:12345; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; client_max_body_size 100m; # 根据需要调整上传大小限制 } ``` ### 手动部署 ```bash # 1. 克隆项目 git clone https://github.com/vastsa/FileCodeBox.git cd FileCodeBox # 2. 安装依赖 pip install -r requirements.txt # 3. 启动服务 python main.py ``` --- ## 📖 使用指南 ### 基础操作 | 操作 | 步骤 | |------|------| | **分享文件** | 打开网页 → 选择/拖拽文件 → 设置有效期 → 获取提取码 | | **获取文件** | 打开网页 → 输入提取码 → 下载文件或查看文本 | | **管理后台** | 访问 `/#/admin` → 输入密码 `FileCodeBox2023` | ### 命令行使用(curl)
点击展开 curl 使用示例 **上传文件** ```bash # 基础上传(默认 1 天有效期) curl -X POST "http://localhost:12345/share/file/" \ -F "file=@/path/to/file.txt" # 指定 1 小时有效期 curl -X POST "http://localhost:12345/share/file/" \ -F "file=@/path/to/file.txt" \ -F "expire_value=1" \ -F "expire_style=hour" # 指定下载 10 次后过期 curl -X POST "http://localhost:12345/share/file/" \ -F "file=@/path/to/file.txt" \ -F "expire_value=10" \ -F "expire_style=count" ``` **分享文本** ```bash curl -X POST "http://localhost:12345/share/text/" \ -F "text=要分享的文本内容" ``` **下载文件** ```bash curl -L "http://localhost:12345/share/select/?code=提取码" -o filename ``` **有效期参数** | `expire_style` | 说明 | |----------------|------| | `day` | 天数 | | `hour` | 小时 | | `minute` | 分钟 | | `count` | 下载次数 | | `forever` | 永久有效 | **返回示例** ```json { "code": 200, "msg": "success", "detail": { "code": "abcd1234", "name": "file.txt" } } ``` **需要认证时**(管理员关闭游客上传后) ```bash # 1. 获取 token curl -X POST "http://localhost:12345/admin/login" \ -H "Content-Type: application/json" \ -d '{"password": "FileCodeBox2023"}' # 2. 携带 token 上传 curl -X POST "http://localhost:12345/share/file/" \ -H "Authorization: Bearer " \ -F "file=@/path/to/file.txt" ```
--- ## 🛠 开发指南 ### 项目结构 ``` FileCodeBox/ ├── apps/ # 应用模块 │ ├── admin/ # 管理后台 │ └── base/ # 基础功能 ├── core/ # 核心模块 ├── data/ # 数据目录(运行时生成) ├── docs/ # 文档 └── main.py # 入口文件 ``` ### 本地开发 **后端** ```bash pip install -r requirements.txt python main.py ``` **前端** ```bash # 前端仓库: https://github.com/vastsa/FileCodeBoxFronted cd fcb-fronted npm install npm run dev ``` ### 技术栈 | 类别 | 技术 | |------|------| | **后端框架** | FastAPI 0.128+ / Uvicorn | | **数据库** | SQLite + Tortoise ORM | | **数据验证** | Pydantic 2.x | | **异步支持** | aiofiles / aiohttp / aioboto3 | | **对象存储** | S3 协议 / OneDrive / OpenDAL | | **前端框架** | Vue 3 + Element Plus + Vite | | **运行环境** | Python 3.8+ / Node.js 18+ | | **容器化** | Docker / Docker Compose | --- ## ❓ 常见问题
如何修改上传大小限制? 在管理面板中修改 `uploadSize` 配置项。如果使用 Nginx 反向代理,还需修改 `client_max_body_size`。
如何配置存储引擎? 在管理面板中选择存储引擎类型并配置相应参数。支持本地存储、S3、OneDrive、OpenDAL 等。
如何备份数据? 备份 `data` 目录即可,包含数据库和上传的文件。
如何修改管理员密码? 登录管理面板后,在系统设置中修改 `adminPassword` 配置项。
更多问题请访问 [Wiki](https://github.com/vastsa/FileCodeBox/wiki/常见问题) 或加入 [QQ群: 739673698](https://qm.qq.com/q/PemPzhdEIM) --- ## 🤝 贡献指南 欢迎提交 Issue 和 Pull Request! ```bash # 1. Fork 并克隆 git clone https://github.com/your-username/FileCodeBox.git # 2. 创建分支 git checkout -b feature/your-feature # 3. 提交更改 git commit -m "feat: add your feature" # 4. 推送并创建 PR git push origin feature/your-feature ``` --- ## 📊 项目统计
HelloGitHub ![Repobeats](https://repobeats.axiom.co/api/embed/7a6c92f1d96ee57e6fb67f0df371528397b0c9ac.svg) [![Star History](https://api.star-history.com/svg?repos=vastsa/FileCodeBox&type=Date)](https://star-history.com/#vastsa/FileCodeBox&Date)
--- ## 🗓 更新计划 - [ ] 2025 年新皮肤 - [ ] 文件收集功能 --- ## 📜 免责声明 本项目开源仅供学习交流使用,不得用于任何违法用途,否则后果自负,与作者无关。使用本项目时请保留项目地址和版权信息。 ---
**如果觉得项目不错,欢迎 ⭐ Star 支持!** Made with ❤️ by [vastsa](https://github.com/vastsa)
================================================ FILE: readme_en.md ================================================
# FileCodeBox ### Anonymous File & Text Sharing with Passcode FileCodeBox Logo Share files like picking up a package — no registration required, just enter the passcode [![GitHub stars](https://img.shields.io/github/stars/vastsa/FileCodeBox?style=flat-square&logo=github)](https://github.com/vastsa/FileCodeBox/stargazers) [![GitHub forks](https://img.shields.io/github/forks/vastsa/FileCodeBox?style=flat-square&logo=github)](https://github.com/vastsa/FileCodeBox/network) [![GitHub issues](https://img.shields.io/github/issues/vastsa/FileCodeBox?style=flat-square&logo=github)](https://github.com/vastsa/FileCodeBox/issues) [![GitHub license](https://img.shields.io/github/license/vastsa/FileCodeBox?style=flat-square)](https://github.com/vastsa/FileCodeBox/blob/master/LICENSE) [![Docker Pulls](https://img.shields.io/docker/pulls/lanol/filecodebox?style=flat-square&logo=docker)](https://hub.docker.com/r/lanol/filecodebox) [![Python](https://img.shields.io/badge/Python-3.8+-3776AB?style=flat-square&logo=python&logoColor=white)](https://www.python.org) [![FastAPI](https://img.shields.io/badge/FastAPI-0.128+-009688?style=flat-square&logo=fastapi&logoColor=white)](https://fastapi.tiangolo.com) [![Vue.js](https://img.shields.io/badge/Vue.js-3.x-4FC08D?style=flat-square&logo=vue.js&logoColor=white)](https://vuejs.org) [简体中文](./README.md) | [Live Demo](https://share.lanol.cn) | [Documentation](https://github.com/vastsa/FileCodeBox/wiki/Deployment-Guide) | [FAQ](https://github.com/vastsa/FileCodeBox/wiki/FAQ) ```bash # 🚀 Quick Deploy docker run -d -p 12345:12345 -v /opt/FileCodeBox:/app/data --name filecodebox lanol/filecodebox:latest # China Mirror (if slow): docker.cnb.cool/aixk/filecodebox ```
--- ## Table of Contents - [Introduction](#-introduction) - [Features](#-features) - [Screenshots](#-screenshots) - [Quick Start](#-quick-start) - [Usage Guide](#-usage-guide) - [Development](#-development) - [FAQ](#-faq) - [Contributing](#-contributing) - [Statistics](#-statistics) - [Disclaimer](#-disclaimer) --- ## 📝 Introduction FileCodeBox is a lightweight file sharing tool built with **FastAPI + Vue3**. Users can anonymously share text and files, and recipients only need to enter a passcode to retrieve the content — just like picking up a package from a locker. ### Use Cases | Scenario | Description | |----------|-------------| | 📁 **Temporary File Sharing** | Quick file sharing without registration | | 📝 **Code Snippet Sharing** | Share code, config files, and text content | | 🕶️ **Anonymous Transfer** | Privacy-protected peer-to-peer transfer | | 🔄 **Cross-Device Transfer** | Quickly sync files between devices | | 💾 **Temporary Storage** | Cloud storage with custom expiration | | 🌐 **Private Service** | Build your own enterprise or personal sharing service | --- ## ✨ Features
### 🚀 Lightweight & Fast - FastAPI + SQLite3 backend - Vue3 + Element Plus frontend - One-click Docker deployment - Minimal resource usage ### 🔒 Secure & Reliable - IP upload rate limiting - Passcode attempt limiting - Auto file expiration cleanup - Admin authentication support ### 📤 Easy Upload - Drag & drop upload - Copy & paste upload - Command line curl upload - Batch file upload
### 🎫 Flexible Sharing - Random / custom passcodes - Set expiration (time/count) - Permanent validity support - Unified text & file management ### 💾 Multiple Storage - Local file system - S3-compatible storage - [OneDrive](./docs/guide/storage-onedrive.md) - [OpenDAL](./docs/guide/storage-opendal.md) ### 🌍 Internationalization - Simplified Chinese - Traditional Chinese - English - Responsive design / Dark mode
--- ## 🖼️ Screenshots > Frontend repositories: [2024 Theme](https://github.com/vastsa/FileCodeBoxFronted) | [2023 Theme](https://github.com/vastsa/FileCodeBoxFronted2023)
🎨 New Interface (2024)
File Upload Text Share
File Management System Settings
Mobile View Dark Mode
📦 Classic Interface (2023)
Home Upload
Management Settings
--- ## 🚀 Quick Start ### Docker Deployment (Recommended) **Option 1: Docker CLI** ```bash # Docker Hub (Recommended) docker run -d --restart always -p 12345:12345 -v /opt/FileCodeBox:/app/data --name filecodebox lanol/filecodebox:latest # China Mirror (if Docker Hub is slow) docker run -d --restart always -p 12345:12345 -v /opt/FileCodeBox:/app/data --name filecodebox docker.cnb.cool/aixk/filecodebox ``` **Option 2: Docker Compose** ```yaml services: filecodebox: image: lanol/filecodebox:latest container_name: filecodebox restart: unless-stopped ports: - "12345:12345" volumes: - ./data:/app/data environment: - WORKERS=4 - LOG_LEVEL=info ``` ```bash docker compose up -d ``` **Environment Variables** | Variable | Default | Description | |----------|---------|-------------| | `HOST` | `::` | Listen address (supports IPv4/IPv6 dual-stack) | | `PORT` | `12345` | Service port | | `WORKERS` | `4` | Worker processes (recommended: CPU cores) | | `LOG_LEVEL` | `info` | Log level: `debug` / `info` / `warning` / `error` | ### Reverse Proxy Configuration When using Nginx, add the following configuration to properly obtain client IP: ```nginx location / { proxy_pass http://127.0.0.1:12345; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; client_max_body_size 100m; # Adjust upload size limit as needed } ``` ### Manual Deployment ```bash # 1. Clone the repository git clone https://github.com/vastsa/FileCodeBox.git cd FileCodeBox # 2. Install dependencies pip install -r requirements.txt # 3. Start the service python main.py ``` --- ## 📖 Usage Guide ### Basic Operations | Operation | Steps | |-----------|-------| | **Share File** | Open website → Select/drag files → Set expiration → Get passcode | | **Retrieve File** | Open website → Enter passcode → Download file or view text | | **Admin Panel** | Visit `/#/admin` → Enter password `FileCodeBox2023` | ### Command Line Usage (curl)
Click to expand curl examples **Upload File** ```bash # Basic upload (default 1 day expiration) curl -X POST "http://localhost:12345/share/file/" \ -F "file=@/path/to/file.txt" # Set 1 hour expiration curl -X POST "http://localhost:12345/share/file/" \ -F "file=@/path/to/file.txt" \ -F "expire_value=1" \ -F "expire_style=hour" # Set expiration after 10 downloads curl -X POST "http://localhost:12345/share/file/" \ -F "file=@/path/to/file.txt" \ -F "expire_value=10" \ -F "expire_style=count" ``` **Share Text** ```bash curl -X POST "http://localhost:12345/share/text/" \ -F "text=Text content to share" ``` **Download File** ```bash curl -L "http://localhost:12345/share/select/?code=PASSCODE" -o filename ``` **Expiration Parameters** | `expire_style` | Description | |----------------|-------------| | `day` | Days | | `hour` | Hours | | `minute` | Minutes | | `count` | Download count | | `forever` | Never expire | **Response Example** ```json { "code": 200, "msg": "success", "detail": { "code": "abcd1234", "name": "file.txt" } } ``` **When Authentication Required** (after admin disables guest upload) ```bash # 1. Get token curl -X POST "http://localhost:12345/admin/login" \ -H "Content-Type: application/json" \ -d '{"password": "FileCodeBox2023"}' # 2. Upload with token curl -X POST "http://localhost:12345/share/file/" \ -H "Authorization: Bearer " \ -F "file=@/path/to/file.txt" ```
--- ## 🛠 Development ### Project Structure ``` FileCodeBox/ ├── apps/ # Application modules │ ├── admin/ # Admin backend │ └── base/ # Base functionality ├── core/ # Core modules ├── data/ # Data directory (generated at runtime) ├── docs/ # Documentation └── main.py # Entry point ``` ### Local Development **Backend** ```bash pip install -r requirements.txt python main.py ``` **Frontend** ```bash # Frontend repo: https://github.com/vastsa/FileCodeBoxFronted cd fcb-fronted npm install npm run dev ``` ### Tech Stack | Category | Technology | |----------|------------| | **Backend Framework** | FastAPI 0.128+ / Uvicorn | | **Database** | SQLite + Tortoise ORM | | **Data Validation** | Pydantic 2.x | | **Async Support** | aiofiles / aiohttp / aioboto3 | | **Object Storage** | S3 Protocol / OneDrive / OpenDAL | | **Frontend Framework** | Vue 3 + Element Plus + Vite | | **Runtime** | Python 3.8+ / Node.js 18+ | | **Containerization** | Docker / Docker Compose | --- ## ❓ FAQ
How to modify upload size limit? Modify the `uploadSize` configuration in the admin panel. If using Nginx reverse proxy, also modify `client_max_body_size`.
How to configure storage engine? Select the storage engine type and configure parameters in the admin panel. Supports local storage, S3, OneDrive, OpenDAL, etc.
How to backup data? Backup the `data` directory, which contains the database and uploaded files.
How to change admin password? After logging into the admin panel, modify the `adminPassword` configuration in system settings.
For more questions, visit [Wiki](https://github.com/vastsa/FileCodeBox/wiki/FAQ) --- ## 🤝 Contributing Issues and Pull Requests are welcome! ```bash # 1. Fork and clone git clone https://github.com/your-username/FileCodeBox.git # 2. Create branch git checkout -b feature/your-feature # 3. Commit changes git commit -m "feat: add your feature" # 4. Push and create PR git push origin feature/your-feature ``` --- ## 📊 Statistics
HelloGitHub ![Repobeats](https://repobeats.axiom.co/api/embed/7a6c92f1d96ee57e6fb67f0df371528397b0c9ac.svg) [![Star History](https://api.star-history.com/svg?repos=vastsa/FileCodeBox&type=Date)](https://star-history.com/#vastsa/FileCodeBox&Date)
--- ## 🗓 Roadmap - [ ] 2025 New Theme - [ ] File Collection Feature --- ## 📜 Disclaimer This project is open-source for learning and communication purposes only. It should not be used for any illegal purposes. The author is not responsible for any consequences. Please retain the project address and copyright information when using it. ---
**If you find this project helpful, please give it a ⭐ Star!** Made with ❤️ by [vastsa](https://github.com/vastsa)
================================================ FILE: requirements.txt ================================================ aioboto3==15.5.0 aiohttp==3.13.3 aiofiles==25.1.0 fastapi==0.128.0 pydantic==2.12.5 uvicorn==0.40.0 tortoise-orm==0.25.3 python-multipart==0.0.21