From 9081cf3b6d18eb219b44c425a66403f28756cb6a Mon Sep 17 00:00:00 2001 From: Orion Date: Fri, 5 Jun 2026 08:39:16 +0800 Subject: [PATCH] =?UTF-8?q?=F0=9F=A7=B0=20=E7=BA=B3=E5=85=A5=20FileCodeBox?= =?UTF-8?q?=201Panel=20=E5=AE=9A=E5=88=B6=E8=B5=84=E6=96=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- deploy/1panel/README.md | 12 + deploy/1panel/current-app/custom/Dockerfile | 4 + .../current-app/custom/fcb_extra_patch.py | 500 ++++++++++++++++++ deploy/1panel/current-app/docker-compose.yml | 21 + deploy/1panel/current-app/env.sample.raw | 7 + .../1panel/current-app/update-50mb-image.sh | 80 +++ deploy/1panel/local-app/README.md | 37 ++ deploy/1panel/local-app/beta/.env.sample | 3 + deploy/1panel/local-app/beta/data.yml | 35 ++ .../1panel/local-app/beta/docker-compose.yml | 17 + deploy/1panel/local-app/data.yml | 30 ++ deploy/1panel/local-app/logo.png | Bin 0 -> 6396 bytes deploy/1panel/remote-app/README.md | 23 + deploy/1panel/remote-app/beta/data.yml | 10 + .../1panel/remote-app/beta/docker-compose.yml | 16 + deploy/1panel/remote-app/data.yml | 26 + deploy/1panel/remote-app/logo.png | Bin 0 -> 7269 bytes 17 files changed, 821 insertions(+) create mode 100644 deploy/1panel/README.md create mode 100644 deploy/1panel/current-app/custom/Dockerfile create mode 100644 deploy/1panel/current-app/custom/fcb_extra_patch.py create mode 100644 deploy/1panel/current-app/docker-compose.yml create mode 100644 deploy/1panel/current-app/env.sample.raw create mode 100755 deploy/1panel/current-app/update-50mb-image.sh create mode 100644 deploy/1panel/local-app/README.md create mode 100644 deploy/1panel/local-app/beta/.env.sample create mode 100644 deploy/1panel/local-app/beta/data.yml create mode 100644 deploy/1panel/local-app/beta/docker-compose.yml create mode 100644 deploy/1panel/local-app/data.yml create mode 100644 deploy/1panel/local-app/logo.png create mode 100644 deploy/1panel/remote-app/README.md create mode 100644 deploy/1panel/remote-app/beta/data.yml create mode 100644 deploy/1panel/remote-app/beta/docker-compose.yml create mode 100644 deploy/1panel/remote-app/data.yml create mode 100644 deploy/1panel/remote-app/logo.png diff --git a/deploy/1panel/README.md b/deploy/1panel/README.md new file mode 100644 index 0000000..13706cd --- /dev/null +++ b/deploy/1panel/README.md @@ -0,0 +1,12 @@ +# Orion FileCodeBox 1Panel 定制资料 + +本目录用于把当前 1Panel 运行态 FileCodeBox 定制纳入 Git 管理,避免继续散落修改。 + +- `current-app/`: 当前 `/opt/1panel/apps/filecodebox/filecodebox` 的 Compose、自定义镜像补丁和更新脚本快照。 +- `local-app/`: 当前 `/opt/1panel/resource/apps/local/filecodebox` 本地应用模板快照。 +- `remote-app/`: 当前 `/opt/1panel/resource/apps/remote/filecodebox` 远程应用模板快照,仅作对比。 + +后续建议: +1. 先在源码仓库里把后端统计、前端后台 UI、分页逻辑做成正常源码改动。 +2. 再让 1Panel 本地应用使用本仓库构建出的镜像,而不是运行时 patch 编译产物。 +3. 保留 `/app/data` 数据卷,不迁移数据库内容到 Git。 diff --git a/deploy/1panel/current-app/custom/Dockerfile b/deploy/1panel/current-app/custom/Dockerfile new file mode 100644 index 0000000..f303785 --- /dev/null +++ b/deploy/1panel/current-app/custom/Dockerfile @@ -0,0 +1,4 @@ +FROM lanol/filecodebox:beta + +COPY fcb_extra_patch.py /tmp/fcb_extra_patch.py +RUN python /tmp/fcb_extra_patch.py && rm -f /tmp/fcb_extra_patch.py diff --git a/deploy/1panel/current-app/custom/fcb_extra_patch.py b/deploy/1panel/current-app/custom/fcb_extra_patch.py new file mode 100644 index 0000000..20182da --- /dev/null +++ b/deploy/1panel/current-app/custom/fcb_extra_patch.py @@ -0,0 +1,500 @@ +from pathlib import Path +import gzip +import re + +assets = Path('/app/themes/2024/assets') + + +def rewrite_gzip(path: Path, content: str) -> None: + path.write_text(content, encoding='utf-8') + with gzip.open(path.with_suffix(path.suffix + '.gz'), 'wb', compresslevel=9) as gz: + gz.write(content.encode('utf-8')) + + +def patch_chunk_size() -> None: + changed = [] + for path in assets.glob('SendFileView-*.js'): + text = path.read_text(encoding='utf-8') + if 'chunk/upload/init/' not in text and 'initChunkUpload' not in text: + continue + old = text + for source, target in [ + ('re=5*1024*1024,ft=', 're=50*1024*1024,ft='), + ('const i=5*1024*1024,d=Math.ceil(o.size/i),k=await ae.post("chunk/upload/init/"', 'const i=50*1024*1024,d=Math.ceil(o.size/i),k=await ae.post("chunk/upload/init/"'), + ('const i=5*1024*1024,d=Math.ceil(o.size/i),S=await ae.post("chunk/upload/init/"', 'const i=50*1024*1024,d=Math.ceil(o.size/i),S=await ae.post("chunk/upload/init/"'), + ('// 默认切片大小为5MB\n const chunkSize = 5 * 1024 * 1024', '// 默认切片大小为50MB\n const chunkSize = 50 * 1024 * 1024'), + ]: + text = text.replace(source, target) + if text == old: + idx = text.find('chunk/upload/init/') + if idx < 0: + idx = text.find('initChunkUpload') + start = max(0, idx - 700) + end = min(len(text), idx + 300) + window = text[start:end] + patched = re.sub(r'5\s*\*\s*1024\s*\*\s*1024', '50*1024*1024', window, count=1) + if patched != window: + text = text[:start] + patched + text[end:] + if text != old: + rewrite_gzip(path, text) + changed.append(path.name) + + schema = Path('/app/apps/base/schemas.py') + if schema.exists(): + text = schema.read_text(encoding='utf-8') + old = text + text = text.replace('chunk_size: int = 5 * 1024 * 1024', 'chunk_size: int = 50 * 1024 * 1024') + text = re.sub(r'chunk_size:\s*int\s*=\s*5\s*\*\s*1024\s*\*\s*1024', 'chunk_size: int = 50 * 1024 * 1024', text) + if text != old: + schema.write_text(text, encoding='utf-8') + print('chunk patch:', changed or 'no frontend marker changed') + + +def patch_models_and_migration() -> None: + models = Path('/app/apps/base/models.py') + text = models.read_text(encoding='utf-8') + if 'class TransferStats(models.Model):' not in text: + insert = ''' + +class TransferStats(models.Model): + id = fields.IntField(pk=True) + action = fields.CharField(max_length=16, index=True) + file_code_id = fields.IntField(null=True, index=True) + code = fields.CharField(max_length=255, null=True, index=True) + name = fields.CharField(max_length=512, default="") + size = fields.BigIntField(default=0) + ip = fields.CharField(max_length=64, default="") + file_path = fields.CharField(max_length=1024, default="") + expired_at = fields.DatetimeField(null=True) + deleted_at = fields.DatetimeField(null=True, index=True) + meta = fields.TextField(null=True) + created_at = fields.DatetimeField(auto_now_add=True, index=True) + + class Meta: + table = "transfer_stats" +''' + text = text.replace('\n\nclass KeyValue(Model):', insert + '\n\nclass KeyValue(Model):') + models.write_text(text, encoding='utf-8') + + migration = Path('/app/apps/base/migrations/migrations_099_transfer_stats.py') + migration.write_text('\n'.join([ + 'from tortoise import Tortoise', + '', + '', + 'async def migrate():', + ' conn = Tortoise.get_connection("default")', + ' await conn.execute_script("""', + ' CREATE TABLE IF NOT EXISTS transfer_stats (', + ' id INTEGER PRIMARY KEY AUTOINCREMENT,', + ' action VARCHAR(16) NOT NULL,', + ' file_code_id INT NULL,', + ' code VARCHAR(255) NULL,', + " name VARCHAR(512) NOT NULL DEFAULT '',", + ' size BIGINT NOT NULL DEFAULT 0,', + " ip VARCHAR(64) NOT NULL DEFAULT '',", + ' created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP', + ' );', + ' CREATE INDEX IF NOT EXISTS idx_transfer_stats_action_created ON transfer_stats(action, created_at);', + ' CREATE INDEX IF NOT EXISTS idx_transfer_stats_file_code_id ON transfer_stats(file_code_id);', + ' CREATE INDEX IF NOT EXISTS idx_transfer_stats_code ON transfer_stats(code);', + ' CREATE INDEX IF NOT EXISTS idx_transfer_stats_deleted_at ON transfer_stats(deleted_at);', + ' """)', + ]) + '\n', encoding='utf-8') + # Keep existing SQLite databases compatible when the table already exists. + import sqlite3 + db = Path('/app/data/filecodebox.db') + if db.exists(): + con = sqlite3.connect(db) + cols = {row[1] for row in con.execute('PRAGMA table_info(transfer_stats)').fetchall()} + for col, ddl in { + 'file_path': "ALTER TABLE transfer_stats ADD COLUMN file_path VARCHAR(1024) NOT NULL DEFAULT ''", + 'expired_at': 'ALTER TABLE transfer_stats ADD COLUMN expired_at TIMESTAMP NULL', + 'deleted_at': 'ALTER TABLE transfer_stats ADD COLUMN deleted_at TIMESTAMP NULL', + 'meta': 'ALTER TABLE transfer_stats ADD COLUMN meta TEXT NULL', + }.items(): + if col not in cols: + con.execute(ddl) + con.execute('CREATE INDEX IF NOT EXISTS idx_transfer_stats_deleted_at ON transfer_stats(deleted_at)') + con.commit(); con.close() + + +def patch_base_views() -> None: + views = Path('/app/apps/base/views.py') + text = views.read_text(encoding='utf-8') + text = text.replace('from apps.base.models import FileCodes, UploadChunk, PresignUploadSession', 'from apps.base.models import FileCodes, UploadChunk, PresignUploadSession, TransferStats') + helper = ''' + +async def record_transfer_stat(action: str, file_code: FileCodes, size: Optional[int] = None, ip: str = "", deleted_at=None, meta: str = "") -> None: + async def _write(): + name = f"{file_code.prefix or ''}{file_code.suffix or ''}" or file_code.uuid_file_name or file_code.code + await TransferStats.create( + action=action, + file_code_id=file_code.id, + code=file_code.code, + name=name, + size=int(size if size is not None else (file_code.size or 0)), + ip=ip or "", + file_path=file_code.file_path or "", + expired_at=file_code.expired_at, + deleted_at=deleted_at, + meta=meta or "", + ) + try: + await _write() + except Exception: + for ddl in [ + "ALTER TABLE transfer_stats ADD COLUMN file_path VARCHAR(1024) NOT NULL DEFAULT ''", + "ALTER TABLE transfer_stats ADD COLUMN expired_at TIMESTAMP NULL", + "ALTER TABLE transfer_stats ADD COLUMN deleted_at TIMESTAMP NULL", + "ALTER TABLE transfer_stats ADD COLUMN meta TEXT NULL", + "CREATE INDEX IF NOT EXISTS idx_transfer_stats_deleted_at ON transfer_stats(deleted_at)", + ]: + try: + await TransferStats._meta.db.execute_script(ddl) + except Exception: + pass + try: + await _write() + except Exception: + pass +''' + if 'async def record_transfer_stat(' not in text: + text = text.replace('async def create_file_code(code, **kwargs):\n return await FileCodes.create(code=code, **kwargs)\n', 'async def create_file_code(code, **kwargs):\n return await FileCodes.create(code=code, **kwargs)\n' + helper + '\n') + replacements = [ + (''' await FileCodes.create(\n code=code,\n prefix=prefix,''', ''' file_code = await FileCodes.create(\n code=code,\n prefix=prefix,'''), + (''' )\n return code\n''', ''' )\n await record_transfer_stat("upload", file_code, file_size)\n return code\n''', 1), + (''' await create_file_code(\n code=code,\n text=text,''', ''' file_code = await create_file_code(\n code=code,\n text=text,'''), + (''' prefix="Text",\n )\n ip_limit["upload"].add_ip(ip)''', ''' prefix="Text",\n )\n await record_transfer_stat("upload", file_code, text_size, ip)\n ip_limit["upload"].add_ip(ip)'''), + (''' await create_file_code(\n code=code,\n prefix=prefix,''', ''' file_code = await create_file_code(\n code=code,\n prefix=prefix,'''), + (''' used_count=used_count,\n )\n ip_limit["upload"].add_ip(ip)\n return APIResponse(detail={"code": code, "name": file.filename})''', ''' used_count=used_count,\n )\n await record_transfer_stat("upload", file_code, file_size, ip)\n ip_limit["upload"].add_ip(ip)\n return APIResponse(detail={"code": code, "name": file.filename})'''), + (''' await update_file_usage(file_code)\n return await file_storage.get_file_response(file_code)''', ''' await update_file_usage(file_code)\n await record_transfer_stat("download", file_code, file_code.size, ip)\n return await file_storage.get_file_response(file_code)'''), + (''' await update_file_usage(file_code)\n return APIResponse(\n detail={''', ''' await update_file_usage(file_code)\n await record_transfer_stat("download", file_code, file_code.size, ip)\n return APIResponse(\n detail={'''), + (''' await FileCodes.create(\n code=code,\n file_hash=file_hash,''', ''' file_code = await FileCodes.create(\n code=code,\n file_hash=file_hash,'''), + (''' )\n # 清理临时文件\n await storage.clean_chunks(upload_id, save_path)''', ''' )\n await record_transfer_stat("upload", file_code, chunk_info.file_size, ip)\n # 清理临时文件\n await storage.clean_chunks(upload_id, save_path)'''), + ] + for item in replacements: + source, target = item[0], item[1] + count = item[2] if len(item) > 2 else -1 + if source in text and target not in text: + text = text.replace(source, target, count) + for source, target in [ + (''' await session.delete()\n ip_limit["upload"].add_ip(ip)\n return APIResponse(detail={"code": code, "name": session.file_name})''', ''' file_code = await FileCodes.filter(code=code).first()\n if file_code:\n await record_transfer_stat("upload", file_code, file_size, ip)\n await session.delete()\n ip_limit["upload"].add_ip(ip)\n return APIResponse(detail={"code": code, "name": session.file_name})'''), + (''' await session.delete()\n ip_limit["upload"].add_ip(ip)\n return APIResponse(detail={"code": code, "name": session.file_name})''', ''' file_code = await FileCodes.filter(code=code).first()\n if file_code:\n await record_transfer_stat("upload", file_code, session.file_size, ip)\n await session.delete()\n ip_limit["upload"].add_ip(ip)\n return APIResponse(detail={"code": code, "name": session.file_name})'''), + ]: + if source in text: + text = text.replace(source, target, 1) + views.write_text(text, encoding='utf-8') + + +def patch_admin_views() -> None: + admin = Path('/app/apps/admin/views.py') + text = admin.read_text(encoding='utf-8') + text = text.replace('from apps.base.models import FileCodes, KeyValue', 'from apps.base.models import FileCodes, KeyValue, TransferStats') + analytics = r''' +@admin_api.get("/analytics") +async def analytics(start: str = "", end: str = ""): + try: + conn = TransferStats._meta.db + await conn.execute_script(""" + ALTER TABLE transfer_stats ADD COLUMN file_path VARCHAR(1024) NOT NULL DEFAULT ''; + """) + except Exception: + pass + for ddl in [ + "ALTER TABLE transfer_stats ADD COLUMN expired_at TIMESTAMP NULL", + "ALTER TABLE transfer_stats ADD COLUMN deleted_at TIMESTAMP NULL", + "ALTER TABLE transfer_stats ADD COLUMN meta TEXT NULL", + "CREATE INDEX IF NOT EXISTS idx_transfer_stats_deleted_at ON transfer_stats(deleted_at)", + ]: + try: + await TransferStats._meta.db.execute_script(ddl) + except Exception: + pass + now = await get_now() + + def parse_day(value, fallback): + if not value: + return fallback + try: + return datetime.datetime.strptime(value[:10], "%Y-%m-%d") + except Exception: + return fallback + + end_day = parse_day(end, now.replace(hour=0, minute=0, second=0, microsecond=0)) + start_day = parse_day(start, end_day - datetime.timedelta(days=29)) + end_exclusive = end_day + datetime.timedelta(days=1) + stats = await TransferStats.filter(created_at__gte=start_day, created_at__lt=end_exclusive) + files = await FileCodes.all() + file_map = {item.code: item for item in files} + total_downloads = sum(1 for item in stats if item.action == "download") + total_uploads = sum(1 for item in stats if item.action == "upload") + download_traffic = sum(item.size or 0 for item in stats if item.action == "download") + upload_traffic = sum(item.size or 0 for item in stats if item.action == "upload") + downloaded_files = len({item.code for item in stats if item.action == "download" and item.code}) + historical_codes = {item.code for item in stats if item.code} + day_map = {} + cursor = start_day + while cursor < end_exclusive: + key = cursor.strftime("%Y-%m-%d") + day_map[key] = {"date": key, "downloads": 0, "uploads": 0, "downloadTraffic": 0, "uploadTraffic": 0} + cursor += datetime.timedelta(days=1) + file_stats = {} + for item in stats: + key = item.created_at.strftime("%Y-%m-%d") + row = day_map.get(key) + if row: + if item.action == "download": + row["downloads"] += 1 + row["downloadTraffic"] += item.size or 0 + elif item.action == "upload": + row["uploads"] += 1 + row["uploadTraffic"] += item.size or 0 + if item.code and item.action in {"upload", "download", "delete"}: + bucket = file_stats.setdefault(item.code, {"downloads": 0, "downloadTraffic": 0, "uploads": 0, "uploadTraffic": 0, "name": item.name or item.code, "size": 0, "file_path": "", "uploaded_at": None, "expired_at": None, "deleted_at": None, "deleted": False}) + bucket["name"] = bucket["name"] or item.name or item.code + bucket["size"] = item.size or bucket["size"] + bucket["file_path"] = getattr(item, "file_path", "") or bucket["file_path"] + if getattr(item, "expired_at", None): + bucket["expired_at"] = item.expired_at + if item.action == "upload": + bucket["uploads"] += 1 + bucket["uploadTraffic"] += item.size or 0 + bucket["uploaded_at"] = bucket["uploaded_at"] or item.created_at + elif item.action == "download": + bucket["downloads"] += 1 + bucket["downloadTraffic"] += item.size or 0 + elif item.action == "delete": + bucket["deleted"] = True + bucket["deleted_at"] = getattr(item, "deleted_at", None) or item.created_at + for file_item in files: + current_name = f"{file_item.prefix or ''}{file_item.suffix or ''}" or file_item.uuid_file_name or file_item.code + bucket = file_stats.setdefault(file_item.code, {"downloads": 0, "downloadTraffic": 0, "uploads": 0, "uploadTraffic": 0, "name": current_name, "size": int(file_item.size or 0), "file_path": file_item.file_path or "", "uploaded_at": file_item.created_at, "expired_at": file_item.expired_at, "deleted_at": None, "deleted": False}) + bucket["name"] = bucket.get("name") or current_name + bucket["size"] = bucket.get("size") or int(file_item.size or 0) + bucket["file_path"] = bucket.get("file_path") or file_item.file_path or "" + bucket["uploaded_at"] = bucket.get("uploaded_at") or file_item.created_at + bucket["expired_at"] = bucket.get("expired_at") or file_item.expired_at + historical_codes.add(file_item.code) + # Current files are always part of history metadata, even if upload events were missed before instrumentation. + + all_history_files = sorted(file_stats.items(), key=lambda kv: (kv[1].get("deleted_at") or kv[1].get("uploaded_at") or datetime.datetime.min.replace(tzinfo=datetime.timezone.utc), kv[1]["downloads"], kv[1]["downloadTraffic"]), reverse=True) + top_files = sorted([kv for kv in all_history_files if kv[1]["downloads"] > 0], key=lambda kv: (kv[1]["downloads"], kv[1]["downloadTraffic"]), reverse=True)[:5] + + def dt_value(value): + return value.isoformat() if value else "" + + def file_history_row(code, data): + file_item = file_map.get(code) + current_name = f"{file_item.prefix or ''}{file_item.suffix or ''}" if file_item else "" + uploaded_at = data.get("uploaded_at") or (file_item.created_at if file_item else None) + expired_at = data.get("expired_at") or (file_item.expired_at if file_item else None) + return { + "code": code, + "name": data["name"] or current_name or code, + "size": str(data["size"] or (file_item.size if file_item else 0) or 0), + "download_count": data["downloads"], + "used_count": data["downloads"], + "download_traffic": str(data["downloadTraffic"]), + "upload_count": data.get("uploads", 0), + "upload_traffic": str(data.get("uploadTraffic", 0)), + "current": bool(file_item), + "deleted": bool(data.get("deleted")) and not bool(file_item), + "file_path": data.get("file_path") or (file_item.file_path if file_item else ""), + "created_at": dt_value(uploaded_at), + "uploaded_at": dt_value(uploaded_at), + "expired_at": dt_value(expired_at), + "deleted_at": dt_value(data.get("deleted_at")), + } + + return APIResponse(detail={ + "range": {"start": start_day.strftime("%Y-%m-%d"), "end": end_day.strftime("%Y-%m-%d")}, + "totals": { + "totalDownloads": total_downloads, + "downloadedFiles": downloaded_files, + "downloadTraffic": str(download_traffic), + "historicalFiles": len(historical_codes), + "currentFiles": len(files), + "totalUploads": total_uploads, + "uploadTraffic": str(upload_traffic), + }, + "daily": list(day_map.values()), + "topFiles": [file_history_row(code, data) for code, data in top_files], + "historyFiles": [file_history_row(code, data) for code, data in all_history_files], + }) +''' + text = re.sub(r'\n@admin_api\.get\("/analytics"\)\nasync def analytics\(start: str = "", end: str = ""\):.*?\n\n@admin_api\.delete\("/file/delete"\)', '\n' + analytics + '\n\n@admin_api.delete("/file/delete")', text, flags=re.S) + if '@admin_api.get("/analytics")' not in text: + text = text.replace('\n\n@admin_api.delete("/file/delete")', '\n' + analytics + '\n\n@admin_api.delete("/file/delete")') + admin.write_text(text, encoding='utf-8') + + +def patch_admin_services() -> None: + services = Path('/app/apps/admin/services.py') + text = services.read_text(encoding='utf-8') + text = text.replace('from apps.base.models import FileCodes, KeyValue, file_codes_pydantic', 'from apps.base.models import FileCodes, KeyValue, TransferStats, file_codes_pydantic') + if 'import json' not in text: + text = 'import json\n' + text + if 'import datetime' not in text and 'from datetime import datetime' not in text: + text = 'import datetime\n' + text + marker = ' item = await file_codes_pydantic.from_tortoise_orm(file_code)\n data = item.model_dump()\n' + replacement = ''' item = await file_codes_pydantic.from_tortoise_orm(file_code) + data = item.model_dump() + download_events = await TransferStats.filter(action="download", file_code_id=file_code.id) + download_count = len(download_events) + download_traffic = sum(event.size or 0 for event in download_events) +''' + if marker in text and 'download_events = await TransferStats.filter(action="download", file_code_id=file_code.id)' not in text: + text = text.replace(marker, replacement, 1) + data_marker = ''' "usedCount": file_code.used_count, + "used_count": file_code.used_count, + "createdAt": file_code.created_at,''' + data_repl = ''' "usedCount": file_code.used_count, + "used_count": file_code.used_count, + "downloadCount": download_count, + "download_count": download_count, + "downloadTraffic": str(download_traffic), + "download_traffic": str(download_traffic), + "createdAt": file_code.created_at,''' + if data_marker in text and '"downloadCount": download_count' not in text: + text = text.replace(data_marker, data_repl, 1) + + old_delete = ' async def _delete_file_code(self, file_code: FileCodes):\n if file_code.text is None:\n await self.file_storage.delete_file(file_code)\n await KeyValue.filter(key=self._file_metadata_key(file_code.id)).delete()\n await file_code.delete()\n' + new_delete = ' async def _delete_file_code(self, file_code: FileCodes):\n try:\n name = f"{file_code.prefix or \'\'}{file_code.suffix or \'\'}" or file_code.uuid_file_name or file_code.code\n await TransferStats.create(\n action="delete",\n file_code_id=file_code.id,\n code=file_code.code,\n name=name,\n size=int(file_code.size or 0),\n ip="admin-delete",\n file_path=file_code.file_path or "",\n expired_at=file_code.expired_at,\n deleted_at=datetime.now(),\n meta=json.dumps({\n "prefix": file_code.prefix or "",\n "suffix": file_code.suffix or "",\n "uuid_file_name": file_code.uuid_file_name or "",\n "file_hash": file_code.file_hash or "",\n "is_chunked": bool(file_code.is_chunked),\n "upload_id": file_code.upload_id or "",\n "used_count": file_code.used_count,\n "expired_count": file_code.expired_count,\n "created_at": str(file_code.created_at),\n "expired_at": str(file_code.expired_at) if file_code.expired_at else "",\n }, ensure_ascii=False),\n )\n except Exception:\n pass\n if file_code.text is None:\n await self.file_storage.delete_file(file_code)\n await KeyValue.filter(key=self._file_metadata_key(file_code.id)).delete()\n await file_code.delete()\n' + if old_delete in text and 'action="delete"' not in text: + text = text.replace(old_delete, new_delete, 1) + text = text.replace('deleted_at=datetime.datetime.now(datetime.timezone.utc),', 'deleted_at=datetime.now(),') + if 'meta=json.dumps(' in text and 'import json' not in text: + text = 'import json\n' + text + + # Ensure every lifecycle row has a visible, meaningful timestamp. + text = text.replace(''' "timestamp": None, + } + ) + elif file_code.expired_at is not None:''', ''' "timestamp": file_code.created_at, + } + ) + elif file_code.expired_at is not None:''') + text = text.replace(''' "timestamp": None, + "value": remaining_downloads, + } + ) + else:''', ''' "timestamp": file_code.created_at, + "value": remaining_downloads, + } + ) + else:''') + text = text.replace(''' "timestamp": None, + "value": None, + } + ) + + timeline.append(''', ''' "timestamp": file_code.created_at, + "value": None, + } + ) + + timeline.append(''') + text = text.replace(''' "timestamp": None, + "value": file_code.used_count, + } + )''', ''' "timestamp": now if file_code.used_count > 0 else file_code.created_at, + "value": file_code.used_count, + } + )''') + services.write_text(text, encoding='utf-8') + + +def patch_official_admin_assets() -> None: + changed = [] + for path in assets.glob('index-*.js'): + text = path.read_text(encoding='utf-8') + old = text + replacements = { + 'monitor:"Keep monitoring"': 'monitor:""', + 'nextAction:"Next Action"': 'nextAction:""', + 'neutral:"Watch"': 'neutral:""', + 'extend24h:"Extend 24h"': 'extend24h:""', + 'extend7d:"Extend 7d"': 'extend7d:""', + 'makePermanent:"Make Permanent"': 'makePermanent:""', + 'monitor:"保持观察"': 'monitor:""', + 'nextAction:"下一步"': 'nextAction:""', + 'neutral:"观察"': 'neutral:""', + } + for source, target in replacements.items(): + text = text.replace(source, target) + if text != old: + rewrite_gzip(path, text) + changed.append(path.name) + for path in assets.glob('FileManageView-*.js'): + text = path.read_text(encoding='utf-8') + old = text + text = text.replace('"mt-4 flex items-center justify-between px-6 py-4 border-t"', '"mt-4 flex items-center justify-between gap-3 px-6 py-4 border-t fcbx-native-pagination"') + text = text.replace('"flex items-center space-x-2"', '"flex items-center space-x-2 fcbx-native-pagination-buttons"') + text = text.replace('"flex items-center space-x-1"', '"flex items-center space-x-1 fcbx-native-pagination-pages"') + if text != old: + rewrite_gzip(path, text) + changed.append(path.name) + print('official admin asset patch:', changed or 'no official asset marker changed') + + +def write_frontend_enhancement() -> None: + js = r'''(() => { + const css = `:root{--fcbx-radius:16px;--fcbx-border:#e5e7eb;--fcbx-surface:rgba(255,255,255,.92);--fcbx-surface-2:rgba(249,250,251,.96);--fcbx-card-shadow:0 10px 15px -3px rgba(0,0,0,.10),0 4px 6px -4px rgba(0,0,0,.10);--fcbx-shadow:0 10px 15px -3px rgba(0,0,0,.10),0 4px 6px -4px rgba(0,0,0,.10)} +html.dark,html.dark body,body.dark{--fcbx-border:#374151;--fcbx-surface:rgba(31,41,55,.78);--fcbx-surface-2:rgba(17,24,39,.88);--fcbx-card-shadow:0 16px 28px -14px rgba(0,0,0,.62),0 8px 18px -12px rgba(0,0,0,.55);--fcbx-shadow:0 14px 24px -12px rgba(0,0,0,.55),0 8px 16px -12px rgba(0,0,0,.48)} +body.fcbx-admin #app .rounded-md,body.fcbx-admin #app .rounded-lg,body.fcbx-admin #app .rounded-xl,body.fcbx-admin #app .rounded-2xl,body.fcbx-admin #app section[class*="rounded"],body.fcbx-admin #app div[class*="rounded"]{border-radius:16px!important} +body.fcbx-admin #app button,body.fcbx-admin #app input:not([type="checkbox"]),body.fcbx-admin #app textarea,body.fcbx-admin #app select{border-radius:16px!important} +body.fcbx-admin #app table{border-radius:0!important;overflow:visible!important} +body.fcbx-admin main section[class*="rounded"],body.fcbx-admin main div[class*="rounded"][class*="border"],body.fcbx-admin main div[class*="shadow"]{box-shadow:var(--fcbx-card-shadow)!important;border-color:var(--fcbx-border)!important} +body.fcbx-admin main section[class*="rounded"]:hover,body.fcbx-admin main div[class*="rounded"][class*="border"]:hover{box-shadow:0 18px 28px -12px rgba(15,23,42,.22),0 8px 14px -10px rgba(15,23,42,.18)!important} +html.dark body.fcbx-admin main section[class*="rounded"],html.dark body.fcbx-admin main div[class*="rounded"][class*="border"],body.dark.fcbx-admin main section[class*="rounded"],body.dark.fcbx-admin main div[class*="rounded"][class*="border"]{box-shadow:var(--fcbx-card-shadow)!important} +body.fcbx-admin main .grid{gap:1.5rem!important} +.fcbx-native-pagination-hidden{display:none!important} +.fcbx-official-pager{display:flex!important;visibility:visible!important;opacity:1!important;align-items:center!important;justify-content:flex-end!important;gap:10px!important;flex-wrap:wrap!important;margin:14px 0 0!important;padding:14px!important;border-top:1px solid var(--fcbx-border)!important;background:var(--fcbx-surface-2)!important;border-radius:0 0 16px 16px!important} +.fcbx-official-pager .fcbx-muted{margin-right:auto!important}.fcbx-official-pager select,.fcbx-official-pager input{height:40px;border:1px solid var(--fcbx-border);padding:0 12px;background:var(--fcbx-surface);color:inherit}.fcbx-official-pager button{height:40px;border:1px solid #4f46e5;background:#4f46e5;color:#fff;padding:0 14px;font-weight:700}.fcbx-official-pager button:disabled{opacity:.45;cursor:not-allowed;background:var(--fcbx-surface-2);color:#6b7280;border-color:var(--fcbx-border)} +@media(max-width:768px){body.fcbx-admin main .grid{gap:1rem!important}.fcbx-official-pager{justify-content:stretch!important}.fcbx-official-pager>*{flex:1 1 auto!important}.fcbx-official-pager .fcbx-muted{flex:1 1 100%!important}.fcbx-official-pager input{width:76px!important;flex:0 0 76px!important}}`; + const ensureStyle = () => { let style = document.getElementById('fcbx-style'); if (!style) { style = document.createElement('style'); style.id='fcbx-style'; document.head.appendChild(style); } style.textContent = css; }; + const updateMode = () => { const login = location.hash.includes('/login') || location.hash.includes('/admin/login'); const admin = location.hash.includes('/admin') && !login; document.body.classList.toggle('fcbx-admin', admin); }; + const closeMobileDrawer = () => { if (window.innerWidth >= 1024) return; const aside = document.querySelector('aside'); if (!aside) return; aside.classList.remove('translate-x-0'); aside.classList.add('-translate-x-full'); }; + const bindDrawer = () => { if (window.__fcbxSafeDrawerBound) return; window.__fcbxSafeDrawerBound = true; document.addEventListener('click', e => { if (e.target?.closest?.('aside a, aside nav a') && window.innerWidth < 1024) { setTimeout(closeMobileDrawer,0); setTimeout(closeMobileDrawer,160); } }, true); window.addEventListener('hashchange', () => { setTimeout(closeMobileDrawer,0); setTimeout(closeMobileDrawer,180); }); }; + const findFilesTableCard = () => { const table = [...document.querySelectorAll('main table')].find(t => /Retrieve Code|取件码/.test([...t.querySelectorAll('th')].map(th=>th.textContent).join('|'))); return table?.closest('section,div[class*="rounded"]') || null; }; + const mountFilesPager = () => { + if (!location.hash.includes('/admin/files')) return; + const native = document.querySelector('.fcbx-native-pagination'); + const card = findFilesTableCard(); + const mount = native || card; if (!mount) return; + native?.classList.add('fcbx-native-pagination-hidden'); + let pager = document.getElementById('fcbx-allfiles-pager'); + if (!pager) { pager = document.createElement('div'); pager.id='fcbx-allfiles-pager'; pager.className='fcbx-official-pager'; native ? native.after(pager) : card.appendChild(pager); } + const text = native?.textContent?.replace(/\s+/g,' ') || card?.textContent || ''; + const m = text.match(/显示第\s*(\d+)\s*到\s*(\d+)\s*条,共\s*(\d+)\s*条/); + const total = Number(m?.[3] || card?.querySelectorAll('tbody tr')?.length || 0); + const params = new URLSearchParams((location.hash.split('?')[1] || '')); + const size = Number(params.get('size') || 10); const page = Number(params.get('page') || 1); const pages = Math.max(1, Math.ceil(total / size)); + pager.innerHTML = `第 ${Math.min(page,pages)} / ${pages} 页,共 ${total} 条`; + const setHash = patch => { const [path, query=''] = location.hash.split('?'); const p = new URLSearchParams(query); Object.entries(patch).forEach(([k,v])=>p.set(k,String(v))); location.hash = `${path || '#/admin/files'}?${p.toString()}`; }; + document.getElementById('fcbx-allfiles-size').onchange = e => setHash({page:1,size:e.target.value}); + document.getElementById('fcbx-allfiles-prev').onclick = () => setHash({page:Math.max(1,page-1),size}); + document.getElementById('fcbx-allfiles-next').onclick = () => setHash({page:Math.min(pages,page+1),size}); + document.getElementById('fcbx-allfiles-go').onclick = () => setHash({page:Math.min(pages,Math.max(1,Number(document.getElementById('fcbx-allfiles-page').value)||1)),size}); + }; + const run = () => { ensureStyle(); updateMode(); bindDrawer(); mountFilesPager(); }; + window.addEventListener('hashchange', run); document.addEventListener('DOMContentLoaded', run); setInterval(run, 1500); run(); +})();''' + rewrite_gzip(assets / 'fcb-analytics.js', js) + index = Path('/app/themes/2024/index.html') + text = index.read_text(encoding='utf-8') + if '/assets/fcb-analytics.js' not in text: + text = text.replace('', '\n') + else: + text = re.sub(r'', '', text) + index.write_text(text, encoding='utf-8') + + +patch_chunk_size() +patch_models_and_migration() +patch_base_views() +patch_admin_views() +patch_admin_services() +patch_official_admin_assets() +write_frontend_enhancement() +print('filecodebox liquid analytics patch done') diff --git a/deploy/1panel/current-app/docker-compose.yml b/deploy/1panel/current-app/docker-compose.yml new file mode 100644 index 0000000..67085de --- /dev/null +++ b/deploy/1panel/current-app/docker-compose.yml @@ -0,0 +1,21 @@ +networks: + 1panel-network: + external: true +services: + filecodebox: + container_name: ${CONTAINER_NAME} + deploy: + resources: + limits: + cpus: ${CPUS} + memory: ${MEMORY_LIMIT} + image: local/filecodebox:50mb-chunk + labels: + createdBy: Apps + networks: + - 1panel-network + ports: + - ${HOST_IP}:${PANEL_APP_PORT_HTTP}:12345 + restart: always + volumes: + - ./data:/app/data \ No newline at end of file diff --git a/deploy/1panel/current-app/env.sample.raw b/deploy/1panel/current-app/env.sample.raw new file mode 100644 index 0000000..dc241f6 --- /dev/null +++ b/deploy/1panel/current-app/env.sample.raw @@ -0,0 +1,7 @@ +CPUS=0 +MEMORY_LIMIT=0 +HOST_IP='' +format='' +collation='' +PANEL_APP_PORT_HTTP=40157 +CONTAINER_NAME='filecodebox' diff --git a/deploy/1panel/current-app/update-50mb-image.sh b/deploy/1panel/current-app/update-50mb-image.sh new file mode 100755 index 0000000..6ccc5aa --- /dev/null +++ b/deploy/1panel/current-app/update-50mb-image.sh @@ -0,0 +1,80 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +APP_DIR="/opt/1panel/apps/filecodebox/filecodebox" +CUSTOM_DIR="$APP_DIR/custom" +COMPOSE_FILE="$APP_DIR/docker-compose.yml" +UPSTREAM_IMAGE="lanol/filecodebox:beta" +LOCAL_IMAGE="local/filecodebox:50mb-chunk" +CONTAINER_NAME="${CONTAINER_NAME:-filecodebox}" +LOG_DIR="$APP_DIR/update-logs" +LOG_FILE="$LOG_DIR/update-$(date +%Y%m%d-%H%M%S).log" + +mkdir -p "$CUSTOM_DIR" "$LOG_DIR" +exec > >(tee -a "$LOG_FILE") 2>&1 + +echo "===== FileCodeBox custom image update start: $(date '+%F %T') =====" +echo "APP_DIR=$APP_DIR" +echo "UPSTREAM_IMAGE=$UPSTREAM_IMAGE" +echo "LOCAL_IMAGE=$LOCAL_IMAGE" + +cd "$APP_DIR" + +if [ -f "$APP_DIR/data/filecodebox.db" ]; then + cp -a "$APP_DIR/data/filecodebox.db" "$APP_DIR/data/filecodebox.db.bak.$(date +%Y%m%d%H%M%S)" + echo "database backup created" +fi + +echo "[1/6] Pull upstream image" +docker pull "$UPSTREAM_IMAGE" + +echo "[2/6] Ensure deterministic Dockerfile patch" +cat > "$CUSTOM_DIR/Dockerfile" <<'DOCKERFILE_EOF' +FROM lanol/filecodebox:beta + +COPY fcb_extra_patch.py /tmp/fcb_extra_patch.py +RUN python /tmp/fcb_extra_patch.py && rm -f /tmp/fcb_extra_patch.py +DOCKERFILE_EOF + +test -f "$CUSTOM_DIR/fcb_extra_patch.py" + +echo "[3/6] Build local image" +docker build --pull -t "$LOCAL_IMAGE" "$CUSTOM_DIR" + +echo "[4/6] Ensure docker-compose uses local image" +cp -a "$COMPOSE_FILE" "$COMPOSE_FILE.bak.$(date +%Y%m%d%H%M%S)" +python3 - <<'PY' +from pathlib import Path +p = Path('/opt/1panel/apps/filecodebox/filecodebox/docker-compose.yml') +s = p.read_text(encoding='utf-8') +lines = [] +for line in s.splitlines(): + if line.strip().startswith('image:'): + indent = line[:len(line)-len(line.lstrip())] + lines.append(indent + 'image: local/filecodebox:50mb-chunk') + else: + lines.append(line) +p.write_text('\n'.join(lines) + ('\n' if s.endswith('\n') else ''), encoding='utf-8') +PY + +echo "[5/6] Validate and recreate container" +docker compose -f "$COMPOSE_FILE" config >/dev/null +docker compose -f "$COMPOSE_FILE" up -d --no-build + +echo "[6/6] Verify running patch" +docker inspect "$CONTAINER_NAME" --format 'Image={{.Config.Image}} Mounts={{json .Mounts}}' +docker exec "$CONTAINER_NAME" sh -lc 'test -f /app/themes/2024/assets/fcb-analytics.js && grep -q "class TransferStats" /app/apps/base/models.py && grep -q "/analytics" /app/apps/admin/views.py && grep -q "downloadTraffic" /app/themes/2024/assets/fcb-analytics.js && echo "analytics files ok"' +for i in $(seq 1 12); do + if curl -fsS --max-time 10 "http://127.0.0.1:40157/" >/dev/null; then + echo "http ok" + break + fi + if [ "$i" = 12 ]; then + echo "http health check failed after retries" >&2 + exit 1 + fi + sleep 5 +done + +echo "===== FileCodeBox custom image update done: $(date '+%F %T') =====" +echo "Log: $LOG_FILE" diff --git a/deploy/1panel/local-app/README.md b/deploy/1panel/local-app/README.md new file mode 100644 index 0000000..5d6918a --- /dev/null +++ b/deploy/1panel/local-app/README.md @@ -0,0 +1,37 @@ +
+

