Initial commit

This commit is contained in:
shiyu
2026-02-09 13:19:28 +08:00
commit 17d13999b0
300 changed files with 48565 additions and 0 deletions

428
setup/foxel.sh Normal file
View File

@@ -0,0 +1,428 @@
#!/bin/bash
#
# Foxel 一键安装与管理脚本Docker Compose
# 一键运行:
# bash <(curl -sL "https://raw.githubusercontent.com/DrizzleTime/Foxel/main/setup/foxel.sh?_=$(date +%s)")
#
# --- 输出可关闭颜色NO_COLOR=1 ---
if [[ -t 1 && -z "${NO_COLOR:-}" ]]; then
C_RESET='\033[0m'
C_RED='\033[31m'
C_GREEN='\033[32m'
C_YELLOW='\033[33m'
C_BLUE='\033[34m'
else
C_RESET=''
C_RED=''
C_GREEN=''
C_YELLOW=''
C_BLUE=''
fi
info() { printf "%b[信息]%b %s\n" "$C_BLUE" "$C_RESET" "$*"; }
success() { printf "%b[成功]%b %s\n" "$C_GREEN" "$C_RESET" "$*"; }
warn() { printf "%b[警告]%b %s\n" "$C_YELLOW" "$C_RESET" "$*"; }
error() { printf "%b[错误]%b %s\n" "$C_RED" "$C_RESET" "$*"; }
# --- 基础函数 ---
command_exists() {
command -v "$1" &> /dev/null
}
confirm_action() {
local prompt_message="$1"
local default="${2:-N}"
local hint='[y/N]'
local confirmation
if [[ "$default" =~ ^[Yy]$ ]]; then
default="Y"
hint='[Y/n]'
else
default="N"
hint='[y/N]'
fi
while true; do
read -r -p "${prompt_message} ${hint}: " confirmation
if [[ -z "$confirmation" ]]; then
[[ "$default" == "Y" ]] && return 0 || return 1
fi
case "$confirmation" in
[Yy]|[Yy][Ee][Ss]) return 0 ;;
[Nn]|[Nn][Oo]) return 1 ;;
*) warn "请输入 y 或 n。" ;;
esac
done
}
# --- IP地址检测函数只输出IP ---
get_public_ipv4() {
curl -4 -s --max-time 2 https://api.ipify.org || \
curl -4 -s --max-time 2 https://ifconfig.me/ip || \
curl -4 -s --max-time 2 https://icanhazip.com
}
get_public_ipv6() {
curl -6 -s --max-time 2 https://api64.ipify.org || \
curl -6 -s --max-time 2 https://ifconfig.co
}
get_private_ip() {
# 尝试多种方法获取最主要的内网IPv4地址
ip -4 route get 1.1.1.1 2>/dev/null | awk -F"src " 'NR==1{print $2}' | awk '{print $1}' || \
hostname -I 2>/dev/null | awk '{for(i=1;i<=NF;i++) if($i ~ /^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$/) {print $i; exit}}' || \
ip -4 addr 2>/dev/null | grep -oP '(?<=inet\s)\d+(\.\d+){3}' | grep -v '127.0.0.1' | head -n 1
}
# --- 依赖与环境检查 ---
check_and_install_dependencies() {
info "检查依赖..."
declare -A deps=( [curl]="curl" [openssl]="openssl" [ss]="iproute2" )
local missing_deps=()
for cmd in "${!deps[@]}"; do
if ! command_exists "$cmd"; then
missing_deps+=("${deps[$cmd]}")
fi
done
if [ ${#missing_deps[@]} -gt 0 ]; then
warn "缺少依赖: ${missing_deps[*]}"
if confirm_action "是否尝试自动安装?" "Y"; then
local pm_cmd=""
if command_exists apt-get; then pm_cmd="sudo apt-get update && sudo apt-get install -y";
elif command_exists yum; then pm_cmd="sudo yum install -y";
elif command_exists dnf; then pm_cmd="sudo dnf install -y";
else error "未检测到 apt, yum 或 dnf。请手动安装: ${missing_deps[*]}"; exit 1; fi
info "即将使用命令安装: '$pm_cmd ${missing_deps[*]}'"
$pm_cmd "${missing_deps[@]}"
for cmd in "${!deps[@]}"; do
if ! command_exists "$cmd"; then error "依赖 '${deps[$cmd]}' 自动安装失败。"; exit 1; fi
done
success "依赖安装完成。"
else
error "用户取消了安装。请先手动安装依赖: ${missing_deps[*]}"; exit 1
fi
else
success "依赖已满足。"
fi
}
initialize_environment() {
check_and_install_dependencies
if ! command_exists docker; then
error "未找到 Docker。请参照官方文档安装: https://docs.docker.com/engine/install/"; exit 1;
fi
if ! docker info &> /dev/null; then error "Docker daemon 未在运行。请先启动 Docker。"; exit 1; fi
success "Docker 环境正常。"
if command_exists docker-compose; then COMPOSE_CMD="docker-compose";
elif docker compose version &> /dev/null; then COMPOSE_CMD="docker compose";
else error "未找到 Docker Compose。请安装 Docker Compose v1 或 v2。"; exit 1; fi
info "Docker Compose: $COMPOSE_CMD"
}
set_image_source_official() {
sed -i -E 's|^([[:space:]]*)#?image:[[:space:]]*ghcr\.io/drizzletime/foxel:latest|\1image: ghcr.io/drizzletime/foxel:latest|' compose.yaml
sed -i -E 's|^([[:space:]]*)#?image:[[:space:]]*ghcr\.nju\.edu\.cn/drizzletime/foxel:latest|\1#image: ghcr.nju.edu.cn/drizzletime/foxel:latest|' compose.yaml
}
set_image_source_mirror() {
sed -i -E 's|^([[:space:]]*)#?image:[[:space:]]*ghcr\.io/drizzletime/foxel:latest|\1#image: ghcr.io/drizzletime/foxel:latest|' compose.yaml
sed -i -E 's|^([[:space:]]*)#?image:[[:space:]]*ghcr\.nju\.edu\.cn/drizzletime/foxel:latest|\1image: ghcr.nju.edu.cn/drizzletime/foxel:latest|' compose.yaml
}
choose_image_source() {
echo
info "请选择镜像源:"
echo "1) ghcr.io (默认)"
echo "2) ghcr.nju.edu.cn (国内)"
local image_choice
read -r -p "请选择 [1-2] (默认 1): " image_choice
image_choice="${image_choice:-1}"
case "$image_choice" in
1)
set_image_source_official
info "已选择: ghcr.io"
;;
2)
set_image_source_mirror
info "已选择: ghcr.nju.edu.cn"
;;
*)
warn "无效选择,使用默认 ghcr.io"
set_image_source_official
;;
esac
}
# --- 新安装流程 ---
install_new_foxel() {
info "开始全新安装..."
local foxel_dir
local default_dir="/opt/foxel"
while true; do
read -r -p "请输入 Foxel 安装目录 (默认: ${default_dir}): " foxel_dir
foxel_dir="${foxel_dir:-$default_dir}"
if [[ -f "$foxel_dir/compose.yaml" ]]; then
warn "检测到已存在: $foxel_dir/compose.yaml"
if confirm_action "是否覆盖它?" "N"; then
mv "$foxel_dir/compose.yaml" "$foxel_dir/compose.yaml.bak.$(date +%s)"
info "已备份为: $foxel_dir/compose.yaml.bak.*"
else
continue
fi
fi
if [[ -d "$foxel_dir" ]]; then
break
fi
if confirm_action "目录不存在,是否创建?" "Y"; then
if mkdir -p "$foxel_dir"; then
break
fi
error "创建目录失败: $foxel_dir"
fi
done
echo
info "准备目录: $foxel_dir"
mkdir -p "$foxel_dir/data/"{db,mount} && chmod 777 "$foxel_dir/data/"{db,mount}
if [ $? -ne 0 ]; then error "创建或设置子目录权限失败。"; exit 1; fi
cd "$foxel_dir" || exit
info "下载 compose.yaml..."
local COMPOSE_MIRROR_URL="https://ghproxy.com/https://raw.githubusercontent.com/DrizzleTime/Foxel/main/compose.yaml"
local COMPOSE_OFFICIAL_URL="https://raw.githubusercontent.com/DrizzleTime/Foxel/main/compose.yaml"
if ! curl -fsSL -o compose.yaml "$COMPOSE_MIRROR_URL"; then
warn "镜像下载失败,尝试官方源..."
if ! curl -fsSL -o compose.yaml "$COMPOSE_OFFICIAL_URL"; then
error "下载 'compose.yaml' 失败。请检查您的网络连接。"; exit 1;
fi
fi
success "compose.yaml 下载成功。"
echo
choose_image_source
echo
local new_port
while true; do
read -r -p "请输入对外端口 (默认 8088): " new_port
if [[ -z "$new_port" ]]; then
new_port="8088"
info "将使用默认端口 8088。"
break
fi
if ! [[ "$new_port" =~ ^[0-9]+$ ]] || [ "$new_port" -lt 1 ] || [ "$new_port" -gt 65535 ]; then
warn "输入无效。请输入 1-65535 之间的数字。"
continue
fi
if ss -tuln | grep -q ":${new_port}\b"; then
warn "端口 $new_port 已被占用,请换一个。"
else
sed -i -E "s|(FOXEL_HOST_PORT:-)[0-9]{1,5}|\\1$new_port|" compose.yaml
info "端口已成功修改为 $new_port"
break
fi
done
echo
if ! confirm_action "是否生成新的随机密钥(推荐)?" "Y"; then
info "将使用 'compose.yaml' 文件中的默认密钥。"
else
info "正在生成新的随机密钥..."
sed -i "s|SECRET_KEY=.*|SECRET_KEY=$(openssl rand -base64 32)|" compose.yaml
sed -i "s|TEMP_LINK_SECRET_KEY=.*|TEMP_LINK_SECRET_KEY=$(openssl rand -base64 32)|" compose.yaml
success "新的密钥已写入 compose.yaml。"
fi
echo
if confirm_action "配置完成,是否现在启动 Foxel" "Y"; then
info "启动中(首次会拉取镜像,可能需要几分钟)..."
$COMPOSE_CMD pull && $COMPOSE_CMD up -d
if [ $? -eq 0 ]; then
success "Foxel 已启动。"
info "正在检测访问地址..."
# 先捕获所有IP地址
local public_ipv4=$(get_public_ipv4 2>/dev/null)
local public_ipv6=$(get_public_ipv6 2>/dev/null)
local private_ip=$(get_private_ip 2>/dev/null)
local final_port=$new_port
local ip_found=false
echo
info "访问地址:"
if [[ -n "$private_ip" ]]; then
echo " - 局域网地址: http://${private_ip}:${final_port}"
ip_found=true
fi
if [[ -n "$public_ipv4" ]]; then
echo " - 公网地址 (IPv4): http://${public_ipv4}:${final_port}"
ip_found=true
fi
if [[ -n "$public_ipv6" ]]; then
# 正确格式化IPv6地址
echo " - 公网地址 (IPv6): http://[${public_ipv6}]:${final_port}"
ip_found=true
fi
if ! $ip_found; then
warn "未能自动检测到服务器IP地址。"
echo " 请手动使用 http://[您的服务器IP]:${final_port} 访问它。"
fi
echo
info "常用命令:"
echo " - 启动/更新: cd $foxel_dir && $COMPOSE_CMD up -d"
echo " - 停止: cd $foxel_dir && $COMPOSE_CMD stop"
echo " - 日志: cd $foxel_dir && $COMPOSE_CMD logs -f"
else
error "启动 Foxel 失败。请运行 'cd $foxel_dir && $COMPOSE_CMD logs' 查看日志。"
fi
else
info "已跳过启动。稍后可运行cd $foxel_dir && $COMPOSE_CMD up -d"
fi
}
# --- 现有安装管理 ---
get_foxel_install_dir() {
local data_path
data_path=$(docker inspect foxel --format='{{range .Mounts}}{{if eq .Destination "/app/data"}}{{.Source}}{{end}}{{end}}')
if [[ -n "$data_path" ]]; then
echo "$(dirname "$data_path")"
fi
}
service_menu() {
while true; do
echo
echo "--- 服务管理 ---"
echo "1. 启动 Foxel"
echo "2. 停止 Foxel"
echo "3. 重启 Foxel"
echo "4. 查看日志"
echo "5. 返回上级菜单"
read -p "请选择操作 [1-5]: " service_choice
case $service_choice in
1) info "正在启动..."; $COMPOSE_CMD up -d ;;
2) info "正在停止..."; $COMPOSE_CMD stop ;;
3) info "正在重启..."; $COMPOSE_CMD restart ;;
4) info "正在显示日志 (按 Ctrl+C 退出)..."; $COMPOSE_CMD logs -f ;;
5) break ;;
*) warn "无效输入。" ;;
esac
done
}
manage_existing_installation() {
info "检测到 Foxel 已安装。"
local foxel_dir
foxel_dir=$(get_foxel_install_dir)
if [[ -z "$foxel_dir" || ! -f "$foxel_dir/compose.yaml" ]]; then
error "无法自动定位 Foxel 的 compose.yaml 文件。"
read -p "请手动输入 Foxel 的安装目录 (包含 compose.yaml 的目录): " foxel_dir
if [[ ! -f "$foxel_dir/compose.yaml" ]]; then error "在指定目录中未找到 compose.yaml。退出。"; exit 1; fi
fi
info "Foxel 安装目录位于: $foxel_dir"
cd "$foxel_dir" || exit 1
while true; do
echo
echo "--- Foxel 管理菜单 ---"
echo "1. 更新"
echo "2. 卸载"
echo "3. 重新安装"
echo "4. 服务管理 (启动/停止/重启/日志)"
echo "5. 退出"
read -p "请选择操作 [1-5]: " choice
case $choice in
1) # 更新
warn "更新前,强烈建议您备份 '$foxel_dir/data' 目录!"
if confirm_action "确认继续更新?" "Y"; then
info "正在拉取最新镜像..."
$COMPOSE_CMD pull
info "正在使用新镜像重新部署..."
$COMPOSE_CMD up -d
if [ $? -eq 0 ]; then info "Foxel 更新成功!"; else error "更新失败!"; fi
else info "更新操作已取消。"; fi
;;
2) # 卸载
warn "这将停止并删除 Foxel 容器及相关网络!"
warn "强烈建议您先备份 '$foxel_dir/data' 目录!"
if confirm_action "确认继续卸载?" "N"; then
info "正在停止并移除容器..."
$COMPOSE_CMD down
if confirm_action "是否删除所有数据卷(会删除数据库等数据)?" "N"; then
$COMPOSE_CMD down -v
info "数据卷已删除。"
fi
if confirm_action "是否删除 Foxel 安装目录 '$foxel_dir'" "N"; then
rm -rf "$foxel_dir"
info "安装目录已删除。"
fi
info "Foxel 卸载完成。"
exit 0
else info "卸载操作已取消。"; fi
;;
3) # 重新安装
warn "重新安装将完全删除当前的 Foxel 实例(包括数据),然后进入全新安装流程。"
warn "在继续之前,请务必备份好您的重要数据!"
if confirm_action "确认继续重新安装?" "N"; then
info "正在执行卸载..."
$COMPOSE_CMD down -v && rm -rf "$foxel_dir"
info "旧实例已彻底移除。"
install_new_foxel
exit 0
else info "重新安装操作已取消。"; fi
;;
4) # 服务管理
service_menu
;;
5) # 退出
break
;;
*)
warn "无效输入。"
;;
esac
done
}
# --- 主函数 ---
main() {
clear
echo "================================================="
info "欢迎使用 Foxel 一键安装与管理脚本"
echo "================================================="
echo
initialize_environment
echo
if docker ps -a -q -f "name=^/foxel$" | grep -q .; then
manage_existing_installation
else
install_new_foxel
fi
echo
info "脚本执行完毕。"
}
# --- 脚本入口 ---
main

