Use forked frontend builds for test admin UI
This commit is contained in:
+6
-2
@@ -9,13 +9,17 @@ RUN corepack enable && \
|
|||||||
WORKDIR /build
|
WORKDIR /build
|
||||||
|
|
||||||
# 克隆并构建 2024 主题
|
# 克隆并构建 2024 主题
|
||||||
RUN git clone --depth 1 https://github.com/vastsa/FileCodeBoxFronted.git /build/fronted-2024 && \
|
ARG FRONTEND_2024_REPO=https://git.orionc.me/orion/FileCodeBoxFronted.git
|
||||||
|
ARG FRONTEND_2024_BRANCH=main
|
||||||
|
RUN git clone --depth 1 --branch "$FRONTEND_2024_BRANCH" "$FRONTEND_2024_REPO" /build/fronted-2024 && \
|
||||||
cd /build/fronted-2024 && \
|
cd /build/fronted-2024 && \
|
||||||
pnpm install --frozen-lockfile --prod=false && \
|
pnpm install --frozen-lockfile --prod=false && \
|
||||||
pnpm run build
|
pnpm run build
|
||||||
|
|
||||||
# 克隆并构建 2023 主题
|
# 克隆并构建 2023 主题
|
||||||
RUN git clone --depth 1 https://github.com/vastsa/FileCodeBoxFronted2023.git /build/fronted-2023 && \
|
ARG FRONTEND_2023_REPO=https://git.orionc.me/orion/FileCodeBoxFronted2023.git
|
||||||
|
ARG FRONTEND_2023_BRANCH=main
|
||||||
|
RUN git clone --depth 1 --branch "$FRONTEND_2023_BRANCH" "$FRONTEND_2023_REPO" /build/fronted-2023 && \
|
||||||
cd /build/fronted-2023 && \
|
cd /build/fronted-2023 && \
|
||||||
npm install --legacy-peer-deps && \
|
npm install --legacy-peer-deps && \
|
||||||
npm run build
|
npm run build
|
||||||
|
|||||||
+30
-1
@@ -1,4 +1,5 @@
|
|||||||
import hashlib
|
import hashlib
|
||||||
|
import json
|
||||||
import os
|
import os
|
||||||
import time
|
import time
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
@@ -8,7 +9,7 @@ from core.response import APIResponse
|
|||||||
from core.storage import FileStorageInterface, storages
|
from core.storage import FileStorageInterface, storages
|
||||||
from core.settings import settings
|
from core.settings import settings
|
||||||
from core.config import refresh_settings
|
from core.config import refresh_settings
|
||||||
from apps.base.models import FileCodes, KeyValue, file_codes_pydantic
|
from apps.base.models import FileCodes, KeyValue, TransferStats, file_codes_pydantic
|
||||||
from apps.base.utils import get_expire_info, get_file_path_name
|
from apps.base.utils import get_expire_info, get_file_path_name
|
||||||
from fastapi import HTTPException
|
from fastapi import HTTPException
|
||||||
from core.settings import data_root
|
from core.settings import data_root
|
||||||
@@ -82,6 +83,34 @@ class FileService:
|
|||||||
return f"{self.FILE_METADATA_KEY_PREFIX}{file_id}"
|
return f"{self.FILE_METADATA_KEY_PREFIX}{file_id}"
|
||||||
|
|
||||||
async def _delete_file_code(self, file_code: FileCodes):
|
async def _delete_file_code(self, file_code: FileCodes):
|
||||||
|
try:
|
||||||
|
name = (
|
||||||
|
f"{file_code.prefix or ''}{file_code.suffix or ''}"
|
||||||
|
or file_code.uuid_file_name
|
||||||
|
or file_code.code
|
||||||
|
)
|
||||||
|
await TransferStats.create(
|
||||||
|
action="delete",
|
||||||
|
file_code_id=file_code.id,
|
||||||
|
code=file_code.code,
|
||||||
|
name=name,
|
||||||
|
size=int(file_code.size or 0),
|
||||||
|
ip="admin-delete",
|
||||||
|
file_path=file_code.file_path or "",
|
||||||
|
expired_at=file_code.expired_at,
|
||||||
|
deleted_at=datetime.now(),
|
||||||
|
meta=json.dumps(
|
||||||
|
{
|
||||||
|
"prefix": file_code.prefix or "",
|
||||||
|
"suffix": file_code.suffix or "",
|
||||||
|
"uuid_file_name": file_code.uuid_file_name or "",
|
||||||
|
"created_at": str(file_code.created_at),
|
||||||
|
},
|
||||||
|
ensure_ascii=False,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
if file_code.text is None:
|
if file_code.text is None:
|
||||||
await self.file_storage.delete_file(file_code)
|
await self.file_storage.delete_file(file_code)
|
||||||
await KeyValue.filter(key=self._file_metadata_key(file_code.id)).delete()
|
await KeyValue.filter(key=self._file_metadata_key(file_code.id)).delete()
|
||||||
|
|||||||
+176
-1
@@ -30,7 +30,7 @@ from apps.admin.schemas import (
|
|||||||
UpdateFileData,
|
UpdateFileData,
|
||||||
)
|
)
|
||||||
from core.response import APIResponse
|
from core.response import APIResponse
|
||||||
from apps.base.models import FileCodes, KeyValue
|
from apps.base.models import FileCodes, KeyValue, TransferStats
|
||||||
from apps.admin.dependencies import create_token
|
from apps.admin.dependencies import create_token
|
||||||
from core.settings import settings
|
from core.settings import settings
|
||||||
from core.utils import get_now, verify_password
|
from core.utils import get_now, verify_password
|
||||||
@@ -174,6 +174,181 @@ async def admin_activities(
|
|||||||
return APIResponse(detail=result)
|
return APIResponse(detail=result)
|
||||||
|
|
||||||
|
|
||||||
|
@admin_api.get("/analytics")
|
||||||
|
async def analytics(start: str = "", end: str = ""):
|
||||||
|
now = await get_now()
|
||||||
|
|
||||||
|
def parse_day(value: str, fallback: datetime.datetime) -> datetime.datetime:
|
||||||
|
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}
|
||||||
|
|
||||||
|
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 and item.action == "download":
|
||||||
|
row["downloads"] += 1
|
||||||
|
row["downloadTraffic"] += item.size or 0
|
||||||
|
elif row and item.action == "upload":
|
||||||
|
row["uploads"] += 1
|
||||||
|
row["uploadTraffic"] += item.size or 0
|
||||||
|
if not item.code:
|
||||||
|
continue
|
||||||
|
bucket = file_stats.setdefault(
|
||||||
|
item.code,
|
||||||
|
{
|
||||||
|
"downloads": 0,
|
||||||
|
"downloadTraffic": 0,
|
||||||
|
"uploads": 0,
|
||||||
|
"uploadTraffic": 0,
|
||||||
|
"name": item.name or item.code,
|
||||||
|
"size": int(item.size or 0),
|
||||||
|
"uploaded_at": None,
|
||||||
|
"expired_at": item.expired_at,
|
||||||
|
"deleted_at": None,
|
||||||
|
"deleted": False,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
bucket["name"] = bucket["name"] or item.name or item.code
|
||||||
|
bucket["size"] = bucket["size"] or int(item.size or 0)
|
||||||
|
bucket["expired_at"] = bucket["expired_at"] or 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"] = item.deleted_at 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),
|
||||||
|
"uploaded_at": file_item.created_at,
|
||||||
|
"expired_at": file_item.expired_at,
|
||||||
|
"deleted_at": None,
|
||||||
|
"deleted": False,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
bucket["name"] = bucket["name"] or current_name
|
||||||
|
bucket["size"] = bucket["size"] or int(file_item.size or 0)
|
||||||
|
bucket["uploaded_at"] = bucket["uploaded_at"] or file_item.created_at
|
||||||
|
bucket["expired_at"] = bucket["expired_at"] or file_item.expired_at
|
||||||
|
|
||||||
|
def iso(value):
|
||||||
|
return value.isoformat() if value else ""
|
||||||
|
|
||||||
|
def history_row(code: str, data: dict) -> dict:
|
||||||
|
file_item = file_map.get(code)
|
||||||
|
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.get("name") or code,
|
||||||
|
"size": str(data.get("size") or 0),
|
||||||
|
"download_count": data.get("downloads") or 0,
|
||||||
|
"download_traffic": str(data.get("downloadTraffic") or 0),
|
||||||
|
"upload_count": data.get("uploads") or 0,
|
||||||
|
"upload_traffic": str(data.get("uploadTraffic") or 0),
|
||||||
|
"current": bool(file_item),
|
||||||
|
"deleted": bool(data.get("deleted")) and not bool(file_item),
|
||||||
|
"created_at": iso(uploaded_at),
|
||||||
|
"uploaded_at": iso(uploaded_at),
|
||||||
|
"expired_at": iso(expired_at),
|
||||||
|
"deleted_at": iso(data.get("deleted_at")),
|
||||||
|
}
|
||||||
|
|
||||||
|
def sort_time(value) -> float:
|
||||||
|
return value.timestamp() if value else 0
|
||||||
|
|
||||||
|
history = sorted(
|
||||||
|
file_stats.items(),
|
||||||
|
key=lambda kv: (
|
||||||
|
sort_time(kv[1].get("deleted_at") or kv[1].get("uploaded_at")),
|
||||||
|
kv[1].get("downloads") or 0,
|
||||||
|
kv[1].get("downloadTraffic") or 0,
|
||||||
|
),
|
||||||
|
reverse=True,
|
||||||
|
)
|
||||||
|
top_files = sorted(
|
||||||
|
[item for item in history if (item[1].get("downloads") or 0) > 0],
|
||||||
|
key=lambda kv: (kv[1].get("downloads") or 0, kv[1].get("downloadTraffic") or 0),
|
||||||
|
reverse=True,
|
||||||
|
)[:5]
|
||||||
|
|
||||||
|
return APIResponse(
|
||||||
|
detail={
|
||||||
|
"range": {
|
||||||
|
"start": start_day.strftime("%Y-%m-%d"),
|
||||||
|
"end": end_day.strftime("%Y-%m-%d"),
|
||||||
|
},
|
||||||
|
"totals": {
|
||||||
|
"totalDownloads": sum(1 for item in stats if item.action == "download"),
|
||||||
|
"downloadedFiles": len(
|
||||||
|
{item.code for item in stats if item.action == "download" and item.code}
|
||||||
|
),
|
||||||
|
"downloadTraffic": str(
|
||||||
|
sum(item.size or 0 for item in stats if item.action == "download")
|
||||||
|
),
|
||||||
|
"historicalFiles": len(file_stats),
|
||||||
|
"currentFiles": len(files),
|
||||||
|
"totalUploads": sum(1 for item in stats if item.action == "upload"),
|
||||||
|
"uploadTraffic": str(
|
||||||
|
sum(item.size or 0 for item in stats if item.action == "upload")
|
||||||
|
),
|
||||||
|
},
|
||||||
|
"daily": list(day_map.values()),
|
||||||
|
"topFiles": [history_row(code, data) for code, data in top_files],
|
||||||
|
"historyFiles": [history_row(code, data) for code, data in history],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@admin_api.delete("/file/delete")
|
@admin_api.delete("/file/delete")
|
||||||
async def file_delete(
|
async def file_delete(
|
||||||
data: IDData,
|
data: IDData,
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
from tortoise import connections
|
||||||
|
|
||||||
|
|
||||||
|
async def migrate():
|
||||||
|
conn = connections.get("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 '',
|
||||||
|
file_path VARCHAR(1024) NOT NULL DEFAULT '',
|
||||||
|
expired_at TIMESTAMP NULL,
|
||||||
|
deleted_at TIMESTAMP NULL,
|
||||||
|
meta TEXT NULL,
|
||||||
|
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);
|
||||||
|
"""
|
||||||
|
)
|
||||||
@@ -54,6 +54,24 @@ class UploadChunk(models.Model):
|
|||||||
completed = fields.BooleanField(default=False)
|
completed = fields.BooleanField(default=False)
|
||||||
|
|
||||||
|
|
||||||
|
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"
|
||||||
|
|
||||||
|
|
||||||
class KeyValue(Model):
|
class KeyValue(Model):
|
||||||
id: Optional[int] = fields.IntField(pk=True)
|
id: Optional[int] = fields.IntField(pk=True)
|
||||||
key: Optional[str] = fields.CharField(
|
key: Optional[str] = fields.CharField(
|
||||||
|
|||||||
+45
-5
@@ -12,7 +12,7 @@ from pydantic import BaseModel, ValidationError
|
|||||||
from starlette import status
|
from starlette import status
|
||||||
|
|
||||||
from apps.admin.dependencies import share_required_login
|
from apps.admin.dependencies import share_required_login
|
||||||
from apps.base.models import FileCodes, UploadChunk, PresignUploadSession
|
from apps.base.models import FileCodes, UploadChunk, PresignUploadSession, TransferStats
|
||||||
from apps.base.schemas import (
|
from apps.base.schemas import (
|
||||||
SelectFileModel,
|
SelectFileModel,
|
||||||
InitChunkUploadModel,
|
InitChunkUploadModel,
|
||||||
@@ -59,6 +59,7 @@ class FileUploadService:
|
|||||||
file_path: str,
|
file_path: str,
|
||||||
expire_value: int,
|
expire_value: int,
|
||||||
expire_style: str,
|
expire_style: str,
|
||||||
|
ip: str = "",
|
||||||
**extra_fields,
|
**extra_fields,
|
||||||
) -> str:
|
) -> str:
|
||||||
"""统一创建FileCodes记录,返回code"""
|
"""统一创建FileCodes记录,返回code"""
|
||||||
@@ -67,7 +68,7 @@ class FileUploadService:
|
|||||||
)
|
)
|
||||||
prefix, suffix = os.path.splitext(file_name)
|
prefix, suffix = os.path.splitext(file_name)
|
||||||
|
|
||||||
await FileCodes.create(
|
file_code = await FileCodes.create(
|
||||||
code=code,
|
code=code,
|
||||||
prefix=prefix,
|
prefix=prefix,
|
||||||
suffix=suffix,
|
suffix=suffix,
|
||||||
@@ -79,6 +80,7 @@ class FileUploadService:
|
|||||||
used_count=used_count,
|
used_count=used_count,
|
||||||
**extra_fields,
|
**extra_fields,
|
||||||
)
|
)
|
||||||
|
await record_transfer_stat("upload", file_code, file_size, ip)
|
||||||
return code
|
return code
|
||||||
|
|
||||||
|
|
||||||
@@ -100,6 +102,36 @@ async def create_file_code(code, **kwargs):
|
|||||||
return await FileCodes.create(code=code, **kwargs)
|
return await FileCodes.create(code=code, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
async def record_transfer_stat(
|
||||||
|
action: str,
|
||||||
|
file_code: FileCodes,
|
||||||
|
size: Optional[int] = None,
|
||||||
|
ip: str = "",
|
||||||
|
deleted_at=None,
|
||||||
|
meta: str = "",
|
||||||
|
) -> None:
|
||||||
|
try:
|
||||||
|
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 "",
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
@share_api.post("/text/", dependencies=[Depends(share_required_login)])
|
@share_api.post("/text/", dependencies=[Depends(share_required_login)])
|
||||||
async def share_text(
|
async def share_text(
|
||||||
text: str = Form(...),
|
text: str = Form(...),
|
||||||
@@ -115,7 +147,7 @@ async def share_text(
|
|||||||
expired_at, expired_count, used_count, code = await get_expire_info(
|
expired_at, expired_count, used_count, code = await get_expire_info(
|
||||||
expire_value, expire_style
|
expire_value, expire_style
|
||||||
)
|
)
|
||||||
await create_file_code(
|
file_code = await create_file_code(
|
||||||
code=code,
|
code=code,
|
||||||
text=text,
|
text=text,
|
||||||
expired_at=expired_at,
|
expired_at=expired_at,
|
||||||
@@ -124,6 +156,7 @@ async def share_text(
|
|||||||
size=len(text),
|
size=len(text),
|
||||||
prefix="Text",
|
prefix="Text",
|
||||||
)
|
)
|
||||||
|
await record_transfer_stat("upload", file_code, text_size, ip)
|
||||||
ip_limit["upload"].add_ip(ip)
|
ip_limit["upload"].add_ip(ip)
|
||||||
return APIResponse(detail={"code": code})
|
return APIResponse(detail={"code": code})
|
||||||
|
|
||||||
@@ -144,7 +177,7 @@ async def share_file(
|
|||||||
path, suffix, prefix, uuid_file_name, save_path = await get_file_path_name(file)
|
path, suffix, prefix, uuid_file_name, save_path = await get_file_path_name(file)
|
||||||
file_storage: FileStorageInterface = storages[settings.file_storage]()
|
file_storage: FileStorageInterface = storages[settings.file_storage]()
|
||||||
await file_storage.save_file(file, save_path)
|
await file_storage.save_file(file, save_path)
|
||||||
await create_file_code(
|
file_code = await create_file_code(
|
||||||
code=code,
|
code=code,
|
||||||
prefix=prefix,
|
prefix=prefix,
|
||||||
suffix=suffix,
|
suffix=suffix,
|
||||||
@@ -155,6 +188,7 @@ async def share_file(
|
|||||||
expired_count=expired_count,
|
expired_count=expired_count,
|
||||||
used_count=used_count,
|
used_count=used_count,
|
||||||
)
|
)
|
||||||
|
await record_transfer_stat("upload", file_code, file_size, ip)
|
||||||
ip_limit["upload"].add_ip(ip)
|
ip_limit["upload"].add_ip(ip)
|
||||||
return APIResponse(detail={"code": code, "name": file.filename})
|
return APIResponse(detail={"code": code, "name": file.filename})
|
||||||
|
|
||||||
@@ -245,6 +279,7 @@ async def get_code_file(code: str, ip: str = Depends(ip_limit["error"])):
|
|||||||
|
|
||||||
assert isinstance(file_code, FileCodes)
|
assert isinstance(file_code, FileCodes)
|
||||||
await update_file_usage(file_code)
|
await update_file_usage(file_code)
|
||||||
|
await record_transfer_stat("download", file_code, file_code.size, ip)
|
||||||
return await file_storage.get_file_response(file_code)
|
return await file_storage.get_file_response(file_code)
|
||||||
|
|
||||||
|
|
||||||
@@ -258,6 +293,7 @@ async def select_file(data: SelectFileModel, ip: str = Depends(ip_limit["error"]
|
|||||||
|
|
||||||
assert isinstance(file_code, FileCodes)
|
assert isinstance(file_code, FileCodes)
|
||||||
await update_file_usage(file_code)
|
await update_file_usage(file_code)
|
||||||
|
await record_transfer_stat("download", file_code, file_code.size, ip)
|
||||||
return APIResponse(detail=await build_select_detail(file_code, file_storage))
|
return APIResponse(detail=await build_select_detail(file_code, file_storage))
|
||||||
|
|
||||||
|
|
||||||
@@ -271,6 +307,7 @@ async def download_file(key: str, code: str, ip: str = Depends(ip_limit["error"]
|
|||||||
if not has:
|
if not has:
|
||||||
return APIResponse(code=404, detail="文件不存在")
|
return APIResponse(code=404, detail="文件不存在")
|
||||||
assert isinstance(file_code, FileCodes)
|
assert isinstance(file_code, FileCodes)
|
||||||
|
await record_transfer_stat("download", file_code, file_code.size, ip)
|
||||||
return (
|
return (
|
||||||
APIResponse(detail=file_code.text)
|
APIResponse(detail=file_code.text)
|
||||||
if file_code.text
|
if file_code.text
|
||||||
@@ -557,7 +594,7 @@ async def complete_upload(
|
|||||||
expired_at, expired_count, used_count, code = await get_expire_info(
|
expired_at, expired_count, used_count, code = await get_expire_info(
|
||||||
data.expire_value, data.expire_style
|
data.expire_value, data.expire_style
|
||||||
)
|
)
|
||||||
await FileCodes.create(
|
file_code = await FileCodes.create(
|
||||||
code=code,
|
code=code,
|
||||||
file_hash=file_hash, # 使用合并后计算的哈希
|
file_hash=file_hash, # 使用合并后计算的哈希
|
||||||
is_chunked=True,
|
is_chunked=True,
|
||||||
@@ -571,6 +608,7 @@ async def complete_upload(
|
|||||||
prefix=prefix,
|
prefix=prefix,
|
||||||
suffix=suffix,
|
suffix=suffix,
|
||||||
)
|
)
|
||||||
|
await record_transfer_stat("upload", file_code, chunk_info.file_size, ip)
|
||||||
# 清理临时文件
|
# 清理临时文件
|
||||||
await storage.clean_chunks(upload_id, save_path)
|
await storage.clean_chunks(upload_id, save_path)
|
||||||
# 清理数据库中的分片记录
|
# 清理数据库中的分片记录
|
||||||
@@ -697,6 +735,7 @@ async def presign_upload_proxy(
|
|||||||
os.path.dirname(session.save_path),
|
os.path.dirname(session.save_path),
|
||||||
session.expire_value,
|
session.expire_value,
|
||||||
session.expire_style,
|
session.expire_style,
|
||||||
|
ip=ip,
|
||||||
)
|
)
|
||||||
|
|
||||||
await session.delete()
|
await session.delete()
|
||||||
@@ -721,6 +760,7 @@ async def presign_upload_confirm(upload_id: str, ip: str = Depends(ip_limit["upl
|
|||||||
os.path.dirname(session.save_path),
|
os.path.dirname(session.save_path),
|
||||||
session.expire_value,
|
session.expire_value,
|
||||||
session.expire_style,
|
session.expire_style,
|
||||||
|
ip=ip,
|
||||||
)
|
)
|
||||||
|
|
||||||
await session.delete()
|
await session.delete()
|
||||||
|
|||||||
@@ -1,60 +0,0 @@
|
|||||||
# FileCodeBox 部署脚本
|
|
||||||
|
|
||||||
## 测试环境一键部署
|
|
||||||
|
|
||||||
当你在 `test/custom-admin-ui` 分支改完测试环境代码后,执行:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
deploy-filecodebox-test
|
|
||||||
```
|
|
||||||
|
|
||||||
脚本行为:
|
|
||||||
|
|
||||||
- 使用 `/opt/src/filecodebox` 当前工作区内容构建测试镜像。
|
|
||||||
- 同步 `deploy/1panel/current-app/custom/fcb_extra_patch.py` 到测试环境。
|
|
||||||
- 重建并启动 `filecodebox-test`。
|
|
||||||
- 测试地址绑定在本机:`127.0.0.1:40158`。
|
|
||||||
- 不影响生产容器 `filecodebox`。
|
|
||||||
|
|
||||||
如果需要重新从生产复制一份数据到测试环境:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
COPY_PROD_DATA=1 deploy-filecodebox-test
|
|
||||||
```
|
|
||||||
|
|
||||||
## 生产环境从 master 部署
|
|
||||||
|
|
||||||
生产环境应该指向你的 Gitea 仓库 `master` 分支,而不是测试分支,也不是直接手改容器。
|
|
||||||
|
|
||||||
执行:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
CONFIRM=YES deploy-filecodebox-prod-master
|
|
||||||
```
|
|
||||||
|
|
||||||
脚本行为:
|
|
||||||
|
|
||||||
- 拉取 `http://127.0.0.1:3000/orion/filecodebox.git` 的 `master` 到 `/opt/src/filecodebox-production`。
|
|
||||||
- 使用该 `master` 工作树构建生产镜像 `local/filecodebox:master`。
|
|
||||||
- 保持 1Panel 生产目录和数据卷不变:`/opt/1panel/apps/filecodebox/filecodebox/data:/app/data`。
|
|
||||||
- 重建/重启生产容器 `filecodebox`。
|
|
||||||
- 验证生产首页不包含 `fcb-analytics.js` 测试脚本。
|
|
||||||
|
|
||||||
## 生产官方镜像回滚
|
|
||||||
|
|
||||||
如果以后需要临时回滚到上游官方 Docker Hub 镜像:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
CONFIRM=YES deploy-filecodebox-prod-official
|
|
||||||
```
|
|
||||||
|
|
||||||
这个只作为兜底回滚,不是正常生产发布流程。
|
|
||||||
|
|
||||||
## 推荐流程
|
|
||||||
|
|
||||||
1. 在 `test/custom-admin-ui` 分支修改代码。
|
|
||||||
2. 执行 `deploy-filecodebox-test`。
|
|
||||||
3. 在测试页面确认没问题。
|
|
||||||
4. 提交测试分支。
|
|
||||||
5. 通过 PR/合并进入 `master`。
|
|
||||||
6. 执行 `CONFIRM=YES deploy-filecodebox-prod-master` 发布生产。
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
# 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。
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
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
|
|
||||||
@@ -1,513 +0,0 @@
|
|||||||
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,.94);--fcbx-surface-2:rgba(249,250,251,.96);--fcbx-text:#111827;--fcbx-muted:#6b7280;--fcbx-card-shadow:0 10px 15px -3px rgba(0,0,0,.10),0 4px 6px -4px rgba(0,0,0,.10);--fcbx-hover-shadow:0 18px 28px -12px rgba(15,23,42,.22),0 8px 14px -10px rgba(15,23,42,.18)}
|
|
||||||
html.dark,html.dark body,body.dark{--fcbx-border:#374151;--fcbx-surface:rgba(31,41,55,.86);--fcbx-surface-2:rgba(17,24,39,.92);--fcbx-text:#f9fafb;--fcbx-muted:#9ca3af;--fcbx-card-shadow:0 16px 28px -14px rgba(0,0,0,.62),0 8px 18px -12px rgba(0,0,0,.55);--fcbx-hover-shadow:0 22px 34px -16px rgba(0,0,0,.70),0 12px 22px -16px rgba(0,0,0,.58)}
|
|
||||||
.fcbx{margin:24px 0 0;color:var(--fcbx-text)}
|
|
||||||
.fcbx button,.fcbx input:not([type="checkbox"]),.fcbx textarea,.fcbx select{border-radius:16px}
|
|
||||||
.fcbx table{border-radius:0;overflow:visible}
|
|
||||||
#fcbx-dashboard .fcbx-card,#fcbx-dashboard .fcbx-panel,#fcbx-file-history .fcbx-panel{box-shadow:var(--fcbx-card-shadow)!important;border-color:var(--fcbx-border)!important}
|
|
||||||
#fcbx-dashboard .fcbx-card:hover,#fcbx-dashboard .fcbx-panel:hover,#fcbx-file-history .fcbx-panel:hover{box-shadow:var(--fcbx-hover-shadow)!important}.fcbx,.fcbx *{box-sizing:border-box}.fcbx-panel,.fcbx-card{background:linear-gradient(180deg,var(--fcbx-surface),var(--fcbx-surface-2));border:1px solid var(--fcbx-border);border-radius:16px;box-shadow:var(--fcbx-card-shadow);transition:box-shadow .18s ease,transform .18s ease}.fcbx-panel:hover,.fcbx-card:hover{box-shadow:var(--fcbx-hover-shadow);transform:translateY(-1px)}.fcbx-title{font-size:18px;font-weight:800;margin:0 0 16px}.fcbx-muted{color:var(--fcbx-muted);font-size:13px}.fcbx-dashboard-grid{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:24px;margin:24px 0}.fcbx-card{padding:20px}.fcbx-card-label{font-size:13px;color:var(--fcbx-muted);font-weight:700}.fcbx-card-value{font-size:28px;line-height:1.25;font-weight:900;margin-top:8px}.fcbx-panel{padding:20px;margin-top:24px}.fcbx-bar{display:flex;gap:12px;align-items:center;flex-wrap:wrap;margin-bottom:16px}.fcbx-bar select,.fcbx-official-pager select,.fcbx-official-pager input,.fcbx-page-input{height:40px;border:1px solid var(--fcbx-border);padding:0 12px;background:var(--fcbx-surface);color:var(--fcbx-text);border-radius:16px}.fcbx-btn,.fcbx-official-pager button{height:40px;border:1px solid #4f46e5;background:#4f46e5;color:#fff;padding:0 14px;font-weight:800;border-radius:16px;cursor:pointer}.fcbx-btn:disabled,.fcbx-official-pager button:disabled{opacity:.45;cursor:not-allowed;background:var(--fcbx-surface-2);color:var(--fcbx-muted);border-color:var(--fcbx-border)}.fcbx-chart-wrap{position:relative;overflow:hidden;touch-action:none}.fcbx-chart{width:100%;height:260px;display:block;touch-action:none;cursor:grab}.fcbx-chart:active{cursor:grabbing}.fcbx-chart-controls{display:flex;gap:10px;align-items:center;justify-content:flex-end;flex-wrap:wrap;margin:-6px 0 12px}.fcbx-chart-window{margin-right:auto}.fcbx-tooltip{position:absolute;z-index:3;min-width:130px;max-width:min(240px,calc(100vw - 48px));padding:9px 11px;border:1px solid var(--fcbx-border);border-radius:12px;background:var(--fcbx-surface);box-shadow:var(--fcbx-card-shadow);font-size:12px;pointer-events:none;transform:translate(-50%,-112%);transition:left .08s linear,top .08s linear,opacity .12s ease}.fcbx-history-deleted{opacity:.58;filter:grayscale(.35)}.fcbx-history-deleted td{color:var(--fcbx-muted)!important}.fcbx-filter-toggle{display:inline-flex!important;align-items:center;gap:6px}.fcbx-filter-body-collapsed{display:none!important}.fcbx-filter-card{position:relative}.fcbx-table-wrap{overflow:auto;border:1px solid var(--fcbx-border);border-radius:12px}.fcbx-table-wrap table{width:100%;border-collapse:collapse}.fcbx-table-wrap th,.fcbx-table-wrap td{padding:12px 14px;border-bottom:1px solid var(--fcbx-border);text-align:left;white-space:nowrap}.fcbx-table-wrap th{font-size:12px;text-transform:uppercase;color:var(--fcbx-muted);font-weight:800}.fcbx-empty{text-align:center;color:var(--fcbx-muted);padding:24px!important}.fcbx-status-pill{display:inline-flex;align-items:center;padding:4px 10px;border-radius:999px;font-size:12px;font-weight:800}.fcbx-status-current{background:#dcfce7;color:#166534}.fcbx-status-expired{background:#fef3c7;color:#92400e}.fcbx-status-deleted{background:#fee2e2;color:#991b1b}.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}
|
|
||||||
@media(max-width:768px){.fcbx-dashboard-grid{grid-template-columns:1fr;gap:16px;margin:16px 0}.fcbx{margin-top:16px}.fcbx-panel,.fcbx-card{padding:16px}.fcbx-chart{height:220px}.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}.fcbx-table-wrap table,.fcbx-table-wrap thead,.fcbx-table-wrap tbody,.fcbx-table-wrap tr,.fcbx-table-wrap th,.fcbx-table-wrap td{display:block}.fcbx-table-wrap thead{display:none}.fcbx-table-wrap tr{padding:10px 0;border-bottom:1px solid var(--fcbx-border)}.fcbx-table-wrap td{border:0;white-space:normal;display:flex;justify-content:space-between;gap:12px}.fcbx-table-wrap td:before{content:attr(data-label);color:var(--fcbx-muted);font-weight:800}}`;
|
|
||||||
const $ = (s, r=document) => r.querySelector(s); const $$ = (s, r=document) => [...r.querySelectorAll(s)];
|
|
||||||
const esc = v => String(v ?? '').replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
|
||||||
const token = () => localStorage.getItem('admin-token') || localStorage.getItem('token') || sessionStorage.getItem('admin-token') || '';
|
|
||||||
const today = () => new Date().toISOString().slice(0,10); const daysAgo = n => { const d=new Date(); d.setDate(d.getDate()-n); return d.toISOString().slice(0,10); };
|
|
||||||
const fmtBytes = n => { n=Number(n||0); const u=['B','KB','MB','GB','TB']; let i=0; while(n>=1024&&i<u.length-1){n/=1024;i++} return `${n.toFixed(i?1:0)} ${u[i]}`; };
|
|
||||||
const fmtDate = v => v ? new Date(v).toLocaleString() : '-';
|
|
||||||
const ensureStyle = () => { let style=$('#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'); document.body.classList.toggle('fcbx-admin', location.hash.includes('/admin') && !login); };
|
|
||||||
let cache = {key:'', data:null, at:0, promise:null};
|
|
||||||
const api = async (url) => { const t=token(); const res=await fetch(url,{headers:t?{Authorization:`Bearer ${t}`}:{}}); if(!res.ok) throw new Error(`HTTP ${res.status}`); return res.json(); };
|
|
||||||
const getAnalytics = async (start=daysAgo(120), end=today(), force=false) => { const key=`${start}|${end}`; const now=Date.now(); if(!force && cache.key===key && cache.data && now-cache.at<60000) return cache.data; if(!force && cache.key===key && cache.promise) return cache.promise; cache.key=key; cache.promise=api(`/admin/analytics?start=${start}&end=${end}`).then(r=>{cache.data=r.detail||r;cache.at=Date.now();cache.promise=null;return cache.data}).catch(e=>{cache.promise=null;throw e}); return cache.promise; };
|
|
||||||
const closeMobileDrawer = () => { if(innerWidth>=1024)return; const aside=$('aside'); if(!aside)return; aside.classList.remove('translate-x-0'); aside.classList.add('-translate-x-full'); };
|
|
||||||
const bindDrawer = () => { if(window.__fcbxDrawerBound)return; window.__fcbxDrawerBound=true; document.addEventListener('click',e=>{if(e.target?.closest?.('aside a, aside nav a')&&innerWidth<1024){setTimeout(closeMobileDrawer,0);setTimeout(closeMobileDrawer,160);setTimeout(closeMobileDrawer,420)}},true); window.addEventListener('hashchange',()=>{setTimeout(closeMobileDrawer,0);setTimeout(closeMobileDrawer,180)}); };
|
|
||||||
const dateSelect = (id, value=today()) => { const d=new Date(value); const y=d.getFullYear(), m=d.getMonth()+1, day=d.getDate(); const years=[]; for(let i=y-5;i<=y+1;i++) years.push(i); return `<span class="fcbx-bar"><select id="${id}-y">${years.map(v=>`<option ${v===y?'selected':''}>${v}</option>`).join('')}</select><select id="${id}-m">${Array.from({length:12},(_,i)=>i+1).map(v=>`<option value="${v}" ${v===m?'selected':''}>${v}月</option>`).join('')}</select><select id="${id}-d">${Array.from({length:31},(_,i)=>i+1).map(v=>`<option value="${v}" ${v===day?'selected':''}>${v}日</option>`).join('')}</select></span>`; };
|
|
||||||
const readDate = id => `${$(`#${id}-y`)?.value}-${String($(`#${id}-m`)?.value||1).padStart(2,'0')}-${String($(`#${id}-d`)?.value||1).padStart(2,'0')}`;
|
|
||||||
const chartStates = new WeakMap();
|
|
||||||
const drawChart = (canvas, rows=[]) => {
|
|
||||||
if(!canvas) return; const wrap=canvas.closest('.fcbx-chart-wrap')||canvas.parentElement; const total=rows.length;
|
|
||||||
let st=chartStates.get(canvas); if(!st){const win=Math.min(total||1, innerWidth<640?35:70); st={start:Math.max(0,total-win), end:total, drag:null, pinch:null, hover:null}; chartStates.set(canvas,st)}
|
|
||||||
st.end=Math.min(total,Math.max(st.end,1)); st.start=Math.max(0,Math.min(st.start,Math.max(0,st.end-1))); if(st.end-st.start<2){st.start=Math.max(0,st.end-2)}
|
|
||||||
const visible=rows.slice(st.start,st.end); const ctx=canvas.getContext('2d'), dpr=devicePixelRatio||1, rect=canvas.getBoundingClientRect(); if(!rect.width||!rect.height)return;
|
|
||||||
canvas.width=rect.width*dpr; canvas.height=rect.height*dpr; ctx.setTransform(dpr,0,0,dpr,0,0); ctx.clearRect(0,0,rect.width,rect.height);
|
|
||||||
const padL=38,padR=18,padT=24,padB=34,w=Math.max(1,rect.width-padL-padR),h=Math.max(1,rect.height-padT-padB); const vals=visible.map(r=>Number(r.downloads||0)); const max=Math.max(1,...vals);
|
|
||||||
const border=getComputedStyle(document.body).getPropertyValue('--fcbx-border')||'#ddd', muted=getComputedStyle(document.body).getPropertyValue('--fcbx-muted')||'#777';
|
|
||||||
ctx.strokeStyle=border; ctx.lineWidth=1; ctx.fillStyle=muted; ctx.font='12px system-ui';
|
|
||||||
for(let i=0;i<4;i++){const y=padT+h*i/3;ctx.beginPath();ctx.moveTo(padL,y);ctx.lineTo(padL+w,y);ctx.stroke();ctx.fillText(String(Math.round(max*(1-i/3))),4,y+4)}
|
|
||||||
const pts=visible.map((r,i)=>({x:padL+(visible.length<2?0:i*w/(visible.length-1)), y:padT+h-(Number(r.downloads||0)/max)*h, r}));
|
|
||||||
ctx.strokeStyle='#4f46e5'; ctx.lineWidth=3; ctx.lineJoin='round'; ctx.lineCap='round'; ctx.beginPath(); pts.forEach((p,i)=>i?ctx.lineTo(p.x,p.y):ctx.moveTo(p.x,p.y)); ctx.stroke();
|
|
||||||
ctx.fillStyle='#4f46e5'; pts.forEach(p=>{ctx.beginPath();ctx.arc(p.x,p.y,3,0,Math.PI*2);ctx.fill()});
|
|
||||||
if(pts.length){ctx.fillStyle=muted; ctx.fillText(String(visible[0].date||visible[0].day||''),padL,rect.height-8); const last=String(visible.at(-1).date||visible.at(-1).day||''); ctx.fillText(last,Math.max(padL,rect.width-padR-ctx.measureText(last).width),rect.height-8)}
|
|
||||||
const label=wrap?.querySelector('.fcbx-chart-window'); if(label) label.textContent = total ? `${st.start+1}-${st.end} / ${total}` : '无数据';
|
|
||||||
let tip=wrap?.querySelector('.fcbx-tooltip'); if(wrap&&!tip){tip=document.createElement('div');tip.className='fcbx-tooltip';tip.style.opacity='0';wrap.appendChild(tip)}
|
|
||||||
if(tip&&st.hover&&pts.length){const near=pts.reduce((a,b)=>Math.abs(b.x-st.hover.x)<Math.abs(a.x-st.hover.x)?b:a,pts[0]); tip.innerHTML=`<b>${esc(near.r.date||near.r.day||'-')}</b><br>下载:${Number(near.r.downloads||0)}<br>上传:${Number(near.r.uploads||0)}`; const left=Math.min(rect.width-80,Math.max(80,near.x)); const top=Math.max(42,near.y); tip.style.left=left+'px'; tip.style.top=top+'px'; tip.style.opacity='1'; ctx.fillStyle='#fff'; ctx.strokeStyle='#4f46e5'; ctx.lineWidth=3; ctx.beginPath();ctx.arc(near.x,near.y,6,0,Math.PI*2);ctx.fill();ctx.stroke();} else if(tip){tip.style.opacity='0'}
|
|
||||||
};
|
|
||||||
const bindChart = (canvas, rows=[]) => { if(!canvas||canvas.dataset.fcbxChartBound==='1') return; canvas.dataset.fcbxChartBound='1'; const zoom=(factor,center=.5)=>{const st=chartStates.get(canvas); if(!st)return; const total=rows.length,min=Math.min(total,7),cur=st.end-st.start,next=Math.max(min,Math.min(total,Math.round(cur*factor))); const anchor=st.start+cur*center; st.start=Math.round(anchor-next*center); st.start=Math.max(0,Math.min(st.start,total-next)); st.end=st.start+next; drawChart(canvas,rows)}; const pan=(dx)=>{const st=chartStates.get(canvas); if(!st)return; const rect=canvas.getBoundingClientRect(), cur=st.end-st.start; st.panAcc=(st.panAcc||0)+(-dx/Math.max(1,rect.width)*cur*1.35); const step=st.panAcc>0?Math.floor(st.panAcc):Math.ceil(st.panAcc); if(!step)return; st.panAcc-=step; st.start=Math.max(0,Math.min(st.start+step,Math.max(0,rows.length-cur))); st.end=st.start+cur; requestAnimationFrame(()=>drawChart(canvas,rows))}; canvas.addEventListener('wheel',e=>{e.preventDefault(); const r=canvas.getBoundingClientRect(); zoom(e.deltaY>0?1.18:.84,(e.clientX-r.left)/r.width)},{passive:false}); canvas.addEventListener('pointerdown',e=>{e.preventDefault(); canvas.setPointerCapture?.(e.pointerId); const st=chartStates.get(canvas); st.drag={x:e.clientX,last:e.clientX};},{passive:false}); canvas.addEventListener('pointermove',e=>{const st=chartStates.get(canvas); const r=canvas.getBoundingClientRect(); st.hover={x:e.clientX-r.left}; if(st.drag){pan(e.clientX-st.drag.last); st.drag.last=e.clientX}else drawChart(canvas,rows)},{passive:false}); canvas.addEventListener('pointerup',e=>{const st=chartStates.get(canvas); if(st){st.drag=null;st.pinch=null;}},{passive:false}); canvas.addEventListener('pointercancel',()=>{const st=chartStates.get(canvas); if(st){st.drag=null;st.pinch=null;}}); canvas.addEventListener('touchmove',e=>{if(e.touches.length===2){e.preventDefault(); const st=chartStates.get(canvas); const a=e.touches[0],b=e.touches[1]; const dist=Math.hypot(a.clientX-b.clientX,a.clientY-b.clientY); const rect=canvas.getBoundingClientRect(); const center=((a.clientX+b.clientX)/2-rect.left)/rect.width; if(st.pinch){zoom(st.pinch/dist,center)} st.pinch=dist;}},{passive:false}); canvas.addEventListener('touchend',()=>{const st=chartStates.get(canvas); if(st)st.pinch=null;},{passive:false}); canvas.addEventListener('pointerleave',()=>{const st=chartStates.get(canvas); if(st){st.hover=null;st.drag=null;drawChart(canvas,rows)}}); canvas.addEventListener('dblclick',e=>{e.preventDefault(); const st=chartStates.get(canvas); const win=Math.min(rows.length||1, innerWidth<640?35:70); st.start=Math.max(0,rows.length-win); st.end=rows.length; drawChart(canvas,rows)},{passive:false}); canvas.closest('.fcbx-panel')?.querySelector('[data-chart-zoom-in]')?.addEventListener('click',()=>zoom(.75)); canvas.closest('.fcbx-panel')?.querySelector('[data-chart-zoom-out]')?.addEventListener('click',()=>zoom(1.33)); canvas.closest('.fcbx-panel')?.querySelector('[data-chart-reset]')?.addEventListener('click',()=>{const st=chartStates.get(canvas); const win=Math.min(rows.length||1, innerWidth<640?35:70); st.start=Math.max(0,rows.length-win); st.end=rows.length; drawChart(canvas,rows)}); };
|
|
||||||
const pagerHtml = id => `<div class="fcbx-official-pager" id="${id}-pager"><span class="fcbx-muted"></span><select id="${id}-size">${[5,10,20,50].map(n=>`<option value="${n}">${n}/页</option>`).join('')}</select><button id="${id}-prev">上一页</button><input class="fcbx-page-input" id="${id}-page" type="number" min="1" value="1"><button id="${id}-go">跳转</button><button id="${id}-next">下一页</button></div>`;
|
|
||||||
const attachPager = (id, rows, render, empty) => { let page=1; const paint=()=>{const size=Number($(`#${id}-size`)?.value||10), pages=Math.max(1,Math.ceil(rows.length/size)); page=Math.min(Math.max(1,page),pages); const slice=rows.slice((page-1)*size,page*size); render(slice, rows.length?null:empty); const p=$(`#${id}-pager`); if(!p)return; $('.fcbx-muted',p).textContent=`第 ${page} / ${pages} 页,共 ${rows.length} 条`; $(`#${id}-page`).value=page; $(`#${id}-prev`).disabled=page<=1; $(`#${id}-next`).disabled=page>=pages; }; $(`#${id}-size`).onchange=()=>{page=1;paint()}; $(`#${id}-prev`).onclick=()=>{page--;paint()}; $(`#${id}-next`).onclick=()=>{page++;paint()}; $(`#${id}-go`).onclick=()=>{page=Number($(`#${id}-page`).value)||1;paint()}; paint(); };
|
|
||||||
const renderDashboard = async () => { if(!location.hash.includes('/admin/dashboard')){ $('#fcbx-dashboard')?.remove(); return; } const main=$('main')||document.body; let box=$('#fcbx-dashboard'); if(!box){box=document.createElement('section');box.id='fcbx-dashboard';box.className='fcbx';main.appendChild(box)} box.innerHTML='<div class="fcbx-panel">加载统计中...</div>'; try{const data=await getAnalytics(); const t=data.totals||{}; box.innerHTML=`<div class="fcbx-bar">${dateSelect('fcbx-start',data.range?.start||daysAgo(120))}${dateSelect('fcbx-end',data.range?.end||today())}<button class="fcbx-btn" id="fcbx-refresh">刷新</button></div><div class="fcbx-dashboard-grid"><div class="fcbx-card"><div class="fcbx-card-label">总文件数</div><div class="fcbx-card-value">${t.currentFiles??0}</div></div><div class="fcbx-card"><div class="fcbx-card-label">历史文件</div><div class="fcbx-card-value">${t.historicalFiles??0}</div></div><div class="fcbx-card"><div class="fcbx-card-label">下载次数</div><div class="fcbx-card-value">${t.totalDownloads??0}</div></div><div class="fcbx-card"><div class="fcbx-card-label">下载流量</div><div class="fcbx-card-value">${fmtBytes(t.downloadTraffic)}</div></div></div><div class="fcbx-panel"><h3 class="fcbx-title">下载趋势</h3><div class="fcbx-chart-controls"><span class="fcbx-muted fcbx-chart-window"></span><button class="fcbx-btn" data-chart-zoom-in>放大</button><button class="fcbx-btn" data-chart-zoom-out>缩小</button><button class="fcbx-btn" data-chart-reset>重置</button></div><div class="fcbx-chart-wrap"><canvas class="fcbx-chart" id="fcbx-chart"></canvas></div></div><div class="fcbx-panel"><h3 class="fcbx-title">热门下载 Top 5</h3><div class="fcbx-table-wrap"><table><thead><tr><th>文件</th><th>下载</th><th>流量</th><th>状态</th></tr></thead><tbody>${(data.topFiles||[]).map(f=>`<tr><td data-label="文件">${esc(f.name)}</td><td data-label="下载">${f.download_count||0}</td><td data-label="流量">${fmtBytes(f.download_traffic)}</td><td data-label="状态">${f.current?'现存':'历史'}</td></tr>`).join('')||'<tr><td class="fcbx-empty" colspan="4">暂无下载记录</td></tr>'}</tbody></table></div></div>`; $('#fcbx-refresh').onclick=async()=>{cache.data=null; await renderDashboard()}; drawChart($('#fcbx-chart'),data.daily||[]); bindChart($('#fcbx-chart'),data.daily||[]); }catch(e){box.innerHTML=`<div class="fcbx-panel">统计加载失败:${esc(e.message)}</div>`} };
|
|
||||||
const renderHistory = async () => { if(!location.hash.includes('/admin/files')){ $('#fcbx-file-history')?.remove(); return; } const card=findFilesTableCard(); const host=card?.parentElement || $('main') || document.body; let box=$('#fcbx-file-history'); if(!box){box=document.createElement('section');box.id='fcbx-file-history';box.className='fcbx';box.innerHTML=`<div class="fcbx-panel"><h3 class="fcbx-title">历史文件</h3><div class="fcbx-table-wrap"><table><thead><tr><th>文件</th><th>大小</th><th>取件码</th><th>上传日期</th><th>过期时间</th><th>删除时间</th><th>下载</th><th>流量</th><th>状态</th></tr></thead><tbody id="fcbx-history-body"><tr><td class="fcbx-empty" colspan="9">加载中...</td></tr></tbody></table></div>${pagerHtml('fcbx-history')}</div>`; host.appendChild(box)} try{const data=await getAnalytics(); const rows=data.historyFiles||[]; const row=f=>{const expired=!f.deleted&&f.expired_at&&new Date(f.expired_at)<new Date(); const status=f.deleted?'已删除':expired?'已过期':f.current?'现存':'历史'; const pill=status==='现存'?'fcbx-status-current':status==='已过期'?'fcbx-status-expired':'fcbx-status-deleted'; return `<tr class="${status==='现存'?'': 'fcbx-history-deleted'}"><td data-label="文件">${esc(f.name)}</td><td data-label="大小">${fmtBytes(f.size)}</td><td data-label="取件码">${esc(f.code)}</td><td data-label="上传日期">${fmtDate(f.uploaded_at||f.created_at)}</td><td data-label="过期时间">${fmtDate(f.expired_at)}</td><td data-label="删除时间">${fmtDate(f.deleted_at)}</td><td data-label="下载">${f.download_count??0}</td><td data-label="流量">${fmtBytes(f.download_traffic)}</td><td data-label="状态"><span class="fcbx-status-pill ${pill}">${status}</span></td></tr>`}; attachPager('fcbx-history',rows,(slice,empty)=>{$('#fcbx-history-body').innerHTML=empty||slice.map(row).join('')},'<tr><td class="fcbx-empty" colspan="9">暂无历史文件</td></tr>'); }catch(e){$('#fcbx-history-body').innerHTML=`<tr><td class="fcbx-empty" colspan="9">历史文件加载失败:${esc(e.message)}</td></tr>`} };
|
|
||||||
const findFilesTableCard = () => { const table=$$('main table').find(t=>/Retrieve Code|取件码/.test($$('th',t).map(th=>th.textContent).join('|'))); return table?.closest('section,div[class*="rounded"]')||null; };
|
|
||||||
const mountFilesPager = () => { if(!location.hash.includes('/admin/files'))return; const native=$('.fcbx-native-pagination'); const card=findFilesTableCard(); if(!native&&!card)return; native?.classList.add('fcbx-native-pagination-hidden'); let pager=$('#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), page=Number(params.get('page')||1), 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,q='']=location.hash.split('?');const p=new URLSearchParams(q);Object.entries(patch).forEach(([k,v])=>p.set(k,String(v)));location.hash=`${path||'#/admin/files'}?${p.toString()}`}; $('#fcbx-allfiles-size').onchange=e=>setHash({page:1,size:e.target.value}); $('#fcbx-allfiles-prev').onclick=()=>setHash({page:Math.max(1,page-1),size}); $('#fcbx-allfiles-next').onclick=()=>setHash({page:Math.min(pages,page+1),size}); $('#fcbx-allfiles-go').onclick=()=>setHash({page:Math.min(pages,Math.max(1,Number($('#fcbx-allfiles-page').value)||1)),size}); };
|
|
||||||
|
|
||||||
const preCollapseFilesFilter = () => { if(!location.hash.includes('/admin/files'))return; const cards=$$('main section, main div[class*="rounded"]'); const card=cards.find(el=>!el.querySelector('table') && (el.querySelector('input')||el.querySelector('select')) && /搜索|筛选|Search|Filter|文件名|取件码/.test(el.innerText||'')); if(!card||card.dataset.fcbxFilterReady==='1')return; card.dataset.fcbxFilterReady='1'; card.classList.add('fcbx-filter-card'); const kids=[...card.children]; const body=kids.length>1?kids.slice(1):kids; body.forEach(el=>el.classList.add('fcbx-filter-body-collapsed')); const btn=document.createElement('button'); btn.type='button'; btn.className='fcbx-btn fcbx-filter-toggle'; btn.textContent='展开筛选'; btn.onclick=()=>{const collapsed=body.some(el=>el.classList.contains('fcbx-filter-body-collapsed')); body.forEach(el=>el.classList.toggle('fcbx-filter-body-collapsed',!collapsed)); btn.textContent=collapsed?'收起筛选':'展开筛选'}; (card.firstElementChild||card).appendChild(btn); };
|
|
||||||
let scheduled=false,lastRun=0; const run=()=>{ if(scheduled)return; scheduled=true; requestAnimationFrame(()=>{scheduled=false; const now=Date.now(); ensureStyle(); updateMode(); bindDrawer(); preCollapseFilesFilter(); mountFilesPager(); if(now-lastRun<500)return; lastRun=now; renderDashboard(); renderHistory(); }); };
|
|
||||||
window.addEventListener('hashchange',run); window.addEventListener('resize',run); document.addEventListener('DOMContentLoaded',run); new MutationObserver(run).observe(document.body,{childList:true,subtree:true}); 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-test-full09"></script>\n</body>')
|
|
||||||
else:
|
|
||||||
text = re.sub(r'<script src="/assets/fcb-analytics\.js(?:\?v=[^"]+)?"></script>', '<script src="/assets/fcb-analytics.js?v=20260605-test-full09"></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')
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
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
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
CPUS=0
|
|
||||||
MEMORY_LIMIT=0
|
|
||||||
HOST_IP=''
|
|
||||||
format=''
|
|
||||||
collation=''
|
|
||||||
PANEL_APP_PORT_HTTP=40157
|
|
||||||
CONTAINER_NAME='filecodebox'
|
|
||||||
@@ -1,80 +0,0 @@
|
|||||||
#!/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"
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
<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`
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
CONTAINER_NAME="filecodebox"
|
|
||||||
PANEL_APP_PORT_HTTP="40157"
|
|
||||||
DATA_PATH="./data"
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
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
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
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
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
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.
|
Before Width: | Height: | Size: 6.2 KiB |
@@ -1,112 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
REPO_URL="${REPO_URL:-http://127.0.0.1:3000/orion/filecodebox.git}"
|
|
||||||
TOKEN_FILE="${TOKEN_FILE:-/root/.cache/gitea-filecodebox-token}"
|
|
||||||
PROD_REPO_DIR="${PROD_REPO_DIR:-/opt/src/filecodebox-production}"
|
|
||||||
PROD_DIR="${PROD_DIR:-/opt/1panel/apps/filecodebox/filecodebox}"
|
|
||||||
BRANCH="${BRANCH:-master}"
|
|
||||||
IMAGE="${IMAGE:-local/filecodebox:master}"
|
|
||||||
PORT="${PORT:-40157}"
|
|
||||||
CONFIRM="${CONFIRM:-}"
|
|
||||||
|
|
||||||
log() { printf '\033[1;32m[deploy-prod-master]\033[0m %s\n' "$*"; }
|
|
||||||
err() { printf '\033[1;31m[deploy-prod-master]\033[0m %s\n' "$*" >&2; }
|
|
||||||
|
|
||||||
command -v git >/dev/null 2>&1 || { err "missing command: git"; exit 1; }
|
|
||||||
command -v docker >/dev/null 2>&1 || { err "missing command: docker"; exit 1; }
|
|
||||||
command -v curl >/dev/null 2>&1 || { err "missing command: curl"; exit 1; }
|
|
||||||
|
|
||||||
if [[ "$CONFIRM" != "YES" ]]; then
|
|
||||||
cat >&2 <<EOF
|
|
||||||
这是生产环境脚本:从你的 Gitea 仓库 $BRANCH 分支构建并部署生产镜像。
|
|
||||||
|
|
||||||
生产数据目录保持不变:$PROD_DIR/data -> /app/data
|
|
||||||
如确认执行,请使用:
|
|
||||||
CONFIRM=YES deploy-filecodebox-prod-master
|
|
||||||
EOF
|
|
||||||
exit 2
|
|
||||||
fi
|
|
||||||
|
|
||||||
log "prepare production source: $REPO_URL#$BRANCH"
|
|
||||||
AUTH_REPO_URL="$REPO_URL"
|
|
||||||
if [[ -f "$TOKEN_FILE" && "$REPO_URL" == http://127.0.0.1:3000/* ]]; then
|
|
||||||
TOKEN="$(tr -d '\n' < "$TOKEN_FILE")"
|
|
||||||
AUTH_REPO_URL="http://orion:${TOKEN}@127.0.0.1:3000/orion/filecodebox.git"
|
|
||||||
fi
|
|
||||||
if [[ ! -d "$PROD_REPO_DIR/.git" ]]; then
|
|
||||||
rm -rf "$PROD_REPO_DIR"
|
|
||||||
git clone "$AUTH_REPO_URL" "$PROD_REPO_DIR"
|
|
||||||
fi
|
|
||||||
cd "$PROD_REPO_DIR"
|
|
||||||
git remote set-url origin "$AUTH_REPO_URL"
|
|
||||||
git fetch origin "$BRANCH"
|
|
||||||
git checkout -B "$BRANCH" "origin/$BRANCH"
|
|
||||||
git reset --hard "origin/$BRANCH"
|
|
||||||
commit="$(git rev-parse HEAD)"
|
|
||||||
log "production source commit: $commit"
|
|
||||||
|
|
||||||
cd "$PROD_DIR"
|
|
||||||
mkdir -p backups
|
|
||||||
TS="$(date +%Y%m%d%H%M%S)"
|
|
||||||
log "backup compose and database"
|
|
||||||
cp -a docker-compose.yml "backups/docker-compose.yml.before-prod-master.$TS"
|
|
||||||
if [[ -f data/filecodebox.db ]]; then
|
|
||||||
cp -a data/filecodebox.db "backups/filecodebox.db.before-prod-master.$TS"
|
|
||||||
fi
|
|
||||||
|
|
||||||
log "write production compose: build from $PROD_REPO_DIR"
|
|
||||||
python3 - <<PY
|
|
||||||
from pathlib import Path
|
|
||||||
p=Path('docker-compose.yml')
|
|
||||||
s=p.read_text()
|
|
||||||
lines=[]
|
|
||||||
skip_next_context=False
|
|
||||||
in_build=False
|
|
||||||
for line in s.splitlines():
|
|
||||||
stripped=line.strip()
|
|
||||||
if stripped.startswith('image:'):
|
|
||||||
indent=line[:len(line)-len(line.lstrip())]
|
|
||||||
lines.append(indent+'image: $IMAGE')
|
|
||||||
continue
|
|
||||||
if stripped == 'build:' or stripped.startswith('build:'):
|
|
||||||
continue
|
|
||||||
if stripped.startswith('context:'):
|
|
||||||
continue
|
|
||||||
lines.append(line)
|
|
||||||
text='\n'.join(lines)+'\n'
|
|
||||||
if 'image: $IMAGE' not in text:
|
|
||||||
raise SystemExit('failed to set production image')
|
|
||||||
needle=' image: $IMAGE\n'
|
|
||||||
replacement=' image: $IMAGE\n build:\n context: $PROD_REPO_DIR\n'
|
|
||||||
text=text.replace(needle, replacement)
|
|
||||||
p.write_text(text)
|
|
||||||
PY
|
|
||||||
|
|
||||||
docker compose config >/dev/null
|
|
||||||
log "build $IMAGE"
|
|
||||||
docker compose build filecodebox
|
|
||||||
|
|
||||||
log "recreate production container"
|
|
||||||
docker compose up -d --no-build filecodebox
|
|
||||||
|
|
||||||
log "wait for http://127.0.0.1:$PORT/"
|
|
||||||
for i in $(seq 1 45); do
|
|
||||||
if curl -fsS "http://127.0.0.1:$PORT/" >/tmp/filecodebox-prod-index.html; then
|
|
||||||
break
|
|
||||||
fi
|
|
||||||
sleep 1
|
|
||||||
if [[ "$i" == "45" ]]; then
|
|
||||||
err "production environment did not become ready"
|
|
||||||
docker logs --tail 100 filecodebox || true
|
|
||||||
exit 3
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
|
|
||||||
if grep -q 'fcb-analytics.js' /tmp/filecodebox-prod-index.html; then
|
|
||||||
err "生产 master 环境包含测试增强脚本注入,已停止"
|
|
||||||
exit 4
|
|
||||||
fi
|
|
||||||
|
|
||||||
docker ps --filter name=filecodebox --format 'table {{.Names}}\t{{.Image}}\t{{.Status}}\t{{.Ports}}'
|
|
||||||
log "OK: production is built from $BRANCH@$commit and ready at http://127.0.0.1:$PORT/"
|
|
||||||
@@ -1,79 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
PROD_DIR="${PROD_DIR:-/opt/1panel/apps/filecodebox/filecodebox}"
|
|
||||||
IMAGE="${IMAGE:-lanol/filecodebox:beta}"
|
|
||||||
PORT="${PORT:-40157}"
|
|
||||||
CONFIRM="${CONFIRM:-}"
|
|
||||||
|
|
||||||
log() { printf '\033[1;32m[deploy-prod-official]\033[0m %s\n' "$*"; }
|
|
||||||
err() { printf '\033[1;31m[deploy-prod-official]\033[0m %s\n' "$*" >&2; }
|
|
||||||
|
|
||||||
command -v docker >/dev/null 2>&1 || { err "missing command: docker"; exit 1; }
|
|
||||||
command -v curl >/dev/null 2>&1 || { err "missing command: curl"; exit 1; }
|
|
||||||
|
|
||||||
if [[ "$CONFIRM" != "YES" ]]; then
|
|
||||||
cat >&2 <<'EOF'
|
|
||||||
这是生产环境脚本,只做“官方镜像重建/更新”,不会部署测试分支改动。
|
|
||||||
|
|
||||||
如确认执行,请使用:
|
|
||||||
CONFIRM=YES deploy-filecodebox-prod-official
|
|
||||||
EOF
|
|
||||||
exit 2
|
|
||||||
fi
|
|
||||||
|
|
||||||
cd "$PROD_DIR"
|
|
||||||
mkdir -p backups
|
|
||||||
TS="$(date +%Y%m%d%H%M%S)"
|
|
||||||
log "backup compose and database"
|
|
||||||
cp -a docker-compose.yml "backups/docker-compose.yml.before-prod-official.$TS"
|
|
||||||
if [[ -f data/filecodebox.db ]]; then
|
|
||||||
cp -a data/filecodebox.db "backups/filecodebox.db.before-prod-official.$TS"
|
|
||||||
fi
|
|
||||||
|
|
||||||
log "ensure official image in compose: $IMAGE"
|
|
||||||
python3 - <<PY
|
|
||||||
from pathlib import Path
|
|
||||||
p=Path('docker-compose.yml')
|
|
||||||
s=p.read_text()
|
|
||||||
lines=[]
|
|
||||||
changed=False
|
|
||||||
for line in s.splitlines():
|
|
||||||
if line.strip().startswith('image:'):
|
|
||||||
indent=line[:len(line)-len(line.lstrip())]
|
|
||||||
lines.append(indent+'image: $IMAGE')
|
|
||||||
changed=True
|
|
||||||
else:
|
|
||||||
lines.append(line)
|
|
||||||
if not changed:
|
|
||||||
raise SystemExit('compose image line not found')
|
|
||||||
p.write_text('\n'.join(lines)+'\n')
|
|
||||||
PY
|
|
||||||
|
|
||||||
docker compose config >/dev/null
|
|
||||||
log "pull official image if available"
|
|
||||||
docker pull "$IMAGE" || true
|
|
||||||
|
|
||||||
log "recreate production container"
|
|
||||||
docker compose up -d --no-build
|
|
||||||
|
|
||||||
log "wait for http://127.0.0.1:$PORT/"
|
|
||||||
for i in $(seq 1 30); do
|
|
||||||
if curl -fsS "http://127.0.0.1:$PORT/" >/tmp/filecodebox-prod-index.html; then
|
|
||||||
break
|
|
||||||
fi
|
|
||||||
sleep 1
|
|
||||||
if [[ "$i" == "30" ]]; then
|
|
||||||
err "production environment did not become ready"
|
|
||||||
docker logs --tail 100 filecodebox || true
|
|
||||||
exit 3
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
|
|
||||||
if grep -q 'fcb-analytics.js' /tmp/filecodebox-prod-index.html; then
|
|
||||||
err "生产环境仍包含测试增强脚本注入,已停止"
|
|
||||||
exit 4
|
|
||||||
fi
|
|
||||||
|
|
||||||
docker ps --filter name=filecodebox --format 'table {{.Names}}\t{{.Image}}\t{{.Status}}\t{{.Ports}}'
|
|
||||||
log "OK: production is official image and ready at http://127.0.0.1:$PORT/"
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
## 默认账户信息
|
|
||||||
|
|
||||||
后端地址: `/#/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] 简单明了:适合新手练手项目
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
additionalProperties:
|
|
||||||
formFields:
|
|
||||||
- default: 40157
|
|
||||||
edit: true
|
|
||||||
envKey: PANEL_APP_PORT_HTTP
|
|
||||||
labelEn: Port
|
|
||||||
labelZh: 端口
|
|
||||||
required: true
|
|
||||||
rule: paramPort
|
|
||||||
type: number
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
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
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
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.
|
Before Width: | Height: | Size: 7.1 KiB |
@@ -1,92 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
REPO_URL="${REPO_URL:-https://git.orionc.me/orion/filecodebox.git}"
|
|
||||||
PROD_REPO_DIR="${PROD_REPO_DIR:-/opt/src/filecodebox-production}"
|
|
||||||
PROD_DIR="${PROD_DIR:-/opt/1panel/apps/filecodebox/filecodebox}"
|
|
||||||
BRANCH="${BRANCH:-master}"
|
|
||||||
IMAGE="${IMAGE:-local/filecodebox:master}"
|
|
||||||
PORT="${PORT:-40157}"
|
|
||||||
|
|
||||||
log() { printf '[FileCodeBox更新] %s\n' "$*"; }
|
|
||||||
fail() { printf '[FileCodeBox更新失败] %s\n' "$*" >&2; exit 1; }
|
|
||||||
|
|
||||||
command -v git >/dev/null 2>&1 || fail '缺少 git'
|
|
||||||
command -v docker >/dev/null 2>&1 || fail '缺少 docker'
|
|
||||||
command -v curl >/dev/null 2>&1 || fail '缺少 curl'
|
|
||||||
[[ -d "$PROD_DIR" ]] || fail "生产目录不存在:$PROD_DIR"
|
|
||||||
|
|
||||||
log "拉取公开仓库 $REPO_URL 的 $BRANCH 分支"
|
|
||||||
if [[ ! -d "$PROD_REPO_DIR/.git" ]]; then
|
|
||||||
rm -rf "$PROD_REPO_DIR"
|
|
||||||
git clone "$REPO_URL" "$PROD_REPO_DIR"
|
|
||||||
fi
|
|
||||||
cd "$PROD_REPO_DIR"
|
|
||||||
git remote set-url origin "$REPO_URL"
|
|
||||||
git fetch origin "$BRANCH"
|
|
||||||
git checkout -B "$BRANCH" "origin/$BRANCH"
|
|
||||||
git reset --hard "origin/$BRANCH"
|
|
||||||
COMMIT="$(git rev-parse HEAD)"
|
|
||||||
log "当前提交:$COMMIT"
|
|
||||||
|
|
||||||
cd "$PROD_DIR"
|
|
||||||
mkdir -p backups
|
|
||||||
TS="$(date +%Y%m%d%H%M%S)"
|
|
||||||
cp -a docker-compose.yml "backups/docker-compose.yml.before-update.$TS"
|
|
||||||
[[ -f data/filecodebox.db ]] && cp -a data/filecodebox.db "backups/filecodebox.db.before-update.$TS"
|
|
||||||
|
|
||||||
log "同步 1Panel Compose 为本地 master 构建"
|
|
||||||
python3 - <<PY
|
|
||||||
from pathlib import Path
|
|
||||||
p = Path('docker-compose.yml')
|
|
||||||
s = p.read_text()
|
|
||||||
lines = []
|
|
||||||
for line in s.splitlines():
|
|
||||||
stripped = line.strip()
|
|
||||||
if stripped.startswith('image:'):
|
|
||||||
indent = line[:len(line) - len(line.lstrip())]
|
|
||||||
lines.append(indent + 'image: $IMAGE')
|
|
||||||
lines.append(indent + 'build:')
|
|
||||||
lines.append(indent + ' context: $PROD_REPO_DIR')
|
|
||||||
elif stripped.startswith('build:') or stripped.startswith('context:'):
|
|
||||||
continue
|
|
||||||
else:
|
|
||||||
lines.append(line)
|
|
||||||
p.write_text('\n'.join(lines) + '\n')
|
|
||||||
PY
|
|
||||||
|
|
||||||
log "构建并重建生产容器"
|
|
||||||
docker compose config >/dev/null
|
|
||||||
docker compose up -d --build filecodebox
|
|
||||||
|
|
||||||
log "等待服务恢复"
|
|
||||||
for i in $(seq 1 60); do
|
|
||||||
if curl -fsS "http://127.0.0.1:$PORT/" >/tmp/filecodebox-update-index.html; then
|
|
||||||
break
|
|
||||||
fi
|
|
||||||
sleep 1
|
|
||||||
if [[ "$i" == "60" ]]; then
|
|
||||||
docker logs --tail 120 filecodebox || true
|
|
||||||
fail '服务未恢复'
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
|
|
||||||
if grep -q 'fcb-analytics.js' /tmp/filecodebox-update-index.html; then
|
|
||||||
fail '生产页面检测到测试增强脚本,已停止'
|
|
||||||
fi
|
|
||||||
|
|
||||||
log "同步 1Panel 安装实例记录"
|
|
||||||
python3 - <<'PY'
|
|
||||||
import sqlite3
|
|
||||||
from pathlib import Path
|
|
||||||
DB = '/opt/1panel/db/agent.db'
|
|
||||||
compose = Path('/opt/1panel/apps/filecodebox/filecodebox/docker-compose.yml').read_text()
|
|
||||||
conn = sqlite3.connect(DB)
|
|
||||||
conn.execute("update app_installs set docker_compose=?, status='Running', container_name='filecodebox', service_name='filecodebox', http_port=40157, updated_at=datetime('now','localtime') where name='filecodebox'", (compose,))
|
|
||||||
conn.commit()
|
|
||||||
conn.close()
|
|
||||||
PY
|
|
||||||
|
|
||||||
(systemctl restart 1panel-agent >/dev/null 2>&1 || true)
|
|
||||||
log "完成:生产已更新为 $BRANCH@$COMMIT,数据目录保持不变"
|
|
||||||
docker ps --filter name=filecodebox --format 'table {{.Names}}\t{{.Image}}\t{{.Status}}\t{{.Ports}}'
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
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
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
# FileCodeBox 测试环境
|
|
||||||
|
|
||||||
当前测试环境用于验证 `test/custom-admin-ui` 分支里的自定义镜像补丁,不影响生产环境。
|
|
||||||
|
|
||||||
- 容器名:`filecodebox-test`
|
|
||||||
- 镜像:`local/filecodebox:test-custom-admin-ui`
|
|
||||||
- 端口:`127.0.0.1:40158 -> 12345`
|
|
||||||
- 数据:`/opt/1panel/apps/filecodebox/filecodebox-test/data`,从生产 `/app/data` 复制而来
|
|
||||||
- 生产容器:`filecodebox`,继续使用官方镜像 `lanol/filecodebox:beta`
|
|
||||||
|
|
||||||
验证:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -fsS http://127.0.0.1:40157/ | grep -qv fcb-analytics.js
|
|
||||||
curl -fsS http://127.0.0.1:40158/ | grep 'fcb-analytics.js?v='
|
|
||||||
```
|
|
||||||
@@ -1,78 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
REPO_DIR="${REPO_DIR:-/opt/src/filecodebox}"
|
|
||||||
TEST_DIR="${TEST_DIR:-/opt/1panel/apps/filecodebox/filecodebox-test}"
|
|
||||||
PROD_DIR="${PROD_DIR:-/opt/1panel/apps/filecodebox/filecodebox}"
|
|
||||||
BRANCH="${BRANCH:-test/custom-admin-ui}"
|
|
||||||
IMAGE="${IMAGE:-local/filecodebox:test-custom-admin-ui}"
|
|
||||||
PORT="${PORT:-40158}"
|
|
||||||
COPY_PROD_DATA="${COPY_PROD_DATA:-0}"
|
|
||||||
|
|
||||||
log() { printf '\033[1;34m[deploy-test]\033[0m %s\n' "$*"; }
|
|
||||||
err() { printf '\033[1;31m[deploy-test]\033[0m %s\n' "$*" >&2; }
|
|
||||||
|
|
||||||
require_cmd() {
|
|
||||||
command -v "$1" >/dev/null 2>&1 || { err "missing command: $1"; exit 1; }
|
|
||||||
}
|
|
||||||
|
|
||||||
require_cmd git
|
|
||||||
require_cmd docker
|
|
||||||
require_cmd curl
|
|
||||||
|
|
||||||
log "repo: $REPO_DIR"
|
|
||||||
cd "$REPO_DIR"
|
|
||||||
|
|
||||||
current_branch="$(git rev-parse --abbrev-ref HEAD)"
|
|
||||||
if [[ "$current_branch" != "$BRANCH" ]]; then
|
|
||||||
err "当前分支是 $current_branch,不是 $BRANCH。请先切到测试分支:git checkout $BRANCH"
|
|
||||||
exit 2
|
|
||||||
fi
|
|
||||||
|
|
||||||
if ! git diff --quiet || ! git diff --cached --quiet; then
|
|
||||||
log "检测到未提交改动:会用当前工作区内容构建测试镜像"
|
|
||||||
fi
|
|
||||||
|
|
||||||
log "sync test deployment directory"
|
|
||||||
mkdir -p "$TEST_DIR/custom" "$TEST_DIR/data"
|
|
||||||
cp -a deploy/1panel/current-app/custom/fcb_extra_patch.py "$TEST_DIR/custom/fcb_extra_patch.py"
|
|
||||||
cp -a deploy/1panel/test-env/Dockerfile "$TEST_DIR/custom/Dockerfile"
|
|
||||||
cp -a deploy/1panel/test-env/docker-compose.yml "$TEST_DIR/docker-compose.yml"
|
|
||||||
|
|
||||||
if [[ "$COPY_PROD_DATA" == "1" ]]; then
|
|
||||||
log "refresh test data from production copy"
|
|
||||||
mkdir -p "$TEST_DIR/backups"
|
|
||||||
if [[ -f "$TEST_DIR/data/filecodebox.db" ]]; then
|
|
||||||
cp -a "$TEST_DIR/data/filecodebox.db" "$TEST_DIR/backups/filecodebox.db.before-refresh.$(date +%Y%m%d%H%M%S)"
|
|
||||||
fi
|
|
||||||
rsync -a --delete "$PROD_DIR/data/" "$TEST_DIR/data/"
|
|
||||||
fi
|
|
||||||
|
|
||||||
log "build $IMAGE"
|
|
||||||
cd "$TEST_DIR"
|
|
||||||
docker compose build filecodebox-test
|
|
||||||
|
|
||||||
log "start/recreate test container"
|
|
||||||
docker compose up -d --no-build filecodebox-test
|
|
||||||
|
|
||||||
log "wait for http://127.0.0.1:$PORT/"
|
|
||||||
for i in $(seq 1 30); do
|
|
||||||
if curl -fsS "http://127.0.0.1:$PORT/" >/tmp/filecodebox-test-index.html; then
|
|
||||||
break
|
|
||||||
fi
|
|
||||||
sleep 1
|
|
||||||
if [[ "$i" == "30" ]]; then
|
|
||||||
err "test environment did not become ready"
|
|
||||||
docker logs --tail 80 filecodebox-test || true
|
|
||||||
exit 3
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
|
|
||||||
log "verify custom asset"
|
|
||||||
if ! grep -q 'fcb-analytics.js' /tmp/filecodebox-test-index.html; then
|
|
||||||
err "测试环境没有加载 fcb-analytics.js,部署异常"
|
|
||||||
exit 4
|
|
||||||
fi
|
|
||||||
|
|
||||||
docker ps --filter name=filecodebox-test --format 'table {{.Names}}\t{{.Image}}\t{{.Status}}\t{{.Ports}}'
|
|
||||||
log "OK: test environment is ready at http://127.0.0.1:$PORT/"
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
networks:
|
|
||||||
1panel-network:
|
|
||||||
external: true
|
|
||||||
services:
|
|
||||||
filecodebox-test:
|
|
||||||
container_name: filecodebox-test
|
|
||||||
image: local/filecodebox:test-custom-admin-ui
|
|
||||||
build:
|
|
||||||
context: ./custom
|
|
||||||
labels:
|
|
||||||
createdBy: Apps
|
|
||||||
env: test
|
|
||||||
networks:
|
|
||||||
- 1panel-network
|
|
||||||
ports:
|
|
||||||
- 127.0.0.1:40158:12345
|
|
||||||
restart: unless-stopped
|
|
||||||
volumes:
|
|
||||||
- ./data:/app/data
|
|
||||||
Reference in New Issue
Block a user