文件快递柜-轻量

+

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

+
+ + +--- + + +
+ 简体中文 | + English | + 部署教程 | + 常见问题 +
+ +## 使用说明 + +- 后端地址:`/#/admin` + +- 后台密码:`FileCodeBox2023` + +## 主要特色 + +- [x] **轻量简洁:** 项目基于Fastapi + Sqlite3 + Vue3 + ElementUI +- [x] **轻松上传:** 支持复制粘贴和拖拽选择 +- [x] **多种类型:** 支持文本和文件 +- [x] **防止爆破:** 错误次数限制 +- [x] **防止滥用:** IP限制上传次数 +- [x] **口令分享:** 随机口令,存取文件,自定义次数及有效期 +- [x] **国际化:** 支持中文简体、繁体以及英文等 +- [x] **匿名分享:** 无需注册,无需登录 +- [x] **管理面板:** 查看和删除文件 +- [x] **一键部署:** 支持Docker一键部署 +- [x] **自由拓展:** 支持S3协议和本地文件流,可根据需求在storage文件中新增存储引擎 +- [x] **简单明了:** 适合新手练手项目 +- [x] **终端下载:** 终端命令`wget https://share.lanol.cn/share/select?code=83432` diff --git a/deploy/1panel/local-app/beta/.env.sample b/deploy/1panel/local-app/beta/.env.sample new file mode 100644 index 0000000..be23ecc --- /dev/null +++ b/deploy/1panel/local-app/beta/.env.sample @@ -0,0 +1,3 @@ +CONTAINER_NAME="filecodebox" +PANEL_APP_PORT_HTTP="40157" +DATA_PATH="./data" diff --git a/deploy/1panel/local-app/beta/data.yml b/deploy/1panel/local-app/beta/data.yml new file mode 100644 index 0000000..ea0b32e --- /dev/null +++ b/deploy/1panel/local-app/beta/data.yml @@ -0,0 +1,35 @@ +additionalProperties: + formFields: + - default: 40157 + edit: true + envKey: PANEL_APP_PORT_HTTP + labelEn: Port + labelZh: 端口 + label: + en: 'Port' + zh: '端口' + zh-Hant: '埠' + ja: 'ポート' + ko: '포트' + ru: 'Порт' + ms: 'Port' + pt-br: 'Porta' + required: true + rule: paramPort + type: number + - default: ./data + edit: true + envKey: DATA_PATH + labelEn: Data folder path + labelZh: 数据文件夹路径 + label: + en: 'Data folder path' + zh: '数据文件夹路径' + zh-Hant: '資料夾路徑' + ja: 'データフォルダパス' + ko: '데이터 폴더 경로' + ru: 'Путь к папке данных' + ms: 'Laluan folder data' + pt-br: 'Caminho da pasta de dados' + required: true + type: text diff --git a/deploy/1panel/local-app/beta/docker-compose.yml b/deploy/1panel/local-app/beta/docker-compose.yml new file mode 100644 index 0000000..3f667b4 --- /dev/null +++ b/deploy/1panel/local-app/beta/docker-compose.yml @@ -0,0 +1,17 @@ +services: + filecodebox: + container_name: ${CONTAINER_NAME} + restart: always + networks: + - 1panel-network + ports: + - "${PANEL_APP_PORT_HTTP}:12345" + volumes: + - "${DATA_PATH}:/app/data" + image: lanol/filecodebox:beta + labels: + createdBy: "Apps" + +networks: + 1panel-network: + external: true diff --git a/deploy/1panel/local-app/data.yml b/deploy/1panel/local-app/data.yml new file mode 100644 index 0000000..ed41942 --- /dev/null +++ b/deploy/1panel/local-app/data.yml @@ -0,0 +1,30 @@ +name: FileCodeBox +tags: + - 实用工具 +title: 文件快递柜-匿名口令分享文本,文件,像拿快递一样取文件 +description: 文件快递柜-匿名口令分享文本,文件,像拿快递一样取文件 +additionalProperties: + key: filecodebox + name: FileCodeBox + tags: + - Tool + shortDescZh: 文件快递柜-匿名口令分享文本,文件,像拿快递一样取文件 + shortDescEn: Anonymous Passcode Sharing Text, Files, Like Taking Express Delivery for Files + description: + en: File locker for anonymous passcode sharing of text and files, pickup like parcel delivery + zh: 文件快递柜-匿名口令分享文本,文件,像拿快递一样取文件 + zh-Hant: 文件快遞櫃—匿名口令分享文字、檔案,像拿快遞一樣取檔 + ja: ファイル宅配ボックス。匿名の合言葉でテキストやファイルを共有し、宅配の受け取りのように取得 + ko: 파일 택배함—익명 비밀번호로 텍스트와 파일을 공유하고, 택배 받듯이 가져오기 + ru: "Файловый шкафчик: анонимный код для обмена текстами и файлами, получение как посылки" + ms: Loker fail—perkongsian teks dan fail dengan kod laluan tanpa nama, ambil seperti menerima bungkusan + pt-br: Armário de arquivos — compartilhamento anônimo de textos e arquivos por senha, retirar como se fosse encomenda + type: tool + crossVersionUpdate: true + limit: 0 + recommend: 0 + website: https://share.lanol.cn + github: https://github.com/vastsa/FileCodeBox + document: https://github.com/vastsa/FileCodeBox + architectures: + - amd64 diff --git a/deploy/1panel/local-app/logo.png b/deploy/1panel/local-app/logo.png new file mode 100644 index 0000000000000000000000000000000000000000..1b010a4f6ae543bad1c2f6c7bd58db731c5888cb GIT binary patch literal 6396 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 literal 0 HcmV?d00001 diff --git a/deploy/1panel/remote-app/README.md b/deploy/1panel/remote-app/README.md new file mode 100644 index 0000000..aaefe66 --- /dev/null +++ b/deploy/1panel/remote-app/README.md @@ -0,0 +1,23 @@ +## 默认账户信息 + +后端地址: `/#/admin` +默认密码: `FileCodeBox2023` + +## 产品介绍 + +文件快递柜-匿名口令分享文本,文件,像拿快递一样取文件(FileCodeBox - File Express Cabinet - Anonymous Passcode Sharing Text, Files, Like Taking Express Delivery for Files) + +## 主要特色 + +- [x] 轻量简洁:Fastapi+Sqlite3+Vue3 +- [x] 轻松上传:复制粘贴,拖拽选择 +- [x] 多种类型:文本,文件 +- [x] 防止爆破:错误次数限制 +- [x] 防止滥用:IP限制上传次数 +- [x] 口令分享:随机口令,存取文件,自定义次数以及有效期 +- [x] 国际化:支持中文和英文 +- [x] 匿名分享:无需注册,无需登录 +- [x] 管理面板:查看所有文件,删除文件 +- [x] 一键部署:docker一键部署 +- [x] 自由拓展:S3协议、本地文件流、webdav,可根据需求在storage文件中新增存储引擎 +- [x] 简单明了:适合新手练手项目 diff --git a/deploy/1panel/remote-app/beta/data.yml b/deploy/1panel/remote-app/beta/data.yml new file mode 100644 index 0000000..0582c9a --- /dev/null +++ b/deploy/1panel/remote-app/beta/data.yml @@ -0,0 +1,10 @@ +additionalProperties: + formFields: + - default: 40157 + edit: true + envKey: PANEL_APP_PORT_HTTP + labelEn: Port + labelZh: 端口 + required: true + rule: paramPort + type: number diff --git a/deploy/1panel/remote-app/beta/docker-compose.yml b/deploy/1panel/remote-app/beta/docker-compose.yml new file mode 100644 index 0000000..56b2d29 --- /dev/null +++ b/deploy/1panel/remote-app/beta/docker-compose.yml @@ -0,0 +1,16 @@ +services: + filecodebox: + container_name: ${CONTAINER_NAME} + image: lanol/filecodebox:beta + restart: always + networks: + - 1panel-network + ports: + - "${PANEL_APP_PORT_HTTP}:12345" + volumes: + - "./data:/app/data" + labels: + createdBy: "Apps" +networks: + 1panel-network: + external: true diff --git a/deploy/1panel/remote-app/data.yml b/deploy/1panel/remote-app/data.yml new file mode 100644 index 0000000..a42b47d --- /dev/null +++ b/deploy/1panel/remote-app/data.yml @@ -0,0 +1,26 @@ +name: FileCodeBox +tags: +- 实用工具 +- 云存储 +title: 文件快递柜-匿名口令分享文本,文件,像拿快递一样取文件 +description: 文件快递柜-匿名口令分享文本,文件,像拿快递一样取文件 +additionalProperties: + key: filecodebox + name: FileCodeBox + tags: + - Storage + - Tool + shortDescZh: 文件快递柜-匿名口令分享文本,文件,像拿快递一样取文件 + shortDescEn: File Express Cabinet - Anonymous Passcode Sharing Text, Files, Like + Taking Express Delivery for Files + type: website + crossVersionUpdate: true + limit: 1 + recommend: 0 + batchInstallSupport: true + website: https://share.lanol.cn + github: https://github.com/vastsa/FileCodeBox + document: https://fcb-docs.aiuo.net + architectures: + - amd64 + - arm64 diff --git a/deploy/1panel/remote-app/logo.png b/deploy/1panel/remote-app/logo.png new file mode 100644 index 0000000000000000000000000000000000000000..60b143a8056504618f1ce887b89644b4fb8e3621 GIT binary patch literal 7269 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