145
setup/foxel_cli.py Normal file
View File

@@ -0,0 +1,145 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import os
import secrets
import sqlite3
import string
import sys
from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parents[1]
DEFAULT_DB_PATH = PROJECT_ROOT / "data/db/db.sqlite3"
if str(PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(PROJECT_ROOT))
from domain.config import VERSION
from domain.auth import get_password_hash
def _supports_color() -> bool:
return sys.stderr.isatty() and not os.getenv("NO_COLOR")
def _print_banner() -> None:
if not sys.stderr.isatty():
return
banner = "\n".join(
[
"███████╗ ██████╗ ██╗ ██╗███████╗██╗",
"██╔════╝██╔═══██╗╚██╗██╔╝██╔════╝██║",
"█████╗ ██║ ██║ ╚███╔╝ █████╗ ██║",
"██╔══╝ ██║ ██║ ██╔██╗ ██╔══╝ ██║",
"██║ ╚██████╔╝██╔╝ ██╗███████╗███████╗",
"╚═╝ ╚═════╝ ╚═╝ ╚═╝╚══════╝╚══════╝",
]
)
title = f"Foxel Admin CLI {VERSION}"
if _supports_color():
c_reset = "\033[0m"
c_bold = "\033[1m"
c_orange = "\033[38;5;208m"
c_orange_light = "\033[38;5;214m"
c_orange_lighter = "\033[38;5;220m"
banner_lines = banner.splitlines()
shades = [
c_orange,
c_orange_light,
c_orange_lighter,
c_orange_lighter,
c_orange_light,
c_orange,
]
for line, color in zip(banner_lines, shades, strict=False):
print(f"{c_bold}{color}{line}{c_reset}", file=sys.stderr)
print(f"{c_bold}{title}{c_reset}\n", file=sys.stderr)
else:
print(banner, file=sys.stderr)
print(f"{title}\n", file=sys.stderr)
def _gen_password(length: int) -> str:
alphabet = string.ascii_letters + string.digits
return "".join(secrets.choice(alphabet) for _ in range(length))
def _find_user(conn: sqlite3.Connection, username_or_email: str) -> tuple[int, str] | None:
cursor = conn.cursor()
normalized = username_or_email.strip().lower()
candidates = [
("username", username_or_email),
("email", username_or_email),
]
if normalized and normalized != username_or_email:
candidates.append(("email", normalized))
for field, value in candidates:
cursor.execute(f"SELECT id, username FROM user WHERE {field} = ?", (value,))
row = cursor.fetchone()
if row:
return int(row[0]), str(row[1])
return None
def _cmd_reset_password(args: argparse.Namespace) -> int:
db_path = Path(args.db).expanduser() if args.db else DEFAULT_DB_PATH
if args.random:
password = _gen_password(args.length)
else:
password = args.password
hashed_password = get_password_hash(password)
with sqlite3.connect(str(db_path)) as conn:
user = _find_user(conn, args.username_or_email)
if not user:
print(f"用户不存在: {args.username_or_email}", file=sys.stderr)
return 1
user_id, username = user
conn.execute(
"UPDATE user SET hashed_password = ? WHERE id = ?",
(hashed_password, user_id),
)
conn.commit()
if args.random:
print(password)
print(f"已重置用户密码: {username} (id={user_id})", file=sys.stderr)
return 0
def _build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(prog="foxel")
subparsers = parser.add_subparsers(dest="command", required=True)
reset_password = subparsers.add_parser("reset-password", help="重置用户密码")
reset_password.add_argument("username_or_email", help="用户名或邮箱")
reset_password.add_argument("password", nargs="?", help="新密码(或用 --random")
reset_password.add_argument("--random", action="store_true", help="生成随机密码并输出到 stdout")
reset_password.add_argument("--length", type=int, default=16, help="随机密码长度(默认 16")
reset_password.add_argument("--db", help="sqlite db 路径(默认 data/db/db.sqlite3")
reset_password.set_defaults(func=_cmd_reset_password)
return parser
def main(argv: list[str] | None = None) -> int:
_print_banner()
parser = _build_parser()
args = parser.parse_args(argv)
if args.command == "reset-password" and not args.random and not args.password:
parser.error("reset-password 需要提供 password 或使用 --random")
return int(args.func(args))
if __name__ == "__main__":
raise SystemExit(main())