🧰 纳入 FileCodeBox 1Panel 定制资料
This commit is contained in:
@@ -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。
|
||||||
@@ -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
|
||||||
@@ -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 = `<span class="fcbx-muted">第 ${Math.min(page,pages)} / ${pages} 页,共 ${total} 条</span><select id="fcbx-allfiles-size">${[10,20,50,100].map(n=>`<option value="${n}" ${n===size?'selected':''}>${n}/页</option>`).join('')}</select><button id="fcbx-allfiles-prev" ${page<=1?'disabled':''}>上一页</button><input id="fcbx-allfiles-page" type="number" min="1" max="${pages}" value="${Math.min(page,pages)}"><button id="fcbx-allfiles-go">跳转</button><button id="fcbx-allfiles-next" ${page>=pages?'disabled':''}>下一页</button>`;
|
||||||
|
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('</body>', '<script src="/assets/fcb-analytics.js?v=20260605-safe-no-api06"></script>\n</body>')
|
||||||
|
else:
|
||||||
|
text = re.sub(r'<script src="/assets/fcb-analytics\.js(?:\?v=[^"]+)?"></script>', '<script src="/assets/fcb-analytics.js?v=20260605-safe-no-api06"></script>', 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')
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
CPUS=0
|
||||||
|
MEMORY_LIMIT=0
|
||||||
|
HOST_IP=''
|
||||||
|
format=''
|
||||||
|
collation=''
|
||||||
|
PANEL_APP_PORT_HTTP=40157
|
||||||
|
CONTAINER_NAME='filecodebox'
|
||||||
+80
@@ -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"
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
<div align="center">
|
||||||
|
<h1>文件快递柜-轻量</h1>
|
||||||
|
<p><em>匿名口令分享文本,文件,像拿快递一样取文件</em></p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
|
||||||
|
<div align="center" style="text-align: center;margin: 20px">
|
||||||
|
<a href="https://github.com/vastsa/FileCodeBox/blob/master/readme.md">简体中文</a> |
|
||||||
|
<a href="https://github.com/vastsa/FileCodeBox/blob/master/readme_en.md">English</a> |
|
||||||
|
<a href="https://github.com/vastsa/FileCodeBox/wiki/%E9%83%A8%E7%BD%B2%E6%95%99%E7%A8%8B">部署教程</a> |
|
||||||
|
<a href="https://github.com/vastsa/FileCodeBox/wiki/%E9%83%A8%E7%BD%B2%E6%95%99%E7%A8%8B">常见问题</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
## 使用说明
|
||||||
|
|
||||||
|
- 后端地址:`/#/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`
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
CONTAINER_NAME="filecodebox"
|
||||||
|
PANEL_APP_PORT_HTTP="40157"
|
||||||
|
DATA_PATH="./data"
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 6.2 KiB |
@@ -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] 简单明了:适合新手练手项目
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
additionalProperties:
|
||||||
|
formFields:
|
||||||
|
- default: 40157
|
||||||
|
edit: true
|
||||||
|
envKey: PANEL_APP_PORT_HTTP
|
||||||
|
labelEn: Port
|
||||||
|
labelZh: 端口
|
||||||
|
required: true
|
||||||
|
rule: paramPort
|
||||||
|
type: number
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 7.1 KiB |
Reference in New Issue
Block a user