4 Commits
Author SHA1 Message Date
loveuer b8fda4322f init: 0.0.7
next: upload
2025-04-28 17:59:07 +08:00
loveuer 0cfd617922 init: 0.0.6
next: upload
2025-04-28 14:59:22 +08:00
loveuer 4801b3de08 wip: v0.0.5 2025-04-27 22:14:21 +08:00
loveuer 25c76aebf2 init: 0.0.4 2025-04-27 18:01:58 +08:00
90 changed files with 288 additions and 8166 deletions
-3
View File
@@ -1,3 +0,0 @@
frontend/node_modules
dist
frontend/dist
-22
View File
@@ -1,22 +0,0 @@
run-name: build ushare docker image
on:
push:
tags:
- 'v*'
jobs:
build:
runs-on: debian
steps:
- name: prepare environment
uses: actions/checkout@v4
- name: print info
run: |
date '+%Y-%m-%dT%H:%M:%S'
whoami
echo "Tag = ${{ gitea.ref_name }}"
pwd && ls -alsh .
- name: build docker image
run: docker build -t ushare:${{ gitea.ref_name }} .
-90
View File
@@ -1,90 +0,0 @@
run-name: release ushare binaries
on:
push:
tags:
- 'v*'
jobs:
release:
runs-on: debian
steps:
- name: prepare environment
uses: actions/checkout@v4
- name: print info
run: |
date '+%Y-%m-%dT%H:%M:%S'
echo "Tag = ${{ gitea.ref_name }}"
echo "Repository = ${{ gitea.repository }}"
echo "Server = ${{ gitea.server_url }}"
- name: build frontend
run: |
docker run --rm \
--network host \
-v "$(pwd)/frontend":/app/frontend \
-w /app/frontend \
node:20-alpine \
sh -c "npm install -g pnpm --registry=https://registry.npmmirror.com \
&& pnpm install --registry=https://registry.npmmirror.com \
&& pnpm run build"
mkdir -p internal/static/frontend
cp -r frontend/dist internal/static/frontend/dist
- name: build binaries
run: |
mkdir -p dist
docker run --rm \
--network host \
-v "$(pwd)":/workspace \
-w /workspace \
-e GOPROXY=https://goproxy.cn,direct \
golang:alpine \
sh -c "
apk add --no-cache git && \
go mod download && \
for TARGET in linux/amd64 linux/arm64 darwin/amd64 darwin/arm64 windows/amd64; do
OS=\$(echo \$TARGET | cut -d/ -f1)
ARCH=\$(echo \$TARGET | cut -d/ -f2)
OUTPUT=\"dist/ushare-\${OS}-\${ARCH}\"
[ \"\$OS\" = \"windows\" ] && OUTPUT=\"\${OUTPUT}.exe\"
echo \">>> Building \${OUTPUT} ...\"
CGO_ENABLED=0 GOOS=\$OS GOARCH=\$ARCH \
go build -ldflags '-s -w' -o \$OUTPUT .
done
"
ls -lh dist/
- name: create release
run: |
apt-get install -y -qq jq
TAG="${{ gitea.ref_name }}"
SERVER="${{ gitea.server_url }}"
REPO="${{ gitea.repository }}"
TOKEN="${{ secrets.GITEA_TOKEN }}"
RESPONSE=$(curl -sf -X POST \
"${SERVER}/api/v1/repos/${REPO}/releases" \
-H "Authorization: token ${TOKEN}" \
-H "Content-Type: application/json" \
-d "{\"tag_name\":\"${TAG}\",\"name\":\"${TAG}\",\"draft\":false,\"prerelease\":false}")
echo "Release created: $(echo $RESPONSE | jq -r '.id')"
echo "RELEASE_ID=$(echo $RESPONSE | jq -r '.id')" >> $GITHUB_ENV
- name: upload assets
run: |
SERVER="${{ gitea.server_url }}"
REPO="${{ gitea.repository }}"
TOKEN="${{ secrets.GITEA_TOKEN }}"
for FILE in dist/ushare-*; do
FILENAME=$(basename "$FILE")
echo ">>> Uploading ${FILENAME} ..."
curl -sf -X POST \
"${SERVER}/api/v1/repos/${REPO}/releases/${RELEASE_ID}/assets?name=${FILENAME}" \
-H "Authorization: token ${TOKEN}" \
-H "Content-Type: application/octet-stream" \
--data-binary @"${FILE}"
done
-106
View File
@@ -1,106 +0,0 @@
name: Release Binaries
on:
push:
tags:
- 'v*'
permissions:
contents: write
jobs:
release:
name: Build and Release
runs-on: ubuntu-latest
strategy:
matrix:
include:
- goos: linux
goarch: amd64
suffix: linux-amd64
- goos: linux
goarch: arm64
suffix: linux-arm64
- goos: darwin
goarch: amd64
suffix: darwin-amd64
- goos: darwin
goarch: arm64
suffix: darwin-arm64
- goos: windows
goarch: amd64
suffix: windows-amd64
ext: .exe
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: '1.24'
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Install pnpm
uses: pnpm/action-setup@v2
with:
version: 8
- name: Get pnpm store directory
id: pnpm-cache
shell: bash
run: |
echo "STORE_PATH=$(pnpm store path)" >> $GITHUB_OUTPUT
- name: Setup pnpm cache
uses: actions/cache@v3
with:
path: ${{ steps.pnpm-cache.outputs.STORE_PATH }}
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-store-
- name: Print build info
run: |
echo "Tag: ${{ github.ref_name }}"
echo "GoOS: ${{ matrix.goos }}"
echo "GoArch: ${{ matrix.goarch }}"
- name: Build frontend
working-directory: frontend
run: |
pnpm install --registry=https://registry.npmmirror.com
pnpm run build
- name: Copy frontend dist for embedding
run: |
mkdir -p internal/static/frontend
cp -r frontend/dist internal/static/frontend/
- name: Build binary
env:
GOOS: ${{ matrix.goos }}
GOARCH: ${{ matrix.goarch }}
CGO_ENABLED: 0
run: |
OUTPUT_NAME="ushare-${{ matrix.suffix }}${{ matrix.ext }}"
go build -ldflags '-s -w' -o "$OUTPUT_NAME" .
chmod +x "$OUTPUT_NAME"
- name: Generate checksums
run: |
OUTPUT_NAME="ushare-${{ matrix.suffix }}${{ matrix.ext }}"
sha256sum "$OUTPUT_NAME" > "$OUTPUT_NAME.sha256"
- name: Upload to release
uses: softprops/action-gh-release@v1
with:
files: |
ushare-${{ matrix.suffix }}${{ matrix.ext }}
ushare-${{ matrix.suffix }}${{ matrix.ext }}.sha256
draft: false
prerelease: false
+1 -15
View File
@@ -1,19 +1,5 @@
# IDE
.idea
.vscode
# OS
.DS_Store
# Build output
dist
# Data directories
data/
x-*/
# Temporary build files
internal/static/frontend
# Compiled binaries
ushare
xtest
-250
View File
@@ -1,250 +0,0 @@
# AGENTS.md
## Build Commands
### Go Backend (Root)
```bash
# Build the Go backend binary
go build -o ushare .
# Run tests
go test ./...
# Run single test
go test -run TestFunctionName ./internal/pkg/tool
# Run tests in specific package
go test ./internal/pkg/tool
# Run tests with verbose output
go test -v ./...
# Run the application
./ushare -debug -address 0.0.0.0:9119 -data ./data -auth "admin:password"
```
### TypeScript Frontend (frontend/)
```bash
# Install dependencies (uses pnpm)
pnpm install
# Development server
pnpm run dev
# Build for production
pnpm run build
# Lint code
pnpm run lint
# Preview production build
pnpm run preview
```
### Docker Build
```bash
# Build the complete Docker image
docker build -t ushare:latest .
```
## Code Style Guidelines
### Go Backend
#### Imports
- Group imports in three sections: standard library, third-party, internal
- Keep one import per line for readability
- Example:
```go
import (
"context"
"fmt"
"net/http"
"github.com/pkg/errors"
"github.com/spf13/viper"
"github.com/loveuer/ushare/internal/model"
"github.com/loveuer/ushare/internal/opt"
)
```
#### Naming Conventions
- Exported functions/types: PascalCase (e.g., `UserManager`, `NewPassword`)
- Private functions/types: camelCase (e.g., `generateMeta`, `tokenFn`)
- Variables: camelCase (e.g., `filename`, `totalChunks`)
- Constants: PascalCase (e.g., `Meta`, `HeaderSize`, `CodeLength`)
- Interfaces: Usually implied, not explicitly declared unless needed
- Receiver names: Short, 1-2 letters (e.g., `m`, `um`, `c`)
#### Error Handling
- Use `github.com/pkg/errors` for error wrapping
- Check errors immediately after function calls
- Return errors for functions that can fail
- Use `errors.New()` for simple error messages
- Wrap errors with context when propagating up:
```go
if err != nil {
return errors.New("invalid file code")
}
```
#### Struct Tags
- Use `json` tags for JSON serialization
- Use `mapstructure` tags for config parsing (viper)
- Use `-` for fields to exclude from JSON:
```go
type User struct {
Id int `json:"id"`
Username string `json:"username"`
Password string `json:"-"`
}
```
#### Concurrency
- Use `sync.Mutex` for protecting shared state
- Always use `defer mutex.Unlock()` after `mutex.Lock()`
- Use goroutines with select statements for graceful shutdown
#### Testing
- Use standard `testing` package
- Test functions named `Test<FunctionName>`
- Table-driven tests are preferred:
```go
func TestFunction(t *testing.T) {
tests := []struct {
name string
arg ArgType
want ReturnType
}{
{"case 1", arg1, want1},
{"case 2", arg2, want2},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := Function(tt.arg); got != tt.want {
t.Errorf("Function() = %v, want %v", got, tt.want)
}
})
}
}
```
#### Generics
- Use generics for utility functions with type parameters:
```go
func Min[T ~int | ~uint | ~float64](a, b T) T
```
#### Context
- Use `context.Context` for cancellation signals
- Always check `ctx.Done()` in long-running goroutines
### TypeScript Frontend
#### Imports
- Use ES6 imports
- Third-party imports first, then relative imports:
```typescript
import { useState } from 'react';
import { createUseStyles } from 'react-jss';
import { CloudBackground } from "../component/fluid/cloud.tsx";
```
#### Naming Conventions
- Components: PascalCase (e.g., `UButton`, `Login`, `FileSharing`)
- Functions/hooks: camelCase (e.g., `useFileUpload`, `onLogin`)
- Variables: camelCase (e.g., `progress`, `loading`, `error`)
- Constants: UPPER_SNAKE_CASE (rarely used)
- Types/Interfaces: PascalCase (e.g., `UploadRes`, `LocalStore`)
#### Types
- Explicitly type function parameters and return values
- Use interfaces for object shapes:
```typescript
interface UploadRes {
code: string
}
interface LocalStore {
id: string;
name: string;
channel?: RTCDataChannel;
set: (id: string, name: string) => void;
}
```
#### Components
- Use functional components with hooks
- Props as interface at top of component:
```typescript
type Props = {
onClick?: () => void;
children: ReactNode;
disabled?: boolean;
};
export const UButton: React.FC<Props> = ({ onClick, children, disabled }) => { ... }
```
#### Styling
- Use `react-jss` with `createUseStyles` for component styles
- Define styles object with camelCase properties:
```typescript
const useStyle = createUseStyles({
container: {
display: "flex",
"&:hover": { backgroundColor: "#45a049" }
}
});
```
#### State Management
- Use `useState` for local component state
- Use `zustand` for global state (defined in `store/` directory)
- Always destructure from store hooks:
```typescript
export const useLocalStore = create<LocalStore>()((_set) => ({
id: '',
set: (id: string) => _set({ id })
}))
```
#### Async Patterns
- Use async/await for API calls
- Handle errors with try-catch:
```typescript
try {
const result = await uploadFile(file);
} catch (err) {
setError(err.message);
}
```
#### Configuration
- Vite dev server proxies `/api` and `/ushare` to Go backend at `http://127.0.0.1:9119`
- WebSocket proxy configured for `/api/ulocal/ws`
## Project Structure
```
ushare/
├── internal/
│ ├── api/ # HTTP API setup and routes
│ ├── controller/ # Business logic (user, meta, room management)
│ ├── handler/ # HTTP request handlers
│ ├── model/ # Data models (User, Meta, WS)
│ ├── opt/ # Configuration and constants
│ └── pkg/
│ ├── db/ # Database utilities
│ └── tool/ # Utility functions (password, random, etc.)
├── frontend/
│ └── src/
│ ├── api/ # API calls (auth, upload)
│ ├── component/ # Reusable UI components
│ ├── hook/ # Custom hooks (websocket, message)
│ ├── page/ # Page components (login, share, local)
│ ├── store/ # Zustand state stores
│ └── interface/ # TypeScript interfaces
└── deployment/ # Docker and nginx configs
```
-39
View File
@@ -1,39 +0,0 @@
# 第一阶段:构建前端
FROM node:20-alpine AS frontend-builder
RUN npm install -g pnpm --registry=https://registry.npmmirror.com
COPY frontend /app/frontend
WORKDIR /app/frontend
RUN pnpm install --registry=https://registry.npmmirror.com
RUN pnpm run build
# 第二阶段:构建 Golang 后端
FROM golang:alpine AS backend-builder
WORKDIR /app
ENV CGO_ENABLED=0
ENV GOOS=linux
ENV GOPROXY=https://goproxy.cn
COPY go.mod /app/go.mod
COPY go.sum /app/go.sum
RUN go mod download
COPY main.go /app/main.go
COPY internal /app/internal
# 将前端构建产物复制到 go:embed 所需的路径
COPY --from=frontend-builder /app/frontend/dist /app/internal/static/frontend/dist
RUN go build -ldflags '-s -w' -o ushare .
# 第三阶段:生成最终镜像
FROM nginx:alpine
COPY --from=frontend-builder /app/frontend/dist /usr/share/nginx/html
COPY --from=backend-builder /app/ushare /usr/local/bin/ushare
# 配置 Nginx
COPY deployment/nginx.conf /etc/nginx/nginx.conf
COPY deployment/entrypoint.sh /usr/local/bin/entrypoint.sh
RUN chmod +x /usr/local/bin/entrypoint.sh
# 开放端口
EXPOSE 80
# 启动服务
ENTRYPOINT ["entrypoint.sh"]
-168
View File
@@ -1,168 +0,0 @@
# UShare
一个简单易用的文件分享工具,支持大文件分片上传下载,以及局域网内 P2P 文件分享。
## 功能特性
- 📁 大文件分片上传下载
- 🌐 网页端文件分享
- 🏠 局域网 P2P 文件分享(WebRTC)
- 📱 响应式设计,支持移动端
- 🔐 简单的用户认证
- 🚀 单一二进制,开箱即用
- 🐳 Docker 支持
## 快速开始
### 开发模式
```bash
# 克隆仓库
git clone https://github.com/loveuer/ushare.git
cd ushare
# 启动开发服务器
./dev.sh
```
开发服务器会同时启动后端(http://0.0.0.0:9119)和前端(http://0.0.0.0:5173),前端支持热重载。
### 生产模式
#### 使用预编译二进制
从 [Releases](https://github.com/loveuer/ushare/releases) 下载对应平台的二进制文件。
```bash
# Linux amd64
wget https://github.com/loveuer/ushare/releases/download/v0.3.2/ushare-linux-amd64
chmod +x ushare-linux-amd64
# 运行
./ushare-linux-amd64 -data ./data
```
#### 从源码构建
```bash
# 构建单一二进制(包含嵌入的前端)
./make.sh
# 运行
./dist/ushare -data ./data
```
#### Docker 部署
```bash
docker build -t ushare:latest .
docker run -d -p 80:80 -v $(pwd)/data:/data ushare:latest
```
## 配置
### 命令行参数
```bash
ushare [options]
Options:
-debug 启用调试模式
-address string 监听地址 (default "0.0.0.0:9119")
-data string 数据目录 (default "/data")
-clean int 文件清理周期,单位:小时,0 表示不自动清理 (default 24)
```
### 环境变量
| 环境变量 | 说明 | 默认值 |
|---------|------|--------|
| `USHARE_USERNAME` | 用户名 | `admin` |
| `USHARE_PASSWORD` | 密码 | `ushare@123` |
#### 示例
```bash
# 使用默认凭据(admin / ushare@123)
./ushare -data ./data
# 使用自定义凭据
export USHARE_USERNAME=myuser
export USHARE_PASSWORD=mypass
./ushare -data ./data
```
## 使用说明
### 文件分享
1. 访问 http://localhost:9119/share
2. 点击"选择文件"选择要上传的文件
3. 点击"上传文件",等待上传完成
4. 复制生成的下载码分享给他人
### 文件下载
1. 访问 http://localhost:9119/share
2. 在"获取文件"区域输入下载码
3. 点击"获取文件"下载文件
### 局域网 P2P 分享
1. 访问 http://localhost:9119
2. 自动注册到局域网设备列表
3. 选择要分享的设备,建立 P2P 连接
4. 开始文件传输
## 移动端支持
- 响应式设计,自动适配手机、平板等设备
- 移动端(≤768px)采用垂直布局,优化触摸操作
- 按钮尺寸优化,适合手指点击
## 开发
### 目录结构
```
ushare/
├── internal/ # 后端代码
│ ├── api/ # HTTP API 路由
│ ├── controller/ # 业务逻辑
│ ├── handler/ # 请求处理器
│ ├── model/ # 数据模型
│ ├── opt/ # 配置管理
│ └── pkg/ # 工具包
├── frontend/ # 前端代码
│ └── src/
│ ├── api/ # API 调用
│ ├── component/ # UI 组件
│ ├── hook/ # 自定义 Hooks
│ ├── page/ # 页面组件
│ └── store/ # 状态管理
├── .github/workflows/ # GitHub Actions
├── dist/ # 构建输出(.gitignore)
└── data/ # 数据目录(.gitignore)
```
### 构建
```bash
# 开发构建(前后端分离)
./dev.sh
# 生产构建(单一二进制)
./make.sh
```
### 代码风格
请参考 [AGENTS.md](./AGENTS.md) 了解代码风格指南。
## License
MIT License
## 贡献
欢迎提交 Issue 和 Pull Request!
-35
View File
@@ -1,35 +0,0 @@
#!/bin/sh
set -e
# 启动后端服务(带参数后台运行)
echo "启动后端服务,参数: $@"
ushare "$@" &
BACKEND_PID=$!
# 定义优雅关闭函数
graceful_shutdown() {
echo "接收到终止信号,开始优雅关闭..."
# 先关闭 Nginx
echo "关闭 Nginx..."
nginx -s quit 2>/dev/null || kill -TERM $NGINX_PID 2>/dev/null
# 关闭后端服务
echo "关闭后端服务 PID $BACKEND_PID..."
kill -TERM $BACKEND_PID 2>/dev/null
# 等待进程终止
wait $BACKEND_PID $NGINX_PID 2>/dev/null
exit 0
}
# 捕获系统信号
trap 'graceful_shutdown' SIGTERM SIGINT
# 启动 Nginx(前台运行)
echo "启动 Nginx..."
nginx -g "daemon off;" &
NGINX_PID=$!
# 等待所有后台进程
wait $BACKEND_PID $NGINX_PID
-60
View File
@@ -1,60 +0,0 @@
user root;
worker_processes auto;
error_log /var/log/nginx/error.log warn;
pid /var/run/nginx.pid;
events {
worker_connections 1024;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
'$status $body_bytes_sent "$http_referer" '
'"$http_user_agent" "$http_x_forwarded_for"';
access_log /var/log/nginx/access.log main;
sendfile on;
keepalive_timeout 65;
server {
listen 80;
location / {
root /usr/share/nginx/html;
try_files $uri $uri/ /index.html;
}
location /api {
proxy_pass http://localhost:9119;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
location /api/ulocal/ws {
proxy_pass http://localhost:9119;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
location /ushare {
proxy_pass http://localhost:9119;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_max_temp_file_size 0;
proxy_request_buffering off;
proxy_buffering off;
client_max_body_size 5M;
}
}
}
-63
View File
@@ -1,63 +0,0 @@
#!/bin/bash
set -e
# 捕获 Ctrl+C 信号
trap 'echo ""; echo "Stopping..."; kill $(jobs -p); exit 0' SIGINT SIGTERM
echo "=========================================="
echo " Starting UShare Development Server"
echo "=========================================="
echo ""
# 构建前端(如果需要)
if [ ! -d "frontend/dist" ]; then
echo "[Frontend] Building..."
cd frontend && pnpm run build && cd ..
echo "[Frontend] Build complete!"
fi
# 创建临时嵌入目录用于编译
mkdir -p internal/static/frontend
if [ ! -d "internal/static/frontend/dist" ]; then
echo "[Setup] Creating frontend embed directory..."
cp -r frontend/dist internal/static/frontend/
fi
# 检查后端是否已构建
if [ ! -f "./ushare" ]; then
echo "[Backend] Building..."
go build -o ushare .
echo "[Backend] Build complete!"
fi
# 创建数据目录
mkdir -p ./data
# 启动后端
echo "[Backend] Starting..."
./ushare -debug -address 0.0.0.0:9119 -data ./data &
BACKEND_PID=$!
echo "[Backend] Running on http://0.0.0.0:9119 (PID: $BACKEND_PID)"
echo ""
# 启动前端
echo "[Frontend] Starting..."
cd frontend && pnpm run dev &
FRONTEND_PID=$!
cd ..
echo "[Frontend] Running on http://localhost:5173 (PID: $FRONTEND_PID)"
echo ""
echo "=========================================="
echo " All services started!"
echo " - Backend: http://0.0.0.0:9119"
echo " - Frontend: http://0.0.0.0:5173"
echo "=========================================="
echo ""
echo "Note: Frontend hot-reload is enabled. Changes to backend code require rebuilding."
echo "Press Ctrl+C to stop all services"
echo ""
# 等待所有后台进程
wait
+10 -20
View File
@@ -1,23 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<!-- Emoji 图标方案 -->
<link rel="icon"
href="data:image/svg+xml,
<svg xmlns='http://www.w3.org/2000/svg'
viewBox='0 0 100 100'>
<text y='.9em'
font-size='90'
style='font-family: Arial, sans-serif'>
🗃️
</text>
</svg>">
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>UShare</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vite + React + TS</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
-1
View File
@@ -13,7 +13,6 @@
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-jss": "^10.10.0",
"react-router-dom": "^7.5.3",
"zustand": "^5.0.3"
},
"devDependencies": {
-51
View File
@@ -17,9 +17,6 @@ importers:
react-jss:
specifier: ^10.10.0
version: 10.10.0(react@19.1.0)
react-router-dom:
specifier: ^7.5.3
version: 7.5.3(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
zustand:
specifier: ^5.0.3
version: 5.0.3(@types/react@19.1.2)(react@19.1.0)
@@ -643,10 +640,6 @@ packages:
convert-source-map@2.0.0:
resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==}
cookie@1.0.2:
resolution: {integrity: sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA==}
engines: {node: '>=18'}
cross-spawn@7.0.6:
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
engines: {node: '>= 8'}
@@ -1127,23 +1120,6 @@ packages:
resolution: {integrity: sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==}
engines: {node: '>=0.10.0'}
react-router-dom@7.5.3:
resolution: {integrity: sha512-cK0jSaTyW4jV9SRKAItMIQfWZ/D6WEZafgHuuCb9g+SjhLolY78qc+De4w/Cz9ybjvLzShAmaIMEXt8iF1Cm+A==}
engines: {node: '>=20.0.0'}
peerDependencies:
react: '>=18'
react-dom: '>=18'
react-router@7.5.3:
resolution: {integrity: sha512-3iUDM4/fZCQ89SXlDa+Ph3MevBrozBAI655OAfWQlTm9nBR0IKlrmNwFow5lPHttbwvITZfkeeeZFP6zt3F7pw==}
engines: {node: '>=20.0.0'}
peerDependencies:
react: '>=18'
react-dom: '>=18'
peerDependenciesMeta:
react-dom:
optional: true
react@19.1.0:
resolution: {integrity: sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==}
engines: {node: '>=0.10.0'}
@@ -1179,9 +1155,6 @@ packages:
engines: {node: '>=10'}
hasBin: true
set-cookie-parser@2.7.1:
resolution: {integrity: sha512-IOc8uWeOZgnb3ptbCURJWNjWUPcO3ZnTTdzsurqERrP6nPyv+paC55vJM0LpOlT2ne+Ix+9+CRG1MNLlyZ4GjQ==}
shallow-equal@1.2.1:
resolution: {integrity: sha512-S4vJDjHHMBaiZuT9NPb616CSmLf618jawtv3sufLl6ivK8WocjAo58cXwbRV1cgqxH0Qbv+iUt6m05eqEa2IRA==}
@@ -1232,9 +1205,6 @@ packages:
peerDependencies:
typescript: '>=4.8.4'
turbo-stream@2.4.0:
resolution: {integrity: sha512-FHncC10WpBd2eOmGwpmQsWLDoK4cqsA/UT/GqNoaKOQnT8uzhtCbg3EoUDMvqpOSAI0S26mr0rkjzbOO6S3v1g==}
type-check@0.4.0:
resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==}
engines: {node: '>= 0.8.0'}
@@ -1863,8 +1833,6 @@ snapshots:
convert-source-map@2.0.0: {}
cookie@1.0.2: {}
cross-spawn@7.0.6:
dependencies:
path-key: 3.1.1
@@ -2376,21 +2344,6 @@ snapshots:
react-refresh@0.17.0: {}
react-router-dom@7.5.3(react-dom@19.1.0(react@19.1.0))(react@19.1.0):
dependencies:
react: 19.1.0
react-dom: 19.1.0(react@19.1.0)
react-router: 7.5.3(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
react-router@7.5.3(react-dom@19.1.0(react@19.1.0))(react@19.1.0):
dependencies:
cookie: 1.0.2
react: 19.1.0
set-cookie-parser: 2.7.1
turbo-stream: 2.4.0
optionalDependencies:
react-dom: 19.1.0(react@19.1.0)
react@19.1.0: {}
regenerator-runtime@0.14.1: {}
@@ -2435,8 +2388,6 @@ snapshots:
semver@7.7.1: {}
set-cookie-parser@2.7.1: {}
shallow-equal@1.2.1: {}
shebang-command@2.0.0:
@@ -2478,8 +2429,6 @@ snapshots:
dependencies:
typescript: 5.7.3
turbo-stream@2.4.0: {}
type-check@0.4.0:
dependencies:
prelude-ls: 1.2.1
+5
View File
@@ -0,0 +1,5 @@
import {FileSharing} from "./page/share.tsx";
export function App() {
return <FileSharing />;
}
-89
View File
@@ -1,89 +0,0 @@
export interface Role {
id: number;
name: string;
label: string;
permissions: string;
created_at: string;
updated_at: string;
}
export interface AdminUser {
id: number;
username: string;
role_id: number;
role: Role;
active: boolean;
created_at: string;
updated_at: string;
}
export interface CreateUserReq {
username: string;
password: string;
role_id: number;
}
export interface UpdateUserReq {
role_id?: number;
active?: boolean;
password?: string;
}
const jsonHeaders: HeadersInit = {'Content-Type': 'application/json'};
export const adminApi = {
listUsers: async (): Promise<AdminUser[]> => {
const res = await fetch('/api/admin/users', {headers: jsonHeaders});
if (!res.ok) {
const json = await res.json().catch(() => ({}));
throw new Error(json.msg || '查询失败');
}
return (await res.json()).data;
},
createUser: async (req: CreateUserReq): Promise<AdminUser> => {
const res = await fetch('/api/admin/users', {
method: 'POST',
headers: jsonHeaders,
body: JSON.stringify(req),
});
if (!res.ok) {
const json = await res.json().catch(() => ({}));
throw new Error(json.msg || '创建失败');
}
return (await res.json()).data;
},
updateUser: async (id: number, req: UpdateUserReq): Promise<AdminUser> => {
const res = await fetch(`/api/admin/users/${id}`, {
method: 'PUT',
headers: jsonHeaders,
body: JSON.stringify(req),
});
if (!res.ok) {
const json = await res.json().catch(() => ({}));
throw new Error(json.msg || '更新失败');
}
return (await res.json()).data;
},
deleteUser: async (id: number): Promise<void> => {
const res = await fetch(`/api/admin/users/${id}`, {
method: 'DELETE',
headers: jsonHeaders,
});
if (!res.ok) {
const json = await res.json().catch(() => ({}));
throw new Error(json.msg || '删除失败');
}
},
listRoles: async (): Promise<Role[]> => {
const res = await fetch('/api/admin/roles', {headers: jsonHeaders});
if (!res.ok) {
const json = await res.json().catch(() => ({}));
throw new Error(json.msg || '查询失败');
}
return (await res.json()).data;
},
};
-32
View File
@@ -1,32 +0,0 @@
import {useState} from "react";
import {message} from "../hook/message/u-message.tsx";
export interface User {
id: number;
username: string;
login_at: number;
token: string;
}
export const useAuth = () => {
const [user, setUser] = useState<User | null>(null)
const login = async (username: string, password: string) => {
let res = await fetch("/api/uauth/login", {
method: "POST",
headers: {"Content-Type": "application/json"},
body: JSON.stringify({username: username, password: password}),
})
if(res.status !== 200) {
message.error("账号或密码错误")
throw new Error(await res.text())
}
let jes = await res.json() as User;
setUser(jes)
}
return {user, login}
}
-53
View File
@@ -1,53 +0,0 @@
export interface ApiToken {
id: number;
user_id: number;
name: string;
created_at: string;
last_used_at: string | null;
expires_at: string | null;
}
export interface CreateTokenRes {
id: number;
name: string;
token: string;
created_at: string;
}
const jsonHeaders: HeadersInit = {'Content-Type': 'application/json'};
export const tokenApi = {
list: async (): Promise<ApiToken[]> => {
const res = await fetch('/api/token', {headers: jsonHeaders});
if (!res.ok) {
const json = await res.json().catch(() => ({}));
throw new Error(json.msg || '查询失败');
}
return (await res.json()).data;
},
create: async (name: string): Promise<CreateTokenRes> => {
const res = await fetch('/api/token', {
method: 'POST',
headers: jsonHeaders,
body: JSON.stringify({name}),
});
if (!res.ok) {
const json = await res.json().catch(() => ({}));
throw new Error(json.msg || '创建失败');
}
return (await res.json()).data;
},
delete: async (id: number): Promise<void> => {
const res = await fetch('/api/token', {
method: 'DELETE',
headers: jsonHeaders,
body: JSON.stringify({id}),
});
if (!res.ok) {
const json = await res.json().catch(() => ({}));
throw new Error(json.msg || '删除失败');
}
},
};
-90
View File
@@ -1,90 +0,0 @@
import { useState } from 'react';
export interface UploadSettings {
maxDownloads: number; // 0 = unlimited
expiresIn: number; // seconds
}
interface UploadRes {
code: string
}
export const useFileUpload = () => {
const [progress, setProgress] = useState(0);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const uploadFile = async (file: File, settings?: UploadSettings): Promise<string> => {
setLoading(true);
setError(null);
setProgress(0);
const maxDownloads = settings?.maxDownloads ?? 3;
const expiresIn = settings?.expiresIn ?? 28800;
try {
const url = `/api/ushare/${file.name}`;
// 1. 初始化上传
const res1 = await fetch(url, {
method: "PUT",
headers: {
"X-File-Size": file.size.toString(),
"X-Max-Downloads": maxDownloads.toString(),
"X-Expires-In": expiresIn.toString(),
}
});
if (!res1.ok) {
console.log(`[W] upload: put file not ok, status = ${res1.status}, res = ${await res1.text()}`)
if (res1.status === 401) {
window.location.href = "/login?next=/share"
return ""
}
throw new Error("上传失败<1>");
}
const j1 = await res1.json() as UploadRes;
if (!j1.code) {
throw new Error("上传失败<2>");
}
// 2. 准备分片上传
const CHUNK_SIZE = 1024 * 1024;
const totalChunks = Math.ceil(file.size / CHUNK_SIZE);
const code = j1.code;
// 3. 上传分片
for (let chunkIndex = 0; chunkIndex < totalChunks; chunkIndex++) {
const start = chunkIndex * CHUNK_SIZE;
const end = Math.min(start + CHUNK_SIZE, file.size);
const chunk = file.slice(start, end);
const res = await fetch(`/api/ushare/${code}`, {
method: "POST",
headers: {
"Range": `bytes=${start}-${end - 1}`,
"Content-Type": "application/octet-stream"
},
body: chunk
});
if (!res.ok) {
const err = await res.text();
throw new Error(`上传失败<3>: ${err}`);
}
const currentProgress = Math.round(((chunkIndex + 1) / totalChunks) * 100);
setProgress(currentProgress);
}
return code;
} catch (err) {
throw err;
} finally {
setLoading(false);
}
};
return { uploadFile, progress, loading, error };
};
+11 -80
View File
@@ -1,5 +1,5 @@
import React, {ReactNode} from 'react';
import {createUseStyles} from 'react-jss';
import {createUseStyles} from "react-jss";
const useStyle = createUseStyles({
ubutton: {
@@ -10,8 +10,6 @@ const useStyle = createUseStyles({
borderRadius: "5px",
cursor: "pointer",
transition: "background-color 0.3s",
position: 'relative',
overflow: 'hidden',
"&:hover": {
backgroundColor: "#45a049",
},
@@ -19,84 +17,17 @@ const useStyle = createUseStyles({
backgroundColor: "#a5d6a7",
cursor: "not-allowed",
},
},
loadingContent: {
display: 'flex',
alignItems: 'center',
gap: '8px',
},
spinner: {
animation: '$spin 1s linear infinite',
border: '2px solid white',
borderTopColor: 'transparent',
borderRadius: '50%',
width: '16px',
height: '16px',
},
'@keyframes spin': {
'0%': {transform: 'rotate(0deg)'},
'100%': {transform: 'rotate(360deg)'},
},
progressBar: {
position: 'absolute',
bottom: 0,
left: 0,
height: '3px',
backgroundColor: 'rgba(255,255,255,0.5)',
width: '100%',
},
progressFill: {
height: '100%',
backgroundColor: 'white',
transition: 'width 0.3s ease',
},
});
}
})
type Props = {
onClick?: () => void;
onClick: () => void;
children: ReactNode;
disabled?: boolean;
style?: React.CSSProperties;
loading?: boolean;
process?: number;
};
export const UButton: React.FC<Props> = ({
onClick,
children,
disabled = false,
style,
loading,
process
}) => {
const classes = useStyle();
return (
<button
style={style}
className={classes.ubutton}
disabled={disabled || loading}
onClick={onClick}
>
{/* 显示加载状态或默认内容 */}
{loading ? (
<div className={classes.loadingContent}>
<span className={classes.spinner}/>
{children}
{process && `(${process}%)`}
</div>
) : children}
{/* 显示进度条 */}
{process !== undefined && (
<div className={classes.progressBar}>
<div
className={classes.progressFill}
style={{width: `${Math.min(Math.max(process, 0), 100)}%`}}
/>
</div>
)}
</button>
);
style?: React.CSSProperties | undefined;
};
export const UButton: React.FC<Props> = ({onClick, children, style,disabled = false}) => {
const classes= useStyle()
return <button style={style} className={classes.ubutton} disabled={disabled} onClick={onClick}>
{children}
</button>
}
-137
View File
@@ -1,137 +0,0 @@
import React, { useEffect, useRef, ReactNode } from 'react';
import {createUseStyles} from "react-jss";
const useClass = createUseStyles({
dialog: {
border: "none",
borderRadius: "8px",
padding: "2rem 3rem",
boxShadow: "0 4px 12px rgba(0, 0, 0, 0.15)",
maxWidth: "90%",
width: "500px",
opacity: 0,
transform: "translateY(-20px)",
transition: "opacity 0.3s ease-out, transform 0.3s ease-out",
"&[open]": { opacity: 1, transform: "translateY(0)" },
"&::backdrop": {
background: "rgba(0, 0, 0, 0.5)",
backdropFilter: "blur(2px)"
},
background: "rgba(212,212,212,0.85)",
backdropFilter: "blur(8px)",
},
dialog_content: {
padding: "1.5rem",
display: "flex",
flexDirection: "column"
},
dialog_header: {
fontSize: "1.5rem",
fontWeight: 600,
marginBottom: "1rem",
paddingBottom: "0.5rem",
borderBottom: "1px solid #eee"
},
dialog_body: { marginBottom: "1.5rem" },
dialog_footer: { display: "flex", justifyContent: "flex-end" },
close_button: {
padding: "8px 16px",
background: "#007aff",
color: "white",
border: "none",
borderRadius: "4px",
cursor: "pointer",
fontSize: "1rem",
"&:hover": { background: "#0062cc" }
},
})
export interface DialogProps {
/** 对话框是否打开 */
open: boolean;
/** 对话框标题 */
title?: string;
/** 对话框内容 */
children: ReactNode;
/** 关闭对话框时的回调 */
onClose: () => void;
/** 自定义样式类名 */
className?: string;
/** 是否显示底部footer(关闭按钮) */
footer?: boolean;
}
/**
* 使用 HTML 原生 dialog 元素的模态对话框组件
*/
export const Dialog: React.FC<DialogProps> = ({
open,
title,
children,
onClose,
className = '',
footer = true,
}) => {
const classes = useClass();
const dialogRef = useRef<HTMLDivElement>(null);
// ESC 键关闭
useEffect(() => {
if (!open) return;
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
onClose();
}
};
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [open, onClose]);
// 遮罩点击关闭
const handleMaskClick = (e: React.MouseEvent<HTMLDivElement>) => {
if (e.target === dialogRef.current) {
onClose();
}
};
if (!open) return null;
return (
<div
ref={dialogRef}
className={className}
style={{
position: 'fixed',
zIndex: 1100,
left: 0,
top: 0,
width: '100vw',
height: '100vh',
background: 'rgba(0,0,0,0.5)',
backdropFilter: 'blur(2px)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
onClick={handleMaskClick}
>
<article className={classes.dialog} style={{opacity: 1, transform: 'translateY(0)'}}>
{title && <header className={classes.dialog_header}>{title}</header>}
<div className={classes.dialog_body}>
{children}
</div>
{footer !== false && (
<footer className={classes.dialog_footer}>
<button
className={classes.close_button}
onClick={onClose}
aria-label="关闭对话框"
>
关闭
</button>
</footer>
)}
</article>
</div>
);
};
-84
View File
@@ -1,84 +0,0 @@
import React, {useEffect} from 'react';
import {createUseStyles} from "react-jss"; // 使用 CSS Modules
const useClass = createUseStyles({
backdrop: {
position: "fixed",
top: "0",
left: "0",
width: "100%",
height: "100%",
maxHeight: '100%',
backgroundColor: "rgba(0, 0, 0, 0.5)",
display: "flex",
justifyContent: "center",
alignItems: "flex-end",
opacity: 0,
transition: "opacity 0.3s ease-in-out",
pointerEvents: "none",
zIndex: 1000,
overflow: 'hidden',
},
visible: {opacity: 1, pointerEvents: "auto"},
drawer_content: {
background: "white",
borderRadius: "8px 8px 0 0",
transition: "transform 0.3s ease-in-out",
overflow: "auto",
boxShadow: "0 -2px 8px rgba(0, 0, 0, 0.1)"
}
})
export interface DrawerProps {
isOpen: boolean;
close: () => void;
onClose?: () => void;
height?: string;
width?: string;
children?: React.ReactNode;
}
export const Drawer: React.FC<DrawerProps> = ({
isOpen,
close,
onClose,
height = '300px',
width = '100%',
children
}) => {
const classes = useClass();
useEffect(() => {
const handleEscape = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
close()
}
};
if (isOpen) {
document.addEventListener('keydown', handleEscape);
document.body.style.overflow = 'hidden';
} else {
document.body.style.overflow = 'auto';
}
}, [isOpen, onClose]);
return (
<div
className={`${classes.backdrop} ${isOpen ? classes.visible : ''}`}
onClick={onClose}
role="dialog"
aria-modal="true"
>
<div
className={classes.drawer_content}
style={{
height,
width,
transform: isOpen ? 'translateY(0)' : 'translateY(100%)'
}}
onClick={(e) => e.stopPropagation()}
>
{children}
</div>
</div>
);
};
-159
View File
@@ -1,159 +0,0 @@
import { useRef, useEffect } from 'react';
export const CloudBackground = () => {
const canvasRef = useRef<HTMLCanvasElement>(null);
// 完整可配置参数
const config = {
cloudNum: 8, // 云朵数量
maxSpeed: 3.0, // 最大水平速度
cloudSize: 100, // 基础云朵尺寸 (新增)
sizeVariation: 0.5, // 尺寸随机变化率 (0-1)
colorVariation: 20, // 色相变化范围
verticalOscillation: 0.5, // 垂直浮动幅度
shapeComplexity: 5, // 形状复杂度(组成圆形数量)
boundaryOffset: 3 // 边界偏移倍数
};
type Cloud = {
x: number;
y: number;
speed: number;
circles: CloudCircle[];
color: string;
maxRadius: number; // 记录云朵最大半径
};
type CloudCircle = {
offsetX: number;
offsetY: number;
radius: number;
};
useEffect(() => {
const canvas = canvasRef.current!;
const ctx = canvas.getContext('2d')!;
const resize = () => {
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;
};
resize();
// 生成云朵形状(基于配置参数)
const createCloudShape = () => {
const circles: CloudCircle[] = [];
const circleCount = 4 + Math.floor(Math.random() * config.shapeComplexity);
for(let i = 0; i < circleCount; i++) {
circles.push({
offsetX: (Math.random() - 0.5) * config.cloudSize * 1.5,
offsetY: (Math.random() - 0.5) * config.cloudSize * 0.8,
radius: config.cloudSize * (1 - config.sizeVariation + Math.random() * config.sizeVariation)
});
}
return circles;
};
let clouds: Cloud[] = [];
const createClouds = () => {
clouds = Array.from({ length: config.cloudNum }).map(() => {
const shape = createCloudShape();
return {
x: Math.random() * canvas.width,
y: canvas.height * (0.2 + Math.random() * 0.6),
speed: (Math.random() * 0.5 + 0.5) * config.maxSpeed,
circles: shape,
color: `hsla(210, 30%, 95%, ${0.8 + Math.random() * 0.2})`,
maxRadius: Math.max(...shape.map(c => c.radius)) // 计算最大半径
};
});
};
const drawCloud = (cloud: Cloud) => {
ctx.save();
ctx.beginPath();
cloud.circles.forEach(circle => {
ctx.moveTo(cloud.x + circle.offsetX, cloud.y + circle.offsetY);
ctx.arc(
cloud.x + circle.offsetX,
cloud.y + circle.offsetY,
circle.radius,
0,
Math.PI * 2
);
});
const gradient = ctx.createRadialGradient(
cloud.x, cloud.y, 0,
cloud.x, cloud.y, config.cloudSize * 2
);
gradient.addColorStop(0, cloud.color);
gradient.addColorStop(1, `hsla(210, 50%, 98%, 0.3)`);
ctx.fillStyle = gradient;
ctx.filter = `blur(${config.cloudSize * 0.2}px)`; // 模糊与尺寸关联
ctx.fill();
ctx.restore();
};
let animationFrameId: number;
const animate = () => {
ctx.clearRect(0, 0, canvas.width, canvas.height);
// 天空渐变背景
const skyGradient = ctx.createLinearGradient(0, 0, 0, canvas.height);
skyGradient.addColorStop(0, '#e6f3ff');
skyGradient.addColorStop(1, '#d1e8ff');
ctx.fillStyle = skyGradient;
ctx.fillRect(0, 0, canvas.width, canvas.height);
clouds.forEach(cloud => {
cloud.x += cloud.speed;
cloud.y += Math.sin(Date.now() / 1000 + cloud.x) * config.verticalOscillation;
// 基于实际最大半径的边界检测
const resetPosition = cloud.x > canvas.width + (cloud.maxRadius * config.boundaryOffset);
if (resetPosition) {
cloud.x = -cloud.maxRadius * config.boundaryOffset;
// 重置时重新生成形状
const newShape = createCloudShape();
cloud.circles = newShape;
cloud.maxRadius = Math.max(...newShape.map(c => c.radius));
}
drawCloud(cloud);
});
animationFrameId = requestAnimationFrame(animate);
};
createClouds();
animate();
window.addEventListener('resize', () => {
resize();
createClouds();
});
return () => {
window.removeEventListener('resize', resize);
cancelAnimationFrame(animationFrameId);
};
}, []);
return (
<canvas
ref={canvasRef}
style={{
position: 'fixed',
top: 0,
left: 0,
zIndex: -1,
width: '100%',
height: '100%'
}}
/>
);
};
-124
View File
@@ -1,124 +0,0 @@
import { useRef, useEffect } from 'react';
export const AnimatedBackground = () => {
const canvasRef = useRef<HTMLCanvasElement>(null);
// 粒子配置
const config = {
particleNum: 100,
maxSpeed: 1.5,
particleRadius: 2,
lineWidth: 1.5,
lineDistance: 100
};
type Particle = {
x: number;
y: number;
speedX: number;
speedY: number;
color: string;
radius: number;
};
useEffect(() => {
const canvas = canvasRef.current!;
const ctx = canvas.getContext('2d')!;
// 设置canvas尺寸
const resize = () => {
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;
};
resize();
// 创建粒子数组
let particles: Particle[] = [];
const createParticles = () => {
particles = Array.from({ length: config.particleNum }).map(() => ({
x: Math.random() * canvas.width,
y: Math.random() * canvas.height,
speedX: (Math.random() - 0.5) * config.maxSpeed,
speedY: (Math.random() - 0.5) * config.maxSpeed,
color: `hsl(${Math.random() * 360}, 70%, 60%)`,
radius: Math.random() * config.particleRadius + 1
}));
};
// 绘制连线
const drawLine = (p1: Particle, p2: Particle) => {
const dx = p1.x - p2.x;
const dy = p1.y - p2.y;
const dist = Math.sqrt(dx * dx + dy * dy);
if (dist < config.lineDistance) {
ctx.beginPath();
ctx.strokeStyle = p1.color;
ctx.lineWidth = config.lineWidth * (1 - dist / config.lineDistance);
ctx.moveTo(p1.x, p1.y);
ctx.lineTo(p2.x, p2.y);
ctx.stroke();
}
};
// 动画循环
let animationFrameId: number;
const animate = () => {
ctx.clearRect(0, 0, canvas.width, canvas.height);
// 更新粒子位置
particles.forEach(particle => {
particle.x += particle.speedX;
particle.y += particle.speedY;
// 边界反弹
if (particle.x < 0 || particle.x > canvas.width) particle.speedX *= -1;
if (particle.y < 0 || particle.y > canvas.height) particle.speedY *= -1;
// 绘制粒子
ctx.beginPath();
ctx.fillStyle = particle.color;
ctx.arc(particle.x, particle.y, particle.radius, 0, Math.PI * 2);
ctx.fill();
});
// 绘制粒子间的连线
for (let i = 0; i < particles.length; i++) {
for (let j = i + 1; j < particles.length; j++) {
drawLine(particles[i], particles[j]);
}
}
animationFrameId = requestAnimationFrame(animate);
};
// 初始化
createParticles();
animate();
// 窗口resize处理
window.addEventListener('resize', () => {
resize();
createParticles();
});
// 清理
return () => {
window.removeEventListener('resize', resize);
cancelAnimationFrame(animationFrameId);
};
}, []);
return (
<canvas
ref={canvasRef}
style={{
position: 'fixed',
top: 0,
left: 0,
zIndex: -1,
backgroundColor: '#1a1a1a'
}}
/>
);
};
@@ -0,0 +1,84 @@
// MessageContext.tsx
import React, { createContext, useContext, useState, useCallback, useRef } from 'react';
import ReactDOM from 'react-dom';
import './Message.css';
export interface Message {
id: number;
content: string;
type: 'info' | 'success' | 'warning' | 'error';
}
export type MessageType = Message['type'];
const MessageContext = createContext<{
addMessage: (content: string, type?: MessageType, duration?: number) => void;
removeMessage: (id: number) => void;
}>({
addMessage: () => {},
removeMessage: () => {},
});
export const MessageProvider: React.FC = ({ children }) => {
const [messages, setMessages] = useState<Message[]>([]);
const timerRef = useRef<Record<string, NodeJS.Timeout>>({});
const addMessage = useCallback((content: string, type: MessageType = 'info', duration: number = 3000) => {
const id = Date.now();
setMessages(prev => [...prev, { id, content, type }]);
timerRef.current[id] = setTimeout(() => {
removeMessage(id);
}, duration);
}, []);
const removeMessage = useCallback((id: number) => {
setMessages(prev => prev.filter(msg => msg.id !== id));
clearTimeout(timerRef.current[id]);
delete timerRef.current[id];
}, []);
return (
<MessageContext.Provider value={{ addMessage, removeMessage }}>
{children}
{ReactDOM.createPortal(
<div className="message-container">
{messages.map(({ id, content, type }) => (
<MessageItem
key={id}
id={id}
content={content}
type={type}
onClose={() => removeMessage(id)}
/>
))}
</div>,
document.body
)}
</MessageContext.Provider>
);
};
interface MessageItemProps {
id: number;
content: string;
type: MessageType;
onClose: () => void;
}
const MessageItem: React.FC<MessageItemProps> = ({ id, content, type, onClose }) => {
return (
<div className={`message-item ${type}`}>
{content}
<button onClick={onClose}>×</button>
</div>
);
};
export const useMessage = (): ReturnType<typeof MessageContext> => {
const context = useContext(MessageContext);
if (!context) {
throw new Error('useMessage must be used within a MessageProvider');
}
return context;
};
-126
View File
@@ -1,126 +0,0 @@
import {createRoot} from "react-dom/client";
import {useState} from "react";
import {createUseStyles} from "react-jss";
const useStyle = createUseStyles({
container: {
position: 'fixed',
width: '100%',
display: 'flex',
zIndex: 10000,
top: '20px',
justifyContent: "center",
alignItems: "center",
flexDirection: "column",
},
message: {
width: '100%',
maxWidth: '300px',
background: '#fafafa', // 浅灰背景与其他状态统一
borderRadius: '8px',
padding: '10px 10px 10px 20px', // 统一左侧20px留白
borderLeft: '4px solid #e0e0e0', // 加粗为4px与其他状态一致
color: '#757575', // 中性灰文字
boxShadow: '0 2px 6px rgba(224, 224, 224, 0.15)', // 灰色投影
transition: 'all 0.3s ease-in-out', // 补全时间单位
marginBottom: '20px',
'&.success': {
color: '#2e7d32',
backgroundColor: '#f0f9eb',
borderLeft: '4px solid #4CAF50',
paddingLeft: '20px',
boxShadow: '0 2px 6px rgba(76, 175, 80, 0.15)'
},
'&.warning': {
color: '#faad14', // 警告文字色
backgroundColor: '#fffbe6', // 浅黄色背景
borderLeft: '4px solid #ffc53d', // 琥珀色左侧标识
paddingLeft: '20px',
boxShadow: '0 2px 6px rgba(255, 197, 61, 0.15)' // 金色投影
},
'&.error': {
color: '#f5222d', // 错误文字色
backgroundColor: '#fff1f0', // 浅红色背景
borderLeft: '4px solid #ff4d4f', // 品红色左侧标识
paddingLeft: '20px',
boxShadow: '0 2px 6px rgba(255, 77, 79, 0.15)' // 红色投影
}
}
})
let el = document.querySelector("#u-message")
if (!el) {
el = document.createElement('div')
el.className = 'u-message'
el.id = 'u-message'
document.body.append(el)
}
export interface Message {
id: number
content: string
duration: number
type: 'info' | 'success' | 'warning' | 'error'
}
export interface MessageApi {
info: (content: string, duration?: number) => void;
warning: (content: string, duration?: number) => void;
success: (content: string, duration?: number) => void;
error: (content: string, duration?: number) => void;
}
const default_duration = 3000
let add: (msg: Message) => void;
const MessageContainer: React.FC = () => {
const classes = useStyle()
const [msgs, setMsgs] = useState<Message[]>([]);
const remove = (id: number) => {
setMsgs(prevMsgs => prevMsgs.filter(v => v.id !== id));
}
add = (msg: Message) => {
const id = Date.now();
setMsgs(prevMsgs => {
const newMsgs = [{...msg, id}, ...prevMsgs];
// 直接限制数组长度为5,移除最旧的消息(最后一项)
if (newMsgs.length > 5) {
newMsgs.pop();
}
return newMsgs;
});
setTimeout(() => {
remove(id);
}, msg.duration ?? default_duration);
}
return <div className={classes.container}>
{msgs.map(m => <div key={m.id} className={`${classes.message} ${m.type}`}>{m.content}</div>)}
</div>
}
createRoot(el).render(<MessageContainer/>)
export const message: MessageApi = {
info: function (content: string, duration?: number): void {
add({content: content, duration: duration, type: "info"} as Message)
},
warning: function (content: string, duration?: number): void {
add({content: content, duration: duration, type: "warning"} as Message)
},
success: function (content: string, duration?: number): void {
add({content: content, duration: duration, type: "success"} as Message)
},
error: function (content: string, duration?: number): void {
add({content: content, duration: duration, type: "error"} as Message)
}
}
-77
View File
@@ -1,77 +0,0 @@
import { useCallback, useRef } from 'react';
export interface Prop {
/** 事件处理函数(可选) */
fn?: (event: MessageEvent) => Promise<void>;
/** 最大重试次数(可选) */
retry?: number;
}
export const useWebsocket = (prop?: Prop) => {
const wsRef = useRef<WebSocket | null>(null);
const retryCountRef = useRef(0);
const reconnectTimerRef = useRef<number>(0);
const currentPropRef = useRef(prop);
// 更新最新 prop
currentPropRef.current = prop;
const connect = useCallback((url: string, connectProp?: Prop) => {
// 合并 prop 优先级:connectProp > hook prop
const mergedProp = { ...currentPropRef.current, ...connectProp };
// 清理现有连接
if (wsRef.current) {
wsRef.current.close();
wsRef.current = null;
}
clearTimeout(reconnectTimerRef.current);
const createConnection = () => {
const ws = new WebSocket(url);
ws.onopen = () => {
retryCountRef.current = 0;
};
ws.onmessage = (event) => {
mergedProp?.fn?.(event).catch(error => {
console.error('WebSocket message handler error:', error);
});
};
ws.onclose = (event) => {
const maxRetries = mergedProp?.retry ?? 0;
if (!event.wasClean && retryCountRef.current < maxRetries) {
retryCountRef.current += 1;
const retryDelay = Math.pow(2, retryCountRef.current) * 1000;
reconnectTimerRef.current = setTimeout(() => {
createConnection();
}, retryDelay);
}
};
ws.onerror = (error) => {
console.error('WebSocket error:', error);
ws.close();
};
wsRef.current = ws;
};
createConnection();
}, []);
const close = useCallback(() => {
wsRef.current?.close();
clearTimeout(reconnectTimerRef.current);
retryCountRef.current = currentPropRef.current?.retry || 0;
}, []);
return {
connect,
close
};
};
-6
View File
@@ -1,6 +0,0 @@
export interface Resp<T>{
status: number;
msg: string;
err: string;
data: T;
}
+6 -23
View File
@@ -1,27 +1,10 @@
// import { StrictMode } from 'react'
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import {createBrowserRouter, RouterProvider} from "react-router-dom";
import {Login} from "./page/login.tsx";
import {FileSharing} from "./page/share/share.tsx";
import {LocalSharing} from "./page/local/local.tsx";
import {TestPage} from "./page/test/test.tsx";
import {AdminPage} from "./page/admin/admin.tsx";
import {SelfPage} from "./page/self/self.tsx";
import {App} from './App.tsx'
const container = document.getElementById('root')
const root = createRoot(container!)
const router = createBrowserRouter([
{path: "/login", element: <Login />},
{path: "/share", element: <FileSharing />},
{path: "/admin", element: <AdminPage />},
{path: "/self", element: <SelfPage />},
{path: "/test", element: <TestPage />},
{path: "*", element: <LocalSharing />},
])
root.render(
// <StrictMode>
<RouterProvider router={router} />
// </StrictMode>,
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
)
-533
View File
@@ -1,533 +0,0 @@
import React, {useEffect, useState} from 'react';
import {createUseStyles} from 'react-jss';
import {adminApi, AdminUser, Role, UpdateUserReq} from '../../api/admin.ts';
import {message} from '../../hook/message/u-message.tsx';
import {UButton} from '../../component/button/u-button.tsx';
const PERM_LABELS: Record<string, string> = {
user_manage: '用户管理',
upload: '上传文件',
token_manage: 'Token管理',
};
const useStyle = createUseStyles({
container: {
minHeight: '100vh',
backgroundColor: '#e3f2fd',
padding: '24px',
boxSizing: 'border-box',
fontFamily: "'Segoe UI', Arial, sans-serif",
},
header: {
display: 'flex',
alignItems: 'center',
gap: '16px',
marginBottom: '24px',
},
backBtn: {
background: 'transparent',
border: '2px solid #2c9678',
color: '#2c9678',
borderRadius: '6px',
padding: '6px 14px',
cursor: 'pointer',
fontSize: '14px',
transition: 'background-color 0.2s',
'&:hover': {backgroundColor: 'rgba(44,150,120,0.1)'},
},
title: {
color: '#2c9678',
margin: 0,
fontSize: '22px',
fontWeight: 600,
},
card: {
backgroundColor: '#C8E6C9',
boxShadow: 'inset 0 0 15px rgba(56, 142, 60, 0.15)',
borderRadius: '15px',
padding: '24px',
marginBottom: '24px',
},
cardHeader: {
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: '16px',
},
cardTitle: {
color: '#2c9678',
margin: 0,
fontSize: '16px',
fontWeight: 600,
},
table: {
width: '100%',
borderCollapse: 'collapse',
backgroundColor: 'rgba(255,255,255,0.7)',
borderRadius: '8px',
overflow: 'hidden',
},
th: {
backgroundColor: 'rgba(44,150,120,0.15)',
color: '#2c9678',
padding: '10px 14px',
textAlign: 'left',
fontSize: '13px',
fontWeight: 600,
},
td: {
padding: '10px 14px',
borderBottom: '1px solid rgba(44,150,120,0.1)',
fontSize: '14px',
color: '#333',
},
badgeActive: {
display: 'inline-block',
padding: '2px 10px',
borderRadius: '12px',
fontSize: '12px',
fontWeight: 500,
backgroundColor: '#c8e6c9',
color: '#2e7d32',
},
badgeInactive: {
display: 'inline-block',
padding: '2px 10px',
borderRadius: '12px',
fontSize: '12px',
fontWeight: 500,
backgroundColor: '#ffcdd2',
color: '#c62828',
},
actionBtn: {
background: 'transparent',
border: '1px solid #2c9678',
color: '#2c9678',
borderRadius: '4px',
padding: '4px 10px',
cursor: 'pointer',
fontSize: '12px',
marginRight: '6px',
transition: 'background-color 0.2s',
'&:hover': {backgroundColor: 'rgba(44,150,120,0.1)'},
},
deleteBtn: {
background: 'transparent',
border: '1px solid #c62828',
color: '#c62828',
borderRadius: '4px',
padding: '4px 10px',
cursor: 'pointer',
fontSize: '12px',
transition: 'background-color 0.2s',
'&:hover': {backgroundColor: 'rgba(198,40,40,0.08)'},
},
permTag: {
display: 'inline-block',
backgroundColor: 'rgba(44,150,120,0.15)',
color: '#2c9678',
borderRadius: '10px',
padding: '2px 8px',
fontSize: '12px',
marginRight: '4px',
marginBottom: '2px',
},
overlay: {
position: 'fixed',
top: 0, left: 0, right: 0, bottom: 0,
backgroundColor: 'rgba(0,0,0,0.4)',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
zIndex: 1000,
},
dialog: {
backgroundColor: '#C8E6C9',
boxShadow: '0 8px 32px rgba(44,150,120,0.2)',
borderRadius: '15px',
padding: '28px',
width: '400px',
maxWidth: '90vw',
boxSizing: 'border-box',
},
dialogTitle: {
color: '#2c9678',
margin: '0 0 20px',
fontSize: '16px',
fontWeight: 600,
},
formRow: {
marginBottom: '14px',
},
label: {
display: 'block',
color: '#2c9678',
fontSize: '13px',
marginBottom: '5px',
fontWeight: 500,
},
input: {
width: '100%',
padding: '9px 11px',
border: '2px solid #ddd',
borderRadius: '5px',
fontSize: '14px',
boxSizing: 'border-box',
background: 'rgba(255,255,255,0.8)',
transition: 'border-color 0.2s',
'&:focus': {outline: 'none', borderColor: '#2c9678'},
},
select: {
width: '100%',
padding: '9px 11px',
border: '2px solid #ddd',
borderRadius: '5px',
fontSize: '14px',
boxSizing: 'border-box',
background: 'rgba(255,255,255,0.8)',
transition: 'border-color 0.2s',
'&:focus': {outline: 'none', borderColor: '#2c9678'},
},
checkboxRow: {
display: 'flex',
alignItems: 'center',
gap: '8px',
},
dialogActions: {
display: 'flex',
gap: '10px',
justifyContent: 'flex-end',
marginTop: '20px',
},
cancelBtn: {
background: 'transparent',
border: '2px solid #aaa',
color: '#666',
borderRadius: '5px',
padding: '8px 18px',
cursor: 'pointer',
fontSize: '14px',
transition: 'border-color 0.2s',
'&:hover': {borderColor: '#888'},
},
dangerBtn: {
backgroundColor: '#c62828',
color: 'white',
border: 'none',
borderRadius: '5px',
padding: '8px 18px',
cursor: 'pointer',
fontSize: '14px',
transition: 'background-color 0.2s',
'&:hover': {backgroundColor: '#b71c1c'},
},
emptyTip: {
textAlign: 'center',
color: '#999',
padding: '24px',
fontSize: '14px',
},
});
type DialogMode = 'create' | 'edit';
interface DialogState {
open: boolean;
mode: DialogMode;
user?: AdminUser;
username: string;
password: string;
roleId: number;
active: boolean;
}
const emptyDialog = (defaultRoleId = 0): DialogState => ({
open: false,
mode: 'create',
username: '',
password: '',
roleId: defaultRoleId,
active: true,
});
export const AdminPage: React.FC = () => {
const classes = useStyle();
const [users, setUsers] = useState<AdminUser[]>([]);
const [roles, setRoles] = useState<Role[]>([]);
const [loading, setLoading] = useState(true);
const [dialog, setDialog] = useState<DialogState>(emptyDialog());
const [saving, setSaving] = useState(false);
const [deleteTarget, setDeleteTarget] = useState<AdminUser | null>(null);
const loadData = async () => {
try {
const [u, r] = await Promise.all([adminApi.listUsers(), adminApi.listRoles()]);
setUsers(u ?? []);
setRoles(r ?? []);
} catch (e: unknown) {
message.error(e instanceof Error ? e.message : '加载失败');
} finally {
setLoading(false);
}
};
useEffect(() => {
fetch('/api/uauth/me').then(res => {
if (res.status === 401) {
window.location.href = '/login?next=/admin';
} else if (res.status === 403) {
message.error('权限不足');
} else {
loadData();
}
}).catch(() => {
window.location.href = '/login?next=/admin';
});
}, []);
const openCreate = () => {
const defaultRoleId = roles.find(r => r.name === 'user')?.id ?? roles[0]?.id ?? 0;
setDialog({...emptyDialog(defaultRoleId), open: true, mode: 'create'});
};
const openEdit = (user: AdminUser) => {
setDialog({
open: true,
mode: 'edit',
user,
username: user.username,
password: '',
roleId: user.role_id,
active: user.active,
});
};
const closeDialog = () => setDialog(emptyDialog());
const handleSave = async () => {
if (dialog.mode === 'create') {
if (!dialog.username.trim()) return message.warning('请输入用户名');
if (!dialog.password) return message.warning('请输入密码');
if (!dialog.roleId) return message.warning('请选择角色');
}
setSaving(true);
try {
if (dialog.mode === 'create') {
await adminApi.createUser({
username: dialog.username.trim(),
password: dialog.password,
role_id: dialog.roleId,
});
message.success('创建成功');
} else if (dialog.user) {
const req: UpdateUserReq = {};
if (dialog.roleId !== dialog.user.role_id) req.role_id = dialog.roleId;
if (dialog.active !== dialog.user.active) req.active = dialog.active;
if (dialog.password) req.password = dialog.password;
await adminApi.updateUser(dialog.user.id, req);
message.success('更新成功');
}
closeDialog();
await loadData();
} catch (e: unknown) {
message.error(e instanceof Error ? e.message : '操作失败');
} finally {
setSaving(false);
}
};
const handleDelete = async () => {
if (!deleteTarget) return;
try {
await adminApi.deleteUser(deleteTarget.id);
message.success('删除成功');
setDeleteTarget(null);
await loadData();
} catch (e: unknown) {
message.error(e instanceof Error ? e.message : '删除失败');
}
};
return (
<div className={classes.container}>
<div className={classes.header}>
<button className={classes.backBtn} onClick={() => window.location.href = '/share'}>
← 返回
</button>
<h2 className={classes.title}>管理控制台</h2>
</div>
{/* Users */}
<div className={classes.card}>
<div className={classes.cardHeader}>
<h3 className={classes.cardTitle}>用户管理</h3>
<UButton onClick={openCreate}>添加用户</UButton>
</div>
{loading ? (
<div className={classes.emptyTip}>加载中...</div>
) : (
<table className={classes.table}>
<thead>
<tr>
<th className={classes.th}>ID</th>
<th className={classes.th}>用户名</th>
<th className={classes.th}>角色</th>
<th className={classes.th}>状态</th>
<th className={classes.th}>创建时间</th>
<th className={classes.th}>操作</th>
</tr>
</thead>
<tbody>
{users.length === 0 ? (
<tr>
<td className={classes.td} colSpan={6}>
<div className={classes.emptyTip}>暂无用户</div>
</td>
</tr>
) : users.map(u => (
<tr key={u.id}>
<td className={classes.td}>{u.id}</td>
<td className={classes.td}>{u.username}</td>
<td className={classes.td}>{u.role?.label ?? '-'}</td>
<td className={classes.td}>
<span className={u.active ? classes.badgeActive : classes.badgeInactive}>
{u.active ? '启用' : '禁用'}
</span>
</td>
<td className={classes.td}>
{new Date(u.created_at).toLocaleDateString('zh-CN')}
</td>
<td className={classes.td}>
<button className={classes.actionBtn} onClick={() => openEdit(u)}>编辑</button>
<button className={classes.deleteBtn} onClick={() => setDeleteTarget(u)}>删除</button>
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
{/* Roles */}
<div className={classes.card}>
<div className={classes.cardHeader}>
<h3 className={classes.cardTitle}>角色权限</h3>
</div>
<table className={classes.table}>
<thead>
<tr>
<th className={classes.th}>角色</th>
<th className={classes.th}>权限</th>
</tr>
</thead>
<tbody>
{roles.map(r => (
<tr key={r.id}>
<td className={classes.td}>{r.label}</td>
<td className={classes.td}>
{r.permissions.split(',')
.map(p => p.trim())
.filter(Boolean)
.map(p => (
<span key={p} className={classes.permTag}>
{PERM_LABELS[p] ?? p}
</span>
))}
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Create / Edit Dialog */}
{dialog.open && (
<div className={classes.overlay} onClick={e => e.target === e.currentTarget && closeDialog()}>
<div className={classes.dialog}>
<h3 className={classes.dialogTitle}>
{dialog.mode === 'create' ? '添加用户' : `编辑用户: ${dialog.user?.username}`}
</h3>
{dialog.mode === 'create' && (
<div className={classes.formRow}>
<label className={classes.label}>用户名</label>
<input
className={classes.input}
placeholder="请输入用户名"
value={dialog.username}
onChange={e => setDialog(d => ({...d, username: e.target.value}))}
/>
</div>
)}
<div className={classes.formRow}>
<label className={classes.label}>
{dialog.mode === 'create' ? '密码' : '新密码(留空则不修改)'}
</label>
<input
className={classes.input}
type="password"
placeholder={dialog.mode === 'create' ? '请输入密码' : '留空不修改'}
value={dialog.password}
onChange={e => setDialog(d => ({...d, password: e.target.value}))}
/>
</div>
<div className={classes.formRow}>
<label className={classes.label}>角色</label>
<select
className={classes.select}
value={dialog.roleId}
onChange={e => setDialog(d => ({...d, roleId: Number(e.target.value)}))}
>
{roles.map(r => (
<option key={r.id} value={r.id}>{r.label}</option>
))}
</select>
</div>
{dialog.mode === 'edit' && (
<div className={classes.formRow}>
<label className={classes.label}>状态</label>
<div className={classes.checkboxRow}>
<input
type="checkbox"
id="active-check"
checked={dialog.active}
onChange={e => setDialog(d => ({...d, active: e.target.checked}))}
/>
<label htmlFor="active-check" style={{color: '#555', fontSize: '14px', cursor: 'pointer'}}>
{dialog.active ? '启用' : '禁用'}
</label>
</div>
</div>
)}
<div className={classes.dialogActions}>
<button className={classes.cancelBtn} onClick={closeDialog}>取消</button>
<UButton onClick={handleSave} loading={saving}>
{dialog.mode === 'create' ? '创建' : '保存'}
</UButton>
</div>
</div>
</div>
)}
{/* Delete Confirm Dialog */}
{deleteTarget && (
<div className={classes.overlay} onClick={e => e.target === e.currentTarget && setDeleteTarget(null)}>
<div className={classes.dialog}>
<h3 className={classes.dialogTitle}>确认删除</h3>
<p style={{color: '#555', marginTop: 0, marginBottom: '20px', fontSize: '14px'}}>
确定要删除用户 <strong>{deleteTarget.username}</strong> 吗?此操作不可撤销。
</p>
<div className={classes.dialogActions}>
<button className={classes.cancelBtn} onClick={() => setDeleteTarget(null)}>取消</button>
<button className={classes.dangerBtn} onClick={handleDelete}>确认删除</button>
</div>
</div>
</div>
)}
</div>
);
};
@@ -0,0 +1,85 @@
import {createUseStyles} from "react-jss";
import {UButton} from "../../component/button/u-button.tsx";
import React from "react";
import {useStore} from "../../store/share.ts";
const useStyle = createUseStyles({
container: {
backgroundColor: "#e3f2fd",
display: "flex",
justifyContent: "center",
alignItems: "center",
},
form: {
backgroundColor: "#C8E6C9",
boxShadow: "inset 0 0 15px rgba(56, 142, 60, 0.15)",
padding: "30px",
borderRadius: "15px",
width: "70%",
margin: "20px 60px 20px 0",
/*todo margin 不用 px*/
},
title: {
color: "#2c9678"
},
file: {
display: 'none',
},
preview: {
marginTop: '10px',
display: 'flex',
},
name: {
color: "#2c9678",
marginLeft: '10px'
},
clean: {
borderRadius: '50%',
cursor: 'pointer',
'&:hover': {}
}
})
export const PanelLeft = () => {
const style = useStyle()
const {file, setFile} = useStore()
function onFileSelect() {
// @ts-ignore
document.querySelector('#real-file-input').click();
}
function onFileChange(e: React.ChangeEvent<HTMLInputElement>) {
console.log('[D] onFileChange: e =', e)
setFile(e.currentTarget.files ? e.currentTarget.files[0] : null)
}
function onFileUpload() {
console.log(`[D] onFileUpload: upload file = ${file?.name}, size = ${file?.size}`, file)
}
function onFileClean() {
setFile(null)
}
return <div className={style.container}>
<div className={style.form}>
<h2 className={style.title}>上传文件</h2>
{
!file &&
<UButton onClick={onFileSelect}>选择文件</UButton>
}
{
file &&
<UButton onClick={onFileUpload}>上传文件</UButton>
}
<input type="file" className={style.file} id="real-file-input" onChange={onFileChange}/>
{
file &&
<div className={style.preview}>
<div className={style.clean} onClick={onFileClean}>×</div>
<div className={style.name}>{file.name}</div>
</div>
}
</div>
</div>
};
@@ -2,13 +2,8 @@ import {createUseStyles} from "react-jss";
const useStyle = createUseStyles({
container: {
backgroundColor: 'lightgray',
position: "relative",
overflow: "hidden",
"@media (max-width: 768px)": {
display: "none",
},
},
left: {
backgroundColor: "#e3f2fd",
@@ -1,6 +1,6 @@
import { createUseStyles } from "react-jss";
import { UButton } from "../../../component/button/u-button.tsx";
import { useStore } from "../../../store/share.ts";
import {createUseStyles} from "react-jss";
import {UButton} from "../../component/button/u-button.tsx";
import {useStore} from "../../store/share.ts";
const useStyle = createUseStyles({
container: {
@@ -8,12 +8,6 @@ const useStyle = createUseStyles({
display: "flex",
justifyContent: "center",
alignItems: "center",
minHeight: "50vh",
"@media (max-width: 768px)": {
minHeight: "auto",
padding: "20px 10px",
},
},
form: {
backgroundColor: "#BBDEFB",
@@ -22,65 +16,33 @@ const useStyle = createUseStyles({
borderRadius: "15px",
width: "70%",
margin: "20px 0 20px 60px",
"@media (max-width: 768px)": {
width: "90%",
margin: "20px 0",
padding: "20px",
},
/*todo margin 不用 px*/
},
title: {
color: '#1661ab',
"@media (max-width: 768px)": {
fontSize: "1.5rem",
marginBottom: "15px",
},
color: '#1661ab', // 靛青
},
code: {
padding: '11px',
margin: '0',
margin: '20px 0',
width: '200px',
border: '2px solid #ddd',
borderRadius: '5px',
'&:active': {
border: '2px solid #1661ab',
},
"@media (max-width: 768px)": {
width: '100%',
boxSizing: 'border-box',
padding: '10px',
fontSize: '14px',
},
},
inputContainer: {
display: 'flex',
gap: '10px',
"@media (max-width: 768px)": {
flexDirection: 'column',
alignItems: 'stretch',
},
},
buttonContainer: {
display: 'flex',
"@media (max-width: 768px)": {
justifyContent: 'left',
},
},
}
}
})
export const PanelRight = () => {
const style = useStyle()
const { code, setCode } = useStore()
const {code, setCode} = useStore()
function onCodeChange(e: React.ChangeEvent<HTMLInputElement>) {
setCode(e.currentTarget.value)
}
async function onFetchFile() {
const url = `/ushare/${code}`
const url = `/api/share/fetch?code=${code}`
console.log('[D] onFetchFile: url =', url)
const link = document.createElement('a');
link.href = url;
document.body.appendChild(link);
@@ -91,7 +53,7 @@ export const PanelRight = () => {
return <div className={style.container}>
<div className={style.form}>
<h2 className={style.title}>获取文件</h2>
<div className={style.inputContainer}>
<div>
<input
type="text"
className={style.code}
@@ -99,9 +61,7 @@ export const PanelRight = () => {
value={code}
onChange={onCodeChange}
/>
<div className={style.buttonContainer}>
<UButton onClick={onFetchFile}>获取文件</UButton>
</div>
<UButton style={{marginLeft: '10px'}} onClick={onFetchFile}>获取文件</UButton>
</div>
</div>
</div>
@@ -1,75 +0,0 @@
import {Bubble, Client} from "./types.ts";
// 生成随机颜色
export const generateColor = () => {
const hue = Math.random() * 360;
return `hsla(${hue},
${Math.random() * 30 + 40}%,
${Math.random() * 10 + 75}%, 0.9)`;
};
// 防碰撞位置生成
export const generateBubbles = (clients: Client[], currentUserId: string): Bubble[] => {
if (!clients) return [];
const BUBBLE_SIZE = 100;
const centerX = window.innerWidth / 2;
const centerY = window.innerHeight / 2;
const bubbles: Bubble[] = [];
let currentRadius = 0;
let angleStep = (2 * Math.PI) / 6; // 初始6个位置
for (let index = 0; index < clients.length; index++) {
let attempt = 0;
let validPosition = false;
if (clients[index].id === currentUserId) {
continue;
}
while (!validPosition && attempt < 100) {
// 螺旋布局算法
const angle = angleStep * (index + attempt);
const radius = currentRadius + (attempt * BUBBLE_SIZE * 0.8);
// 极坐标转笛卡尔坐标
const x = centerX + radius * Math.cos(angle);
const y = centerY + radius * Math.sin(angle);
// 边界检测
const inBounds = x >= 0 && x <= window.innerWidth - BUBBLE_SIZE &&
y >= 0 && y <= window.innerHeight - BUBBLE_SIZE;
// 碰撞检测
const collision = bubbles.some(pos => {
const distance = Math.sqrt(
Math.pow(pos.x - x, 2) +
Math.pow(pos.y - y, 2)
);
return distance < BUBBLE_SIZE * 1.5;
});
if (inBounds && !collision) {
bubbles.push({
id: clients[index].id,
name: clients[index].name,
x: x,
y: y,
color: generateColor(),
radius: 0,
angle: 0
});
// 动态调整布局参数
currentRadius = Math.max(currentRadius, radius);
angleStep = (2 * Math.PI) / Math.max(6, bubbles.length * 0.7);
validPosition = true;
}
attempt++;
}
}
return bubbles;
};
@@ -1,163 +0,0 @@
import React, {useState} from 'react';
import {Dialog} from "../../../component/dialog/dialog.tsx";
import {ReceivedMessage} from "./types.ts";
import {createUseStyles} from "react-jss";
const useStyles = createUseStyles({
root: {
marginBottom: '1rem',
},
sender: {
margin: '0 0 0.5rem 0',
fontSize: '0.9rem',
color: '#666',
},
msgBox: {
background: 'rgba(255,255,255,0.96)',
color: '#222',
padding: '1rem',
borderRadius: '4px',
border: '1px solid #ddd',
maxHeight: '400px',
minHeight: '80px',
overflowY: 'auto',
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
},
downloadLink: {
color: '#007aff',
textDecoration: 'underline',
},
btnRow: {
display: 'flex',
flexDirection: 'row',
gap: '0.5rem',
justifyContent: 'flex-end',
alignItems: 'center',
marginTop: '8px',
},
copySuccess: {
color: '#28a745',
fontSize: '0.9rem',
marginRight: '0.5rem',
animation: 'fadeIn 0.3s ease-in',
},
copyBtn: {
padding: '8px 16px',
background: '#007aff',
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: 'pointer',
fontSize: '0.9rem',
transition: 'all 0.2s',
'&:hover': {
background: '#0056b3',
},
},
copyBtnSuccess: {
background: '#28a745',
},
closeBtn: {
padding: '8px 16px',
background: '#6c757d',
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: 'pointer',
fontSize: '0.9rem',
transition: 'all 0.2s',
},
progressBar: {
width: '100%',
height: 8,
background: '#eee',
borderRadius: 4,
margin: '12px 0',
overflow: 'hidden',
},
progressInner: {
height: '100%',
background: '#4dabf7',
transition: 'width 0.3s',
},
});
interface MessageDialogProps {
open: boolean;
message: ReceivedMessage | null;
onClose: () => void;
}
export const MessageDialog: React.FC<MessageDialogProps> = ({open, message, onClose}) => {
const [copySuccess, setCopySuccess] = useState(false);
const classes = useStyles();
const handleCopyMessage = () => {
if (message) {
navigator.clipboard.writeText(message.text || '').then(() => {
setCopySuccess(true);
setTimeout(() => setCopySuccess(false), 2000);
}).catch(() => {
alert('复制失败,请手动复制');
});
}
};
return (
<Dialog
open={open}
title="收到新消息"
onClose={onClose}
footer={false}
>
<div className={classes.root}>
<p className={classes.sender}>
来自: {message?.sender}
</p>
<div className={classes.msgBox}>
{message?.isFile ? (
<>
<div>📎 文件: {message.fileName} ({message.fileSize ? (message.fileSize/1024).toFixed(1) : ''} KB)</div>
{message.receiving ? (
<>
<div style={{fontSize: '0.95em', color: '#888'}}>正在接收... {Math.round((message.progress||0)*100)}%</div>
<div className={classes.progressBar}>
<div className={classes.progressInner} style={{width: `${Math.round((message.progress||0)*100)}%`}} />
</div>
</>
) : (
<a href={message.fileBlobUrl} download={message.fileName} className={classes.downloadLink}>
点击下载
</a>
)}
</>
) : (
message?.text
)}
</div>
</div>
<div className={classes.btnRow}>
{copySuccess && (
<span className={classes.copySuccess}>
✓ 已复制
</span>
)}
{!message?.isFile && (
<button
onClick={handleCopyMessage}
className={copySuccess ? `${classes.copyBtn} ${classes.copyBtnSuccess}` : classes.copyBtn}
>
{copySuccess ? '已复制' : '复制消息'}
</button>
)}
<button
onClick={onClose}
className={classes.closeBtn}
>
关闭
</button>
</div>
</Dialog>
);
};
@@ -1,323 +0,0 @@
import { WSMessage, ReceivedMessage} from "./types.ts";
import {useLocalStore} from "../../../store/local.ts";
// 文件接收缓存
const fileReceiveCache: Record<string, {chunks: ArrayBuffer[], total: number, received: number, name: string, size: number, sender: string, timestamp: number}> = {};
export const handleFileChunk = (chunk: any, onFileReceived: (msg: import("./types").ReceivedMessage) => void) => {
if (chunk.type === 'file') {
// 文件元信息,初始化缓存
fileReceiveCache[chunk.name + '_' + chunk.timestamp] = {
chunks: new Array(chunk.totalChunks),
total: chunk.totalChunks,
received: 0,
name: chunk.name,
size: chunk.size,
sender: chunk.sender,
timestamp: chunk.timestamp
};
// 首次弹出进度
onFileReceived({
sender: chunk.sender,
timestamp: chunk.timestamp,
fileName: chunk.name,
fileSize: chunk.size,
isFile: true,
progress: 0,
receiving: true
});
} else if (chunk.type === 'file-chunk') {
const key = chunk.name + '_' + chunk.timestamp;
const cache = fileReceiveCache[key];
if (cache) {
// 将 data 数组还原为 ArrayBuffer
const uint8 = new Uint8Array(chunk.data);
cache.chunks[chunk.chunkIndex] = uint8.buffer;
cache.received++;
// 实时回调进度
if (cache.received < cache.total) {
onFileReceived({
sender: cache.sender,
timestamp: cache.timestamp,
fileName: cache.name,
fileSize: cache.size,
isFile: true,
progress: cache.received / cache.total,
receiving: true
});
}
}
} else if (chunk.type === 'file-end') {
const key = chunk.name + '_' + chunk.timestamp;
const cache = fileReceiveCache[key];
if (cache && cache.received === cache.total) {
const blob = new Blob(cache.chunks);
const url = URL.createObjectURL(blob);
onFileReceived({
sender: cache.sender,
timestamp: cache.timestamp,
fileName: cache.name,
fileSize: cache.size,
fileBlobUrl: url,
isFile: true,
progress: 1,
receiving: false
});
delete fileReceiveCache[key];
} else if (cache) {
// 分块未齐全,提示异常
onFileReceived({
sender: cache.sender,
timestamp: cache.timestamp,
fileName: cache.name,
fileSize: cache.size,
isFile: true,
progress: cache.received / cache.total,
receiving: true,
text: '文件分块未齐全,接收失败'
});
}
}
};
export interface RTCHandlerCallbacks {
onChannelOpen: (type: 'sender' | 'receiver') => void;
onMessageReceived: (message: ReceivedMessage) => void;
onChannelClose: () => void;
}
export class RTCHandler {
private rtcRef: React.MutableRefObject<RTCPeerConnection | null>;
private callbacks: RTCHandlerCallbacks;
constructor(rtcRef: React.MutableRefObject<RTCPeerConnection | null>, callbacks: RTCHandlerCallbacks) {
this.rtcRef = rtcRef;
this.callbacks = callbacks;
}
// 更新回调函数的方法
updateCallbacks = (newCallbacks: RTCHandlerCallbacks) => {
this.callbacks = newCallbacks;
};
setupDataChannel = async (ch: RTCDataChannel, type: 'sender' | 'receiver') => {
ch.onopen = () => {
console.log(`[D] 通道已打开!类型: ${type}`);
this.callbacks.onChannelOpen(type);
useLocalStore.getState().setChannel(ch);
};
ch.onmessage = (e) => {
// console.log('[D] Received message:', e.data);
try {
const data = JSON.parse(e.data);
if (data.type === 'message') {
// 处理文本消息
const message: ReceivedMessage = {
text: data.content,
timestamp: Date.now(),
sender: data.sender || '未知用户'
};
this.callbacks.onMessageReceived(message);
} else if (data.type === 'file' || data.type === 'file-chunk' || data.type === 'file-end') {
// 处理文件相关消息
handleFileChunk(data, this.callbacks.onMessageReceived);
}
} catch (error) {
// 如果不是JSON格式,当作普通文本处理
const message: ReceivedMessage = {
text: e.data,
timestamp: Date.now(),
sender: '未知用户'
};
this.callbacks.onMessageReceived(message);
}
};
ch.onclose = () => {
console.log('[D] 通道关闭');
this.callbacks.onChannelClose();
useLocalStore.getState().setChannel();
};
};
handleBubbleClick = async (bubbleId: string, currentUserId: string) => {
const current_rtc = this.rtcRef.current;
if (!current_rtc) return;
current_rtc.onnegotiationneeded = async () => {
const offer = await current_rtc.createOffer();
await current_rtc.setLocalDescription(offer);
const data = {
id: bubbleId,
from: currentUserId,
offer: offer,
};
await fetch('/api/ulocal/offer', {
method: 'POST',
headers: {"Content-Type": "application/json"},
body: JSON.stringify(data)
});
};
current_rtc.onicecandidate = async (e) => {
await fetch('/api/ulocal/candidate', {
method: 'POST',
headers: {"Content-Type": "application/json"},
body: JSON.stringify({candidate: e.candidate, id: currentUserId})
});
};
const ch = current_rtc.createDataChannel('local', {ordered: true});
await this.setupDataChannel(ch, 'sender');
};
handleWSEvent = async (e: MessageEvent) => {
let current_id: string;
let current_rtc: RTCPeerConnection | null;
const msg = JSON.parse(e.data) as WSMessage;
// console.log('[D] ws event msg =', msg);
switch (msg.type) {
case "enter":
case "leave":
// 这些事件由父组件处理
return;
case "offer":
const offer_data = msg.data as { id: string; from: string; offer: RTCSessionDescriptionInit };
current_id = useLocalStore.getState().id;
if (offer_data.id !== current_id) {
console.warn(`[W] wrong offer id, want = ${current_id}, got = ${offer_data.id}, data =`, offer_data);
return;
}
current_rtc = this.rtcRef.current;
if (!current_rtc) {
console.warn('[W] rtc undefined');
return;
}
await current_rtc.setRemoteDescription(offer_data.offer);
const answer = await current_rtc.createAnswer();
if (!answer) {
console.log('[W] answer undefined');
return;
}
await current_rtc.setLocalDescription(answer);
current_rtc.ondatachannel = (e) => {
this.setupDataChannel(e.channel, 'receiver');
};
await fetch('/api/ulocal/answer', {
method: 'POST',
headers: {"Content-Type": "application/json"},
body: JSON.stringify({id: offer_data.from, answer: answer})
});
return;
case "answer":
const answer_data = msg.data as { answer: RTCSessionDescriptionInit; id: string };
current_id = useLocalStore.getState().id;
if (answer_data.id !== current_id) {
console.warn(`[W] wrong answer id, want = ${current_id}, got = ${answer_data.id}, data =`, answer_data);
}
current_rtc = this.rtcRef.current;
if (!current_rtc) {
console.warn('[W] rtc undefined');
return;
}
await current_rtc.setRemoteDescription(answer_data.answer);
return;
case "candidate":
const candidate_data = msg.data as { candidate: RTCIceCandidateInit };
current_rtc = this.rtcRef.current;
if (!current_rtc) {
console.warn('[W] rtc undefined');
return;
}
if (!candidate_data.candidate) {
return;
}
await current_rtc.addIceCandidate(candidate_data.candidate);
return;
}
};
sendMessage = (msg: string, files: File[], senderName: string) => {
const ch = useLocalStore.getState().channel;
const CHUNK_SIZE = 64 * 1024; // 64KB
const BUFFERED_AMOUNT_THRESHOLD = 1 * 1024 * 1024; // 1MB
if (ch && ch.readyState === 'open') {
if (msg.trim()) {
// 发送文本消息
const messageData = {
type: 'message',
content: msg,
sender: senderName,
timestamp: Date.now()
};
ch.send(JSON.stringify(messageData));
}
if (files && files.length > 0) {
files.forEach(file => {
const totalChunks = Math.ceil(file.size / CHUNK_SIZE);
const timestamp = Date.now();
// 先发送文件元信息
const fileData = {
type: 'file',
name: file.name,
size: file.size,
sender: senderName,
timestamp,
totalChunks
};
ch.send(JSON.stringify(fileData));
let offset = 0;
let chunkIndex = 0;
const reader = new FileReader();
const sendNextChunk = () => {
if (offset >= file.size) {
// 分块全部发送完毕,发送 file-end
ch.send(JSON.stringify({
type: 'file-end',
name: file.name,
timestamp,
}));
return;
}
if (ch.bufferedAmount > BUFFERED_AMOUNT_THRESHOLD) {
ch.addEventListener('bufferedamountlow', sendNextChunk, { once: true });
return;
}
const slice = file.slice(offset, offset + CHUNK_SIZE);
reader.onload = (e) => {
if (e.target?.result) {
ch.send(JSON.stringify({
type: 'file-chunk',
name: file.name,
timestamp,
chunkIndex,
totalChunks,
data: Array.from(new Uint8Array(e.target.result as ArrayBuffer)),
}));
offset += CHUNK_SIZE;
chunkIndex++;
sendNextChunk();
}
};
reader.readAsArrayBuffer(slice);
};
sendNextChunk();
});
}
}
};
}
@@ -1,18 +0,0 @@
import React from 'react';
import {Dialog} from '../../../component/dialog/dialog.tsx';
import {Sender} from './sender.tsx';
interface SendDialogProps {
open: boolean;
onSend: (msg: string, files: File[]) => void;
onClose: () => void;
name: string;
}
export const SendDialog: React.FC<SendDialogProps> = ({open, onSend, onClose, name}) => {
return (
<Dialog open={open} title={`发送消息给${name}`} onClose={onClose}>
<Sender onSend={onSend} />
</Dialog>
);
};
@@ -1,161 +0,0 @@
import React, {useRef, useState} from 'react';
import {createUseStyles} from "react-jss";
import { message } from '../../../hook/message/u-message.tsx';
const useClass = createUseStyles({
container: {
width: "100%",
minHeight: "260px",
margin: "0",
border: "1px solid #ddd",
borderRadius: "8px",
overflow: "hidden",
position: "relative",
background: "#fff",
boxSizing: "border-box",
padding: "20px 16px 60px 16px"
},
input_box: {
width: "100%",
minHeight: "180px",
padding: "12px",
boxSizing: "border-box",
border: "none",
resize: "vertical",
outline: "none",
fontFamily: "Arial, sans-serif",
fontSize: "1.1rem",
transition: "padding 0.3s ease",
background: "#f8fafd",
borderRadius: "6px"
},
input_has_files: {
paddingTop: '50px',
},
file_list: {
position: "absolute",
top: "0",
left: "0",
right: "0",
padding: "8px 10px",
background: "rgba(255, 255, 255, 0.95)",
borderBottom: "1px solid #eee",
display: "none",
flexWrap: "wrap",
gap: "6px",
maxHeight: "60px",
overflowY: "auto",
},
list_has_files: {
display: 'flex'
},
"@keyframes slideIn": {
from: {transform: "translateY(-10px)", opacity: 0},
to: {transform: "translateY(0)", opacity: 1}
},
file_item: {
display: "flex",
alignItems: "center",
background: "#f0f6ff",
color: '#1661ab',
border: "1px solid #c2d9ff",
borderRadius: "15px",
padding: "4px 12px",
fontSize: "13px",
animation: "$slideIn 0.2s ease"
},
delete_btn: {
width: "16px",
height: "16px",
border: "none",
background: "#ff6b6b",
color: "white",
borderRadius: "50%",
marginLeft: "8px",
cursor: "pointer",
display: "flex",
alignItems: "center",
justifyContent: "center",
fontSize: "12px",
"&:hover": {background: "#ff5252"},
},
buttons: {
position: "absolute",
bottom: "16px",
right: "16px",
display: "flex",
gap: "12px"
},
action_btn: {
padding: "10px 22px",
background: "#4dabf7",
color: "white",
border: "none",
borderRadius: "20px",
cursor: "pointer",
fontSize: "1rem",
transition: "all 0.2s",
"&:hover": {background: "#339af0", transform: "translateY(-1px)"}
},
})
export interface SenderProps {
onSend: (message: string, files: File[]) => void;
}
export const Sender: React.FC<SenderProps> = ({onSend}) => {
const classes = useClass();
const [inputMessage, setInputMessage] = useState('');
const [files, setFiles] = useState<File | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null)
const handleTextInput = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
setInputMessage(e.target.value)
}
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files) {
setFiles(e.target.files[0]);
} else {
message.warning('未能选择文件');
}
};
const handleSubmit = () => {
if (!(inputMessage.trim() || files)) {
message.warning('请输入内容或选择文件');
return;
}
try {
onSend(inputMessage, files ? [files] : []);
setInputMessage('');
setFiles(null);
} catch (e) {
message.error('发送失败,请重试');
}
};
return (
<div className={classes.container}>
<textarea className={files?`${classes.input_box} ${classes.input_has_files}`:`${classes.input_box}`} placeholder="开始输入..."
onChange={handleTextInput} value={inputMessage}></textarea>
<div className={files? `${classes.file_list} ${classes.list_has_files}` : `${classes.file_list}`}>
{files && <div className={classes.file_item}>
<span>{files.name}</span>
<button className={classes.delete_btn} onClick={() => {
setFiles(null)
}}>×
</button>
</div>}
</div>
<div className={classes.buttons}>
<input type="file" ref={fileInputRef} hidden onChange={handleFileSelect}/>
<button className={classes.action_btn} onClick={() => {
fileInputRef.current && fileInputRef.current.click()
}}>📁 选择文件
</button>
<button className={classes.action_btn} onClick={handleSubmit}>✈️ 发送</button>
</div>
</div>
);
};
@@ -1,38 +0,0 @@
export interface Bubble {
id: string;
name: string;
x: number;
y: number;
color: string;
radius: number;
angle: number;
}
export interface WSMessage {
data: any;
time: number;
type: "register" | "enter" | "leave" | "offer" | "answer" | "candidate"
}
export interface Client {
client_type: 'desktop' | 'mobile' | 'tablet';
app_type: 'web';
ip: number;
name: string;
id: string;
register_at: string;
offer: RTCSessionDescription;
candidate: RTCIceCandidateInit;
}
export interface ReceivedMessage {
text?: string;
timestamp: number;
sender: string;
fileName?: string;
fileSize?: number;
fileBlobUrl?: string;
isFile?: boolean;
progress?: number; // 0-1
receiving?: boolean; // 是否正在接收
}
@@ -1,51 +0,0 @@
import React from 'react';
import {createUseStyles} from "react-jss";
import {Bubble} from "./types.ts";
const useClass = createUseStyles({
bubble: {
position: "absolute",
width: "100px",
height: "100px",
borderRadius: "50%",
display: "flex",
justifyContent: "center",
alignItems: "center",
textAlign: "center",
cursor: "pointer",
fontFamily: "'Microsoft Yahei', sans-serif",
fontSize: "14px",
color: "rgba(255, 255, 255, 0.9)",
textShadow: "1px 1px 3px rgba(0,0,0,0.3)",
transition: "transform 0.3s ease",
transform: 'translate(-50%, -50%)',
animation: 'emerge 0.5s ease-out forwards,float 6s 0.5s ease-in-out infinite',
background: "radial-gradient(circle at 30% 30%,rgba(255, 255, 255, 0.8) 10%,rgba(255, 255, 255, 0.3) 50%,transparent 100%)",
border: "2px solid rgba(255, 255, 255, 0.5)",
boxShadow: "inset 0 -5px 15px rgba(255,255,255,0.3),0 5px 15px rgba(0,0,0,0.1)",
}
});
interface UserBubbleProps {
bubble: Bubble;
onClick: (bubble: Bubble) => void;
}
export const UserBubble: React.FC<UserBubbleProps> = ({bubble, onClick}) => {
const classes = useClass();
return (
<div
className={classes.bubble}
style={{
left: bubble.x,
top: bubble.y,
backgroundColor: bubble.color,
animationDelay: `${Math.random() * 0.5}s, ${0.5 + Math.random() * 2}s`
}}
onClick={() => onClick(bubble)}
>
{bubble.name}
</div>
);
};
-242
View File
@@ -1,242 +0,0 @@
import {CloudBackground} from "../../component/fluid/cloud.tsx";
import {useEffect, useRef, useState, useCallback} from "react";
import {createUseStyles} from "react-jss";
import {useWebsocket} from "../../hook/websocket/u-ws.tsx";
import {Resp} from "../../interface/response.ts";
import {useLocalStore} from "../../store/local.ts";
import {UserBubble} from "./component/user-bubble.tsx";
import {MessageDialog} from "./component/message-dialog.tsx";
import {RTCHandler, RTCHandlerCallbacks} from "./component/rtc-handler.ts";
import {generateBubbles} from "./component/bubble-layout.ts";
import {Client, ReceivedMessage} from "./component/types.ts";
import {SendDialog} from "./component/send-dialog.tsx";
import {message} from "../../hook/message/u-message.tsx";
const useClass = createUseStyles({
'@global': {
'@keyframes emerge': {
'0%': {
transform: 'scale(0) translate(-50%, -50%)',
opacity: 0
},
'80%': {
transform: 'scale(1.1) translate(-50%, -50%)',
opacity: 1
},
'100%': {
transform: 'scale(1) translate(-50%, -50%)',
opacity: 1
}
},
'@keyframes fadeIn': {
'0%': {
opacity: 0,
transform: 'translateY(-10px)'
},
'100%': {
opacity: 1,
transform: 'translateY(0)'
}
}
},
container: {
margin: "0",
height: "100vh",
overflow: "hidden",
position: "relative",
},
title: {
width: '100%',
display: "flex",
justifyContent: "center",
color: '#1661ab',
}
});
export const LocalSharing: React.FC = () => {
const classes = useClass();
const {id, name, set, } = useLocalStore();
const [_rtc, setRTC] = useState<RTCPeerConnection>();
const rtcRef = useRef<RTCPeerConnection | null>(null);
const [clients, setClients] = useState<Client[]>([]);
const {connect, close} = useWebsocket({});
const [open, setOpen] = useState<{ send: boolean; receive: boolean }>({send: false, receive: false});
const [receivedMessage, setReceivedMessage] = useState<ReceivedMessage | null>(null);
const [showMessageDialog, setShowMessageDialog] = useState(false);
const [receivingFile, setReceivingFile] = useState<ReceivedMessage | null>(null);
// RTC处理器的回调函数 - 使用useCallback确保稳定性
const onChannelOpen = useCallback((type: 'sender' | 'receiver') => {
console.log(`[D] Channel opened: ${type}`);
setOpen(val => ({...val, [type]: true}));
}, []);
const onMessageReceived = useCallback((message: ReceivedMessage) => {
if (message.isFile && message.receiving) {
setReceivingFile(message);
} else if (message.isFile && !message.receiving) {
setReceivingFile(null);
setReceivedMessage(message);
setShowMessageDialog(true);
} else {
setReceivedMessage(message);
setShowMessageDialog(true);
}
}, []);
const onChannelClose = useCallback(() => {
console.log('[D] Channel closed');
setOpen({send: false, receive: false});
}, []);
const rtcCallbacks: RTCHandlerCallbacks = {
onChannelOpen,
onMessageReceived,
onChannelClose
};
// 创建RTC处理器实例 - 使用useRef确保实例稳定
const rtcHandlerRef = useRef<RTCHandler | null>(null);
// 更新RTC处理器的回调函数
useEffect(() => {
if (rtcHandlerRef.current) {
rtcHandlerRef.current.updateCallbacks(rtcCallbacks);
}
}, [rtcCallbacks]);
const updateClients = async () => {
setTimeout(async () => {
const res = await fetch(`/api/ulocal/clients`);
const jes = await res.json() as Resp<Client[]>;
setClients(jes.data);
}, 500);
};
const handleWSEvent = async (e: MessageEvent) => {
const msgData = JSON.parse(e.data);
if (msgData.type === "enter" || msgData.type === "leave") {
await updateClients();
return;
}
// 其他RTC相关事件由RTC处理器处理
if (rtcHandlerRef.current) {
try {
await rtcHandlerRef.current.handleWSEvent(e);
} catch (err) {
message.error('通信异常,请刷新页面');
}
} else {
message.error('内部错误:通信模块未初始化');
}
};
const handleBubbleClick = async (bubble: any) => {
setOpen({send: true, receive: false});
if (rtcHandlerRef.current) {
try {
await rtcHandlerRef.current.handleBubbleClick(bubble.id, id);
} catch (e) {
message.error('建立连接失败,请重试');
}
} else {
message.error('内部错误:通信模块未初始化');
console.error('[E] RTC handler is null!');
}
};
const handleSend = (msg: string, files: File[]) => {
if (rtcHandlerRef.current) {
try {
rtcHandlerRef.current.sendMessage(msg, files, name);
} catch (e) {
message.error('发送失败,请重试');
}
} else {
message.error('内部错误:通信模块未初始化');
}
};
const handleCloseMessageDialog = () => {
setShowMessageDialog(false);
setReceivedMessage(null);
};
useEffect(() => {
const fn = async () => {
const response = await fetch('/api/ulocal/register', {method: 'POST'});
const data = ((await response.json()) as Resp<{ id: string; name: string }>).data;
set(data.id, data.name);
connect(`/api/ulocal/ws?id=${data.id}`, {fn: handleWSEvent});
await updateClients();
const _rtc = new RTCPeerConnection();
rtcRef.current = _rtc;
setRTC(_rtc);
// 在RTC连接创建后立即创建处理器实例
rtcHandlerRef.current = new RTCHandler(rtcRef, rtcCallbacks);
return () => {
close();
if (rtcRef.current) {
rtcRef.current.close();
}
};
};
fn();
}, []);
const bubbles = generateBubbles(clients, id);
useEffect(() => {
if (receivingFile && receivingFile.isFile && !receivingFile.receiving) {
setReceivingFile(null);
setReceivedMessage(receivingFile);
setShowMessageDialog(true);
}
}, [receivingFile]);
return (
<div className={classes.container}>
<CloudBackground/>
<h1 className={classes.title}>
{name}
{/* <span> - {id}</span> */}
</h1>
{bubbles.map(bubble => (
<UserBubble
key={bubble.id}
bubble={bubble}
onClick={handleBubbleClick}
/>
))}
<SendDialog
open={open.send}
onSend={handleSend}
onClose={() => setOpen({send: false, receive: false})}
name={name}
/>
{/* 文件接收进度弹窗 */}
{receivingFile && (
<MessageDialog
open={true}
message={receivingFile}
onClose={() => setReceivingFile(null)}
/>
)}
<MessageDialog
open={showMessageDialog}
message={receivedMessage}
onClose={handleCloseMessageDialog}
/>
</div>
);
};
-139
View File
@@ -1,139 +0,0 @@
import React, { useState } from "react";
import { createUseStyles } from "react-jss";
import { useAuth } from "../api/auth.ts";
import { UButton } from "../component/button/u-button.tsx";
const useClass = createUseStyles({
container: {
overflow: 'hidden',
height: '100vh',
margin: 0,
padding: 0,
boxSizing: "border-box",
fontFamily: "'Segoe UI', Arial, sans-serif",
display: "flex",
justifyContent: "center",
alignItems: "center",
backgroundColor: "#e3f2fd",
},
form: {
backgroundColor: "#C8E6C9",
boxShadow: "inset 0 0 15px rgba(56, 142, 60, 0.15)",
padding: "30px",
borderRadius: "15px",
width: "350px",
display: "flex",
flexDirection: "column",
},
title: {
color: "#2c9678",
marginTop: 0,
marginBottom: "25px",
},
inputContainer: {
position: 'relative',
width: '100%',
marginTop: '15px',
},
inputField: {
width: "100%",
padding: "11px",
border: "2px solid #ddd",
borderRadius: "5px",
fontSize: "16px",
boxSizing: "border-box",
transition: "border-color 0.3s",
background: "rgba(255,255,255,0.8)",
"&:focus": {
outline: "none",
borderColor: "#2c9678",
},
"&:hover": {
borderColor: "#2c9678",
}
},
iconButton: {
position: 'absolute',
right: '10px',
top: '50%',
transform: 'translateY(-50%)',
background: 'transparent',
border: 'none',
cursor: 'pointer',
color: '#666',
"&:hover": {
color: '#333',
}
},
button: {
marginTop: '25px',
width: '100%',
"& > button": {
width: "100%",
},
}
})
export const Login: React.FC = () => {
const classes = useClass()
const { login } = useAuth()
const [username, setUsername] = useState("")
const [password, setPassword] = useState("")
const [showPassword, setShowPassword] = useState(false)
const onLogin = async () => {
try {
await login(username, password)
window.location.href = "/"
} catch (_e) {
}
}
return <div className={classes.container}>
<div className={classes.form}>
<h2 className={classes.title}>UShare</h2>
<div className={classes.inputContainer}>
<input
className={classes.inputField}
placeholder="请输入账号"
value={username}
onChange={(e) => setUsername(e.target.value)}
/>
{username && (
<button
className={classes.iconButton}
onClick={() => setUsername("")}
style={{ right: '10px', fontSize: '16px' }}
>
×
</button>
)}
</div>
<div className={classes.inputContainer}>
<input
className={classes.inputField}
placeholder="请输入密码"
type={showPassword ? "text" : "password"}
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
<button
className={classes.iconButton}
onMouseDown={() => setShowPassword(true)}
onMouseUp={() => setShowPassword(false)}
onMouseLeave={() => setShowPassword(false)}
style={{ right: '10px', fontSize: '12px' }}
>
{showPassword ? "🫣" : "🙈"}
</button>
</div>
<div className={classes.button}>
<UButton onClick={onLogin}>登录</UButton>
</div>
</div>
</div>
}
-429
View File
@@ -1,429 +0,0 @@
import React, {useEffect, useState} from 'react';
import {createUseStyles} from 'react-jss';
import {tokenApi, ApiToken, CreateTokenRes} from '../../api/token.ts';
import {message} from '../../hook/message/u-message.tsx';
import {UButton} from '../../component/button/u-button.tsx';
const useStyle = createUseStyles({
container: {
minHeight: '100vh',
backgroundColor: '#e3f2fd',
padding: '24px',
boxSizing: 'border-box',
fontFamily: "'Segoe UI', Arial, sans-serif",
},
header: {
display: 'flex',
alignItems: 'center',
gap: '16px',
marginBottom: '24px',
},
backBtn: {
background: 'transparent',
border: '2px solid #2c9678',
color: '#2c9678',
borderRadius: '6px',
padding: '6px 14px',
cursor: 'pointer',
fontSize: '14px',
transition: 'background-color 0.2s',
'&:hover': {backgroundColor: 'rgba(44,150,120,0.1)'},
},
title: {
color: '#2c9678',
margin: 0,
fontSize: '22px',
fontWeight: 600,
},
card: {
backgroundColor: '#C8E6C9',
boxShadow: 'inset 0 0 15px rgba(56, 142, 60, 0.15)',
borderRadius: '15px',
padding: '24px',
marginBottom: '24px',
},
cardTitle: {
color: '#2c9678',
marginTop: 0,
marginBottom: '16px',
fontSize: '16px',
fontWeight: 600,
},
table: {
width: '100%',
borderCollapse: 'collapse',
fontSize: '14px',
},
th: {
backgroundColor: 'rgba(44,150,120,0.15)',
padding: '10px 12px',
textAlign: 'left',
color: '#2c9678',
fontWeight: 600,
borderBottom: '2px solid rgba(44,150,120,0.3)',
},
td: {
padding: '10px 12px',
borderBottom: '1px solid rgba(44,150,120,0.2)',
color: '#333',
},
trHover: {
'&:hover': {backgroundColor: 'rgba(44,150,120,0.05)'},
},
emptyRow: {
textAlign: 'center',
color: '#888',
padding: '24px',
},
actionBtn: {
padding: '4px 12px',
borderRadius: '4px',
border: 'none',
cursor: 'pointer',
fontSize: '13px',
transition: 'opacity 0.2s',
'&:hover': {opacity: 0.8},
},
deleteBtn: {
backgroundColor: '#e53935',
color: 'white',
},
topBar: {
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: '16px',
},
// Dialog overlay
overlay: {
position: 'fixed',
inset: 0,
backgroundColor: 'rgba(0,0,0,0.4)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 1000,
},
dialog: {
backgroundColor: '#C8E6C9',
borderRadius: '15px',
padding: '28px',
width: '440px',
maxWidth: '90vw',
boxShadow: '0 8px 32px rgba(0,0,0,0.2)',
},
dialogTitle: {
color: '#2c9678',
marginTop: 0,
marginBottom: '20px',
fontSize: '16px',
fontWeight: 600,
},
label: {
display: 'block',
color: '#2c9678',
fontSize: '13px',
marginBottom: '6px',
fontWeight: 500,
},
input: {
width: '100%',
padding: '8px 12px',
borderRadius: '6px',
border: '1px solid rgba(44,150,120,0.4)',
fontSize: '14px',
marginBottom: '16px',
boxSizing: 'border-box',
backgroundColor: 'rgba(255,255,255,0.8)',
outline: 'none',
'&:focus': {borderColor: '#2c9678'},
},
dialogFooter: {
display: 'flex',
gap: '10px',
justifyContent: 'flex-end',
},
cancelBtn: {
padding: '8px 18px',
borderRadius: '6px',
border: '2px solid #2c9678',
background: 'transparent',
color: '#2c9678',
cursor: 'pointer',
fontSize: '14px',
'&:hover': {backgroundColor: 'rgba(44,150,120,0.1)'},
},
tokenValueBox: {
backgroundColor: 'rgba(255,255,255,0.9)',
borderRadius: '8px',
padding: '12px 14px',
fontFamily: 'monospace',
fontSize: '13px',
wordBreak: 'break-all',
marginBottom: '12px',
color: '#1a1a2e',
border: '1px solid rgba(44,150,120,0.4)',
},
warningText: {
color: '#e53935',
fontSize: '12px',
marginBottom: '16px',
},
copyBtn: {
padding: '8px 18px',
borderRadius: '6px',
border: 'none',
background: '#2c9678',
color: 'white',
cursor: 'pointer',
fontSize: '14px',
'&:hover': {backgroundColor: '#1f6d5a'},
},
usageCard: {
backgroundColor: 'rgba(255,255,255,0.5)',
borderRadius: '10px',
padding: '16px 20px',
},
usageTitle: {
color: '#2c9678',
margin: '0 0 10px',
fontSize: '14px',
fontWeight: 600,
},
pre: {
margin: '6px 0',
padding: '10px 14px',
backgroundColor: '#1a1a2e',
color: '#c3e88d',
borderRadius: '6px',
fontSize: '13px',
overflowX: 'auto',
fontFamily: 'monospace',
},
});
interface Session {
user_id: number;
username: string;
role_label: string;
permissions: string[];
}
export const SelfPage: React.FC = () => {
const style = useStyle();
const [session, setSession] = useState<Session | null>(null);
const [tokens, setTokens] = useState<ApiToken[]>([]);
const [loading, setLoading] = useState(true);
const [showCreate, setShowCreate] = useState(false);
const [newTokenName, setNewTokenName] = useState('');
const [creating, setCreating] = useState(false);
const [createdToken, setCreatedToken] = useState<CreateTokenRes | null>(null);
useEffect(() => {
fetch('/api/uauth/me')
.then(async res => {
if (!res.ok) {
window.location.href = '/login';
return;
}
const json = await res.json();
const s: Session = json.data;
setSession(s);
if (!s.permissions.includes('token_manage')) {
message.warning('无 Token 管理权限');
return;
}
return loadTokens();
})
.catch(() => {
window.location.href = '/login';
})
.finally(() => setLoading(false));
}, []);
async function loadTokens() {
try {
const list = await tokenApi.list();
setTokens(list ?? []);
} catch (e: unknown) {
message.error(e instanceof Error ? e.message : '加载失败');
}
}
async function handleCreate() {
if (!newTokenName.trim()) {
message.warning('请输入 Token 名称');
return;
}
setCreating(true);
try {
const res = await tokenApi.create(newTokenName.trim());
setCreatedToken(res);
setNewTokenName('');
setShowCreate(false);
await loadTokens();
} catch (e: unknown) {
message.error(e instanceof Error ? e.message : '创建失败');
} finally {
setCreating(false);
}
}
async function handleDelete(id: number, name: string) {
if (!confirm(`确认吊销 Token「${name}」?`)) return;
try {
await tokenApi.delete(id);
message.success('已吊销');
setTokens(prev => prev.filter(t => t.id !== id));
} catch (e: unknown) {
message.error(e instanceof Error ? e.message : '操作失败');
}
}
function handleCopyToken(val: string) {
navigator.clipboard.writeText(val)
.then(() => message.success('已复制到剪贴板'))
.catch(() => message.warning('复制失败,请手动复制'));
}
function formatDate(s: string | null) {
if (!s) return '-';
return new Date(s).toLocaleString();
}
const hasTokenPerm = session?.permissions.includes('token_manage') ?? false;
return (
<div className={style.container}>
<div className={style.header}>
<button className={style.backBtn} onClick={() => window.history.back()}>← 返回</button>
<h2 className={style.title}>个人中心</h2>
</div>
{!loading && session && (
<>
{/* User info card */}
<div className={style.card}>
<h3 className={style.cardTitle}>账号信息</h3>
<p style={{margin: '4px 0', color: '#333', fontSize: '14px'}}>
用户名:<strong>{session.username}</strong>
</p>
<p style={{margin: '4px 0', color: '#333', fontSize: '14px'}}>
角色:<strong>{session.role_label}</strong>
</p>
</div>
{/* Token management card */}
{hasTokenPerm && (
<div className={style.card}>
<div className={style.topBar}>
<h3 className={style.cardTitle} style={{margin: 0}}>API Token</h3>
<UButton onClick={() => setShowCreate(true)}>+ 新建 Token</UButton>
</div>
<table className={style.table}>
<thead>
<tr>
<th className={style.th}>名称</th>
<th className={style.th}>创建时间</th>
<th className={style.th}>最后使用</th>
<th className={style.th}>操作</th>
</tr>
</thead>
<tbody>
{tokens.length === 0 ? (
<tr>
<td className={style.td} colSpan={4} style={{textAlign: 'center', color: '#888'}}>
暂无 Token,点击「新建 Token」创建
</td>
</tr>
) : (
tokens.map(t => (
<tr key={t.id} className={style.trHover}>
<td className={style.td}>{t.name}</td>
<td className={style.td}>{formatDate(t.created_at)}</td>
<td className={style.td}>{formatDate(t.last_used_at)}</td>
<td className={style.td}>
<button
className={`${style.actionBtn} ${style.deleteBtn}`}
onClick={() => handleDelete(t.id, t.name)}
>
吊销
</button>
</td>
</tr>
))
)}
</tbody>
</table>
{/* Usage guide */}
<div style={{marginTop: '20px'}}>
<div className={style.usageCard}>
<p className={style.usageTitle}>使用方式(curl 示例)</p>
<pre className={style.pre}>{`curl -H "Authorization: Bearer <your_token>" \\
-T <file_path> \\
https://<your_domain>/api/v1/upload/<filename>`}</pre>
<p style={{margin: '8px 0 4px', color: '#555', fontSize: '13px'}}>返回示例:</p>
<pre className={style.pre}>{`{"status":200,"data":{"code":"ABCD1234"}}`}</pre>
<p style={{margin: '8px 0 4px', color: '#555', fontSize: '13px'}}>下载文件:</p>
<pre className={style.pre}>{`https://<your_domain>/ushare/<code>`}</pre>
</div>
</div>
</div>
)}
{!hasTokenPerm && (
<div className={style.card}>
<p style={{color: '#888', margin: 0}}>当前角色无 Token 管理权限</p>
</div>
)}
</>
)}
{/* Create token dialog */}
{showCreate && (
<div className={style.overlay} onClick={() => setShowCreate(false)}>
<div className={style.dialog} onClick={e => e.stopPropagation()}>
<h3 className={style.dialogTitle}>新建 API Token</h3>
<label className={style.label}>Token 名称</label>
<input
className={style.input}
placeholder="例:服务器上传脚本"
value={newTokenName}
onChange={e => setNewTokenName(e.target.value)}
onKeyDown={e => e.key === 'Enter' && handleCreate()}
autoFocus
/>
<div className={style.dialogFooter}>
<button className={style.cancelBtn} onClick={() => setShowCreate(false)}>取消</button>
<UButton onClick={handleCreate} loading={creating} disabled={creating}>
创建
</UButton>
</div>
</div>
</div>
)}
{/* Newly created token display - shown only once */}
{createdToken && (
<div className={style.overlay} onClick={() => setCreatedToken(null)}>
<div className={style.dialog} onClick={e => e.stopPropagation()}>
<h3 className={style.dialogTitle}>Token 已创建</h3>
<p className={style.warningText}>
请立即复制并妥善保存,Token 值仅显示一次,关闭后无法再次查看!
</p>
<label className={style.label}>Token 名称:{createdToken.name}</label>
<div className={style.tokenValueBox}>{createdToken.token}</div>
<div className={style.dialogFooter}>
<button className={style.cancelBtn} onClick={() => setCreatedToken(null)}>关闭</button>
<button className={style.copyBtn} onClick={() => handleCopyToken(createdToken.token)}>
复制 Token
</button>
</div>
</div>
</div>
)}
</div>
);
};
+26
View File
@@ -0,0 +1,26 @@
import {createUseStyles} from 'react-jss'
import {PanelLeft} from "./component/panel-left.tsx";
import {PanelRight} from "./component/panel-right.tsx";
import {PanelMid} from "./component/panel-mid.tsx";
const useStyle = createUseStyles({
"@global": {
margin: 0,
padding: 0,
},
container: {
margin: 0,
height: "100vh",
display: "grid",
gridTemplateColumns: "40% 20% 40%",
},
})
export const FileSharing = () => {
const style = useStyle()
return <div className={style.container}>
<PanelLeft />
<PanelMid/>
<PanelRight/>
</div>
};
@@ -1,81 +0,0 @@
import React, {useEffect, useState} from 'react';
import {createUseStyles} from 'react-jss';
const useStyle = createUseStyles({
nav: {
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '0 24px',
height: '48px',
backgroundColor: '#2c9678',
boxShadow: '0 2px 6px rgba(0,0,0,0.15)',
flexShrink: 0,
},
brand: {
color: 'white',
fontWeight: 700,
fontSize: '18px',
letterSpacing: '1px',
textDecoration: 'none',
},
links: {
display: 'flex',
gap: '8px',
alignItems: 'center',
},
link: {
color: 'rgba(255,255,255,0.9)',
fontSize: '13px',
textDecoration: 'none',
padding: '5px 12px',
borderRadius: '4px',
transition: 'background-color 0.2s',
'&:hover': {
backgroundColor: 'rgba(255,255,255,0.2)',
color: 'white',
},
},
divider: {
color: 'rgba(255,255,255,0.4)',
fontSize: '13px',
},
});
export const NavBar: React.FC = () => {
const style = useStyle();
const [isAdmin, setIsAdmin] = useState(false);
const [hasTokenPerm, setHasTokenPerm] = useState(false);
useEffect(() => {
fetch('/api/uauth/me').then(async res => {
if (res.ok) {
const json = await res.json();
const perms: string[] = json.data?.permissions ?? [];
setIsAdmin(perms.includes('user_manage'));
setHasTokenPerm(perms.includes('token_manage'));
}
}).catch(() => {});
}, []);
const showLinks = isAdmin || hasTokenPerm;
return (
<nav className={style.nav}>
<a href="/share" className={style.brand}>UShare</a>
{showLinks && (
<div className={style.links}>
{hasTokenPerm && (
<a href="/self" className={style.link}>个人中心</a>
)}
{isAdmin && hasTokenPerm && (
<span className={style.divider}>|</span>
)}
{isAdmin && (
<a href="/admin" className={style.link}>管理控制台</a>
)}
</div>
)}
</nav>
);
};
@@ -1,394 +0,0 @@
import {createUseStyles} from "react-jss";
import {UButton} from "../../../component/button/u-button.tsx";
import React, {useState} from "react";
import {useStore} from "../../../store/share.ts";
import {message} from "../../../hook/message/u-message.tsx";
import {useFileUpload, UploadSettings} from "../../../api/upload.ts";
const useUploadStyle = createUseStyles({
container: {
backgroundColor: "#e3f2fd",
display: "flex",
justifyContent: "center",
alignItems: "center",
minHeight: "50vh",
"@media (max-width: 768px)": {
minHeight: "auto",
padding: "20px 10px",
},
},
form: {
backgroundColor: "#C8E6C9",
boxShadow: "inset 0 0 15px rgba(56, 142, 60, 0.15)",
padding: "30px",
borderRadius: "15px",
width: "70%",
margin: "20px 60px 20px 0",
"@media (max-width: 768px)": {
width: "90%",
margin: "20px 0",
padding: "20px",
},
},
title: {
color: "#2c9678",
"@media (max-width: 768px)": {
fontSize: "1.5rem",
marginBottom: "15px",
},
},
file: {
display: 'none',
},
preview: {
marginTop: '10px',
display: 'flex',
},
name: {
color: "#2c9678",
marginLeft: '10px',
"@media (max-width: 768px)": {
fontSize: "14px",
},
},
clean: {
borderRadius: '50%',
cursor: 'pointer',
'&:hover': {}
},
// Advanced settings
advToggle: {
marginTop: '16px',
display: 'flex',
alignItems: 'center',
gap: '6px',
cursor: 'pointer',
color: '#2c9678',
fontSize: '13px',
userSelect: 'none',
opacity: 0.75,
'&:hover': {opacity: 1},
},
advPanel: {
marginTop: '12px',
padding: '14px 16px',
backgroundColor: 'rgba(255,255,255,0.5)',
borderRadius: '10px',
display: 'flex',
flexDirection: 'column',
gap: '12px',
},
advRow: {
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: '12px',
},
advLabel: {
color: '#2c9678',
fontSize: '13px',
fontWeight: 500,
flexShrink: 0,
},
advInput: {
width: '80px',
padding: '5px 8px',
borderRadius: '5px',
border: '1px solid rgba(44,150,120,0.4)',
fontSize: '13px',
textAlign: 'center',
outline: 'none',
backgroundColor: 'rgba(255,255,255,0.8)',
'&:focus': {borderColor: '#2c9678'},
},
advSelect: {
padding: '5px 8px',
borderRadius: '5px',
border: '1px solid rgba(44,150,120,0.4)',
fontSize: '13px',
outline: 'none',
backgroundColor: 'rgba(255,255,255,0.8)',
color: '#333',
cursor: 'pointer',
'&:focus': {borderColor: '#2c9678'},
},
advHint: {
fontSize: '11px',
color: '#888',
},
})
const useShowStyle = createUseStyles({
container: {
backgroundColor: "#e3f2fd",
display: "flex",
justifyContent: "center",
alignItems: "center",
minHeight: "50vh",
"@media (max-width: 768px)": {
minHeight: "auto",
padding: "20px 10px",
},
},
form: {
backgroundColor: "#C8E6C9",
boxShadow: "inset 0 0 15px rgba(56, 142, 60, 0.15)",
padding: "30px",
borderRadius: "15px",
width: "70%",
margin: "20px 60px 20px 0",
position: "relative",
"@media (max-width: 768px)": {
width: "90%",
margin: "20px 0",
padding: "20px",
},
},
title: {
color: "#2c9678",
marginTop: 0,
marginBottom: "25px",
display: "flex",
justifyContent: "space-between",
alignItems: "center",
"@media (max-width: 768px)": {
fontSize: "1.5rem",
marginBottom: "15px",
},
},
closeButton: {
position: "absolute",
top: "10px",
right: "10px",
background: "transparent",
color: "white",
border: "none",
borderRadius: "50%",
width: "24px",
height: "24px",
cursor: "pointer",
"&:hover": {
boxShadow: "20px 20px 60px #fff, -20px -20px 60px #fff",
},
},
codeWrapper: {
backgroundColor: "rgba(255,255,255,0.8)",
padding: "0 15px",
borderRadius: "8px",
margin: "15px 0",
overflowX: "auto",
"@media (max-width: 768px)": {
padding: "0 10px",
},
},
pre: {
display: 'flex',
flexDirection: 'row',
color: 'black',
alignItems: 'center',
height: '24px',
"& > code": {
marginLeft: "0",
fontSize: "14px",
wordBreak: "break-all",
"@media (max-width: 768px)": {
fontSize: "12px",
},
}
},
copyButton: {
marginLeft: 'auto',
background: "#2c9678",
color: "white",
border: "none",
padding: "8px 16px",
borderRadius: "4px",
cursor: "pointer",
transition: "background 0.3s",
"&:hover": {
background: "#1f6d5a",
},
"@media (max-width: 768px)": {
padding: "6px 12px",
fontSize: "12px",
},
},
metaInfo: {
fontSize: '12px',
color: '#555',
marginTop: '10px',
display: 'flex',
gap: '16px',
flexWrap: 'wrap',
},
metaItem: {
display: 'flex',
alignItems: 'center',
gap: '4px',
},
});
// Expiry options (hours) shown in the dropdown
const EXPIRY_OPTIONS = [1, 2, 4, 8, 12, 24];
export const PanelLeft = () => {
const [code, setCode] = useState("")
const [settings, setSettings] = useState<UploadSettings>({maxDownloads: 3, expiresIn: 8 * 3600})
if (code) {
return <PanelLeftShow code={code} set_code={setCode} settings={settings}/>
}
return <PanelLeftUpload set_code={setCode} settings={settings} setSettings={setSettings}/>
};
const PanelLeftUpload: React.FC<{
set_code: (code: string) => void;
settings: UploadSettings;
setSettings: (s: UploadSettings) => void;
}> = ({set_code, settings, setSettings}) => {
const style = useUploadStyle()
const {file, setFile} = useStore()
const {uploadFile, progress, loading} = useFileUpload();
const [showAdv, setShowAdv] = useState(false);
function onFileSelect() {
// @ts-expect-error no types for direct DOM query
document.querySelector('#real-file-input').click();
}
function onFileChange(e: React.ChangeEvent<HTMLInputElement>) {
setFile(e.currentTarget.files ? e.currentTarget.files[0] : null)
}
async function onFileUpload() {
if (!file) return;
const code = await uploadFile(file, settings)
set_code(code)
}
function onFileClean() {
setFile(null)
}
function onMaxDownloadsChange(e: React.ChangeEvent<HTMLInputElement>) {
let v = parseInt(e.target.value, 10);
if (isNaN(v) || v < 0) v = 0;
if (v > 999) v = 999;
setSettings({...settings, maxDownloads: v});
}
function onExpiryChange(e: React.ChangeEvent<HTMLSelectElement>) {
setSettings({...settings, expiresIn: parseInt(e.target.value, 10)});
}
return <div className={style.container}>
<div className={style.form}>
<h2 className={style.title}>上传文件</h2>
{!file && !loading && <UButton onClick={onFileSelect}>选择文件</UButton>}
{file && !loading && <UButton onClick={onFileUpload}>上传文件</UButton>}
{loading && <UButton process={progress} loading={loading}>上传中</UButton>}
<input type="file" className={style.file} id="real-file-input" onChange={onFileChange}/>
{file && (
<div className={style.preview}>
<div className={style.clean} onClick={onFileClean}>×</div>
<div className={style.name}>{file.name}</div>
</div>
)}
{/* Advanced settings toggle */}
<div className={style.advToggle} onClick={() => setShowAdv(v => !v)}>
<span>{showAdv ? '▾' : '▸'}</span>
<span>高级设置</span>
</div>
{showAdv && (
<div className={style.advPanel}>
<div className={style.advRow}>
<span className={style.advLabel}>下载次数限制</span>
<div style={{display: 'flex', alignItems: 'center', gap: '8px'}}>
<input
type="number"
min={0}
max={999}
className={style.advInput}
value={settings.maxDownloads}
onChange={onMaxDownloadsChange}
/>
<span className={style.advHint}>0 = 不限制</span>
</div>
</div>
<div className={style.advRow}>
<span className={style.advLabel}>过期时间</span>
<select
className={style.advSelect}
value={settings.expiresIn}
onChange={onExpiryChange}
>
{EXPIRY_OPTIONS.map(h => (
<option key={h} value={h * 3600}>{h} 小时</option>
))}
</select>
</div>
</div>
)}
</div>
</div>
}
const PanelLeftShow: React.FC<{
code: string;
set_code: (code: string) => void;
settings: UploadSettings;
}> = ({code, set_code, settings}) => {
const classes = useShowStyle();
const handleCopy = async () => {
try {
await navigator.clipboard.writeText(code);
message.success("复制成功!");
} catch (err) {
message.warning("复制失败,请手动选择文本复制");
}
};
const expiryHours = Math.round(settings.expiresIn / 3600);
const downloadLimit = settings.maxDownloads === 0 ? '不限' : `${settings.maxDownloads} 次`;
return (
<div className={classes.container}>
<div className={classes.form}>
<button
className={classes.closeButton}
onClick={() => set_code('')}
aria-label="关闭"
>×</button>
<h2 className={classes.title}>上传成功!</h2>
<div className={classes.codeWrapper}>
<pre className={classes.pre}>
<code>{code}</code>
<button className={classes.copyButton} onClick={handleCopy}>
一键复制
</button>
</pre>
</div>
<div className={classes.metaInfo}>
<span className={classes.metaItem}>下载限制:{downloadLimit}</span>
<span className={classes.metaItem}>有效期:{expiryHours} 小时</span>
</div>
</div>
</div>
);
};
-43
View File
@@ -1,43 +0,0 @@
import {createUseStyles} from 'react-jss'
import {PanelLeft} from "./component/panel-left.tsx";
import {PanelRight} from "./component/panel-right.tsx";
import {PanelMid} from "./component/panel-mid.tsx";
import {NavBar} from "./component/nav-bar.tsx";
const useStyle = createUseStyles({
"@global": {
margin: 0,
padding: 0,
},
wrapper: {
display: 'flex',
flexDirection: 'column',
height: '100vh',
},
container: {
flex: 1,
display: "grid",
gridTemplateColumns: "40% 20% 40%",
overflow: 'hidden',
"@media (max-width: 768px)": {
gridTemplateColumns: "100%",
gridTemplateRows: "auto auto",
overflowY: "auto",
},
},
})
export const FileSharing = () => {
const style = useStyle()
return (
<div className={style.wrapper}>
<NavBar />
<div className={style.container}>
<PanelLeft />
<PanelMid />
<PanelRight />
</div>
</div>
);
};
-18
View File
@@ -1,18 +0,0 @@
import {useState} from "react";
import {createUseStyles} from "react-jss";
const useClass = createUseStyles({
container: {}
})
export const TestPage = () => {
const classes = useClass()
const [_open, setOpen] = useState<boolean>(false)
const handleOpen = () => {
setOpen(true)
}
return <div className={classes.container}>
<button onClick={handleOpen}>open</button>
</div>
}
-24
View File
@@ -1,24 +0,0 @@
import {create} from 'zustand'
export interface LocalStore {
id: string;
name: string;
channel?: RTCDataChannel;
set: (id: string, name: string) => void;
setChannel: (chan?: RTCDataChannel) => void;
}
export const useLocalStore = create<LocalStore>()((_set, _get) => ({
id: '',
name: '',
set: (id: string, name: string) => {
_set(state => {
return {...state, id: id, name: name};
})
},
setChannel: (ch?: RTCDataChannel) => {
_set(state => {
return {...state, channel: ch}
})
}
}))
+1 -4
View File
@@ -3,8 +3,5 @@
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
],
"compilerOptions": {
"allowJs": true
}
]
}
+1 -11
View File
@@ -5,21 +5,11 @@ import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
server: {
host: '0.0.0.0',
proxy: {
'/api': {
target: 'http://127.0.0.1:9119',
changeOrigin: true
},
'/api/ulocal/ws': {
target: 'ws://127.0.0.1:9119',
rewriteWsOrigin: true,
ws: true,
},
'/ushare': {
target: 'http://127.0.0.1:9119',
changeOrigin: true
},
}
}
}
})
+6 -31
View File
@@ -3,75 +3,50 @@ module github.com/loveuer/ushare
go 1.24.2
require (
github.com/glebarez/sqlite v1.11.0
github.com/google/uuid v1.6.0
github.com/gorilla/websocket v1.5.3
github.com/jedib0t/go-pretty/v6 v6.6.7
github.com/loveuer/nf v0.3.5
github.com/matoous/go-nanoid/v2 v2.1.0
github.com/mileusna/useragent v1.3.5
github.com/pkg/errors v0.9.1
github.com/spf13/cast v1.7.1
github.com/spf13/viper v1.20.1
golang.org/x/crypto v0.32.0
gorm.io/driver/mysql v1.6.0
gorm.io/driver/postgres v1.6.0
gorm.io/gorm v1.30.0
)
require (
dario.cat/mergo v1.0.0 // indirect
filippo.io/edwards25519 v1.1.0 // indirect
github.com/Microsoft/go-winio v0.6.1 // indirect
github.com/ProtonMail/go-crypto v1.0.0 // indirect
github.com/cloudflare/circl v1.3.7 // indirect
github.com/cyphar/filepath-securejoin v0.2.4 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/emirpasic/gods v1.18.1 // indirect
github.com/fatih/color v1.17.0 // indirect
github.com/fsnotify/fsnotify v1.8.0 // indirect
github.com/glebarez/go-sqlite v1.21.2 // indirect
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect
github.com/go-git/go-billy/v5 v5.5.0 // indirect
github.com/go-git/go-git/v5 v5.12.0 // indirect
github.com/go-sql-driver/mysql v1.8.1 // indirect
github.com/go-viper/mapstructure/v2 v2.2.1 // indirect
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/pgx/v5 v5.6.0 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
github.com/kevinburke/ssh_config v1.2.0 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-runewidth v0.0.16 // indirect
github.com/pelletier/go-toml/v2 v2.2.3 // indirect
github.com/pjbgf/sha1cd v0.3.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/sagikazarmark/locafero v0.7.0 // indirect
github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect
github.com/skeema/knownhosts v1.2.2 // indirect
github.com/sourcegraph/conc v0.3.0 // indirect
github.com/spf13/afero v1.12.0 // indirect
github.com/spf13/cast v1.7.1 // indirect
github.com/spf13/pflag v1.0.6 // indirect
github.com/subosito/gotenv v1.6.0 // indirect
github.com/xanzy/ssh-agent v0.3.3 // indirect
go.uber.org/atomic v1.9.0 // indirect
go.uber.org/multierr v1.9.0 // indirect
golang.org/x/crypto v0.32.0 // indirect
golang.org/x/mod v0.17.0 // indirect
golang.org/x/net v0.33.0 // indirect
golang.org/x/sync v0.11.0 // indirect
golang.org/x/sys v0.30.0 // indirect
golang.org/x/text v0.22.0 // indirect
golang.org/x/sync v0.10.0 // indirect
golang.org/x/sys v0.29.0 // indirect
golang.org/x/text v0.21.0 // indirect
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d // indirect
gopkg.in/warnings.v0 v0.1.2 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
modernc.org/libc v1.22.5 // indirect
modernc.org/mathutil v1.5.0 // indirect
modernc.org/memory v1.5.0 // indirect
modernc.org/sqlite v1.23.1 // indirect
)
+8 -64
View File
@@ -1,7 +1,5 @@
dario.cat/mergo v1.0.0 h1:AGCNq9Evsj31mOgNPcLyXc+4PNABt905YmuqPYYpBWk=
dario.cat/mergo v1.0.0/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk=
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY=
github.com/Microsoft/go-winio v0.6.1 h1:9/kr64B9VUZrLm5YYwbGtUJnMgqWVOdUAXu6Migciow=
github.com/Microsoft/go-winio v0.6.1/go.mod h1:LRdKpFKfdobln8UmuiYcKPot9D2v6svN5+sAH+4kjUM=
@@ -20,8 +18,6 @@ github.com/cyphar/filepath-securejoin v0.2.4/go.mod h1:aPGpWjXOXUn2NCNjFvBE6aRxG
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/elazarl/goproxy v0.0.0-20230808193330-2592e75ae04a h1:mATvB/9r/3gvcejNsXKSkQ6lcIaNec2nyfOdlTBR2lU=
github.com/elazarl/goproxy v0.0.0-20230808193330-2592e75ae04a/go.mod h1:Ro8st/ElPeALwNFlcTpWmkr6IoMFfkjXAvTHpevnDsM=
github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc=
@@ -32,10 +28,6 @@ github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHk
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/fsnotify/fsnotify v1.8.0 h1:dAwr6QBTBZIkG8roQaJjGof0pp0EeF+tNV7YBP3F/8M=
github.com/fsnotify/fsnotify v1.8.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/glebarez/go-sqlite v1.21.2 h1:3a6LFC4sKahUunAmynQKLZceZCOzUthkRkEAl9gAXWo=
github.com/glebarez/go-sqlite v1.21.2/go.mod h1:sfxdZyhQjTM2Wry3gVYWaW072Ri1WMdWJi0k6+3382k=
github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw=
github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ=
github.com/gliderlabs/ssh v0.3.7 h1:iV3Bqi942d9huXnzEF2Mt+CY9gLu8DNM4Obd+8bODRE=
github.com/gliderlabs/ssh v0.3.7/go.mod h1:zpHEXBstFnQYtGnB8k8kQLol82umzn/2/snG7alWVD8=
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI=
@@ -46,36 +38,16 @@ github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399 h1:eMj
github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399/go.mod h1:1OCfN199q1Jm3HZlxleg+Dw/mwps2Wbk9frAWm+4FII=
github.com/go-git/go-git/v5 v5.12.0 h1:7Md+ndsjrzZxbddRDZjF14qK+NN56sy6wkqaVrjZtys=
github.com/go-git/go-git/v5 v5.12.0/go.mod h1:FTM9VKtnI2m65hNI/TenDDDnUf2Q9FHnXYjuz9i5OEY=
github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y=
github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
github.com/go-viper/mapstructure/v2 v2.2.1 h1:ZAaOCxANMuZx5RCeg0mBdEZk7DZasvvZIxtHqx8aGss=
github.com/go-viper/mapstructure/v2 v2.2.1/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE=
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/pprof v0.0.0-20240227163752-401108e1b7e7 h1:y3N7Bm7Y9/CtpiVkw/ZWj6lSlDF3F74SfKwfTCer72Q=
github.com/google/pprof v0.0.0-20240227163752-401108e1b7e7/go.mod h1:czg5+yv1E0ZGTi6S6vVK1mke0fV+FaUhNGcd6VRS9Ik=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.6.0 h1:SWJzexBzPL5jb0GEsrPMLIsi/3jOo7RHlzTjcAeDrPY=
github.com/jackc/pgx/v5 v5.6.0/go.mod h1:DNZ/vlrUnhWCoFGxHAG8U2ljioxukquj7utPDgtQdTw=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A=
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo=
github.com/jedib0t/go-pretty/v6 v6.6.7 h1:m+LbHpm0aIAPLzLbMfn8dc3Ht8MW7lsSO4MPItz/Uuo=
github.com/jedib0t/go-pretty/v6 v6.6.7/go.mod h1:YwC5CE4fJ1HFUDeivSV1r//AmANFHyqczZk+U6BDALU=
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
github.com/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4gf13a4=
github.com/kevinburke/ssh_config v1.2.0/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
@@ -87,17 +59,11 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/loveuer/nf v0.3.5 h1:DlgTa6Rx8D3/VtH9e0fLGAxmPwSYd7b3nfBltSMuypE=
github.com/loveuer/nf v0.3.5/go.mod h1:IAq0K1c/mlNQzLBvUzAD1LCWiVlt2GqTMPdDjej3Ryo=
github.com/matoous/go-nanoid/v2 v2.1.0 h1:P64+dmq21hhWdtvZfEAofnvJULaRR1Yib0+PnU669bE=
github.com/matoous/go-nanoid/v2 v2.1.0/go.mod h1:KlbGNQ+FhrUNIHUxZdL63t7tl4LaPkZNpUULS8H4uVM=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/mileusna/useragent v1.3.5 h1:SJM5NzBmh/hO+4LGeATKpaEX9+b4vcGg2qXGLiNGDws=
github.com/mileusna/useragent v1.3.5/go.mod h1:3d8TOmwL/5I8pJjyVDteHtgDGcefrFUX4ccGOMKNYYc=
github.com/onsi/gomega v1.27.10 h1:naR28SdDFlqrG6kScpT8VWpu1xWY5nJRCF3XaYyBjhI=
github.com/onsi/gomega v1.27.10/go.mod h1:RsS8tutOdbdgzbPtzzATp12yT7kM5I5aElG3evPbQ0M=
github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M=
@@ -108,12 +74,6 @@ github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M=
github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA=
github.com/sagikazarmark/locafero v0.7.0 h1:5MqpDsTGNDhY8sGp0Aowyf0qKsPrhewaLSsFaodPcyo=
@@ -137,7 +97,6 @@ github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
@@ -172,8 +131,8 @@ golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.11.0 h1:GGz8+XQP4FvTTrjZPzNKTMFtSXH80RAzG+5ghFPgK9w=
golang.org/x/sync v0.11.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ=
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@@ -188,15 +147,15 @@ golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU=
golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.2.0/go.mod h1:TVmDHMZPmdnySmBfhjOoOdhjzdE1h4u1VwSiw2l1Nuc=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U=
golang.org/x/term v0.29.0 h1:L6pJp37ocefwRRtYPKSWOWzOtWSxVajvz2ldH/xi3iU=
golang.org/x/term v0.29.0/go.mod h1:6bl4lRlvVuDgSf3179VpIxBF0o10JUpXWOnI7nErv7s=
golang.org/x/term v0.28.0 h1:/Ts8HFuMR2E6IP/jlo7QVLZHggjKQbhu/7H0LJFr3Gg=
golang.org/x/term v0.28.0/go.mod h1:Sw/lC2IAUZ92udQNf3WodGtn4k/XoLyZoh8v/8uiwek=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
@@ -204,8 +163,8 @@ golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM=
golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
@@ -221,20 +180,5 @@ gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME=
gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gorm.io/driver/mysql v1.6.0 h1:eNbLmNTpPpTOVZi8MMxCi2aaIm0ZpInbORNXDwyLGvg=
gorm.io/driver/mysql v1.6.0/go.mod h1:D/oCC2GWK3M/dqoLxnOlaNKmXz8WNTfcS9y5ovaSqKo=
gorm.io/driver/postgres v1.6.0 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4=
gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo=
gorm.io/gorm v1.30.0 h1:qbT5aPv1UH8gI99OsRlvDToLxW5zR7FzS9acZDOZcgs=
gorm.io/gorm v1.30.0/go.mod h1:8Z33v652h4//uMA76KjeDH8mJXPm1QNCYrMeatR0DOE=
modernc.org/libc v1.22.5 h1:91BNch/e5B0uPbJFgqbxXuOnxBQjlS//icfQEGmvyjE=
modernc.org/libc v1.22.5/go.mod h1:jj+Z7dTNX8fBScMVNRAYZ/jF91K8fdT2hYMThc3YjBY=
modernc.org/mathutil v1.5.0 h1:rV0Ko/6SfM+8G+yKiyI830l3Wuz1zRutdslNoQ0kfiQ=
modernc.org/mathutil v1.5.0/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E=
modernc.org/memory v1.5.0 h1:N+/8c5rE6EqugZwHii4IFsaJ7MUhoWX07J5tC/iI5Ds=
modernc.org/memory v1.5.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU=
modernc.org/sqlite v1.23.1 h1:nrSBg4aRQQwq59JpvGEQ15tNxoO5pX/kUjcRNwSAGQM=
modernc.org/sqlite v1.23.1/go.mod h1:OrDj17Mggn6MhE+iPbBNf7RGKODDE9NFT0f3EwDzJqk=
+4 -48
View File
@@ -2,67 +2,23 @@ package api
import (
"context"
"net"
"net/http"
"github.com/loveuer/nf"
"github.com/loveuer/nf/nft/log"
"github.com/loveuer/nf/nft/tool"
"github.com/loveuer/ushare/internal/handler"
"github.com/loveuer/ushare/internal/model"
"github.com/loveuer/ushare/internal/opt"
"net"
"net/http"
)
func Start(ctx context.Context) <-chan struct{} {
app := nf.New(nf.Config{BodyLimit: 10 * 1024 * 1024 * 1024})
app.Get("/api/healthz", func(c *nf.Ctx) error {
app.Get("/api/available", func(c *nf.Ctx) error {
return c.SendStatus(http.StatusOK)
})
// Auth
app.Post("/api/uauth/login", handler.AuthLogin())
app.Get("/api/uauth/me", handler.AuthVerify(), handler.AuthMe())
// File sharing
app.Get("/ushare/:code", handler.Fetch())
app.Put("/api/ushare/:filename", handler.AuthVerify(), handler.AuthPermission(model.PermUpload), handler.ShareNew())
app.Post("/api/ushare/:code", handler.ShareUpload())
// Local sharing (WebRTC signaling)
{
api := app.Group("/api/ulocal")
api.Post("/register", handler.LocalRegister())
api.Post("/offer", handler.LocalOffer())
api.Post("/answer", handler.LocalAnswer())
api.Post("/candidate", handler.LocalCandidate())
api.Get("/clients", handler.LocalClients())
api.Get("/ws", handler.LocalWS())
}
// Admin
{
api := app.Group("/api/admin")
api.Get("/users", handler.AuthVerify(), handler.AuthPermission(model.PermUserManage), handler.AdminListUsers())
api.Post("/users", handler.AuthVerify(), handler.AuthPermission(model.PermUserManage), handler.AdminCreateUser())
api.Put("/users/:id", handler.AuthVerify(), handler.AuthPermission(model.PermUserManage), handler.AdminUpdateUser())
api.Delete("/users/:id", handler.AuthVerify(), handler.AuthPermission(model.PermUserManage), handler.AdminDeleteUser())
api.Get("/roles", handler.AuthVerify(), handler.AuthPermission(model.PermUserManage), handler.AdminListRoles())
}
// Token management
{
api := app.Group("/api/token")
api.Get("", handler.AuthVerify(), handler.AuthPermission(model.PermTokenManage), handler.TokenList())
api.Post("", handler.AuthVerify(), handler.AuthPermission(model.PermTokenManage), handler.TokenCreate())
api.Delete("", handler.AuthVerify(), handler.AuthPermission(model.PermTokenManage), handler.TokenDelete())
}
// API v1 - token-authenticated file upload
app.Put("/api/v1/upload/:filename", handler.AuthVerify(), handler.AuthPermission(model.PermUpload), handler.ShareAPIUpload())
// Frontend static files
app.Use(handler.ServeFrontendMiddleware())
app.Get("/api/share/fetch", handler.Fetch())
ready := make(chan struct{})
ln, err := net.Listen("tcp", opt.Cfg.Address)
-284
View File
@@ -1,284 +0,0 @@
package controller
import (
"context"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"sync"
"time"
gonanoid "github.com/matoous/go-nanoid/v2"
"github.com/loveuer/nf/nft/log"
"github.com/loveuer/ushare/internal/model"
"github.com/loveuer/ushare/internal/opt"
"github.com/pkg/errors"
"github.com/spf13/viper"
)
type metaInfo struct {
f *os.File
name string
create time.Time
last time.Time
size int64
cursor int64
user string
maxDownloads int
expiresAt int64
}
func (m *metaInfo) generateMeta(code string) error {
content := fmt.Sprintf(
"filename=%s\ncreated_at=%d\nsize=%d\nuploader=%s\nmax_downloads=%d\nexpires_at=%d\ndownloads=0",
m.name, m.create.UnixMilli(), m.size, m.user, m.maxDownloads, m.expiresAt,
)
return os.WriteFile(opt.MetaPath(code), []byte(content), 0644)
}
type meta struct {
sync.Mutex
ctx context.Context
m map[string]*metaInfo
}
var (
MetaManager = &meta{m: make(map[string]*metaInfo)}
)
const letters = "1234567890ABCDEFGHIJKLMNOPQRSTUVWXYZ"
// New creates a new upload session.
// maxDownloads: 0 = unlimited; expiresIn: seconds from now (minimum opt.MinExpiresIn).
func (m *meta) New(size int64, filename, ip string, maxDownloads int, expiresIn int64) (string, error) {
now := time.Now()
if expiresIn < opt.MinExpiresIn {
expiresIn = opt.DefaultExpiresIn
}
if maxDownloads < 0 {
maxDownloads = 0
}
code, err := gonanoid.Generate(letters, opt.CodeLength)
if err != nil {
return "", err
}
f, err := os.Create(opt.FilePath(code))
if err != nil {
return "", err
}
if err = f.Truncate(size); err != nil {
f.Close()
return "", err
}
m.Lock()
defer m.Unlock()
m.m[code] = &metaInfo{
f: f,
name: filename,
last: now,
size: size,
cursor: 0,
create: now,
user: ip,
maxDownloads: maxDownloads,
expiresAt: now.Unix() + expiresIn,
}
return code, nil
}
func (m *meta) Write(code string, start, end int64, reader io.Reader) (total, cursor int64, err error) {
m.Lock()
defer m.Unlock()
if _, ok := m.m[code]; !ok {
return 0, 0, fmt.Errorf("code not exist")
}
w, err := io.CopyN(m.m[code].f, reader, end-start+1)
if err != nil {
return 0, 0, err
}
m.m[code].cursor += w
m.m[code].last = time.Now()
total = m.m[code].size
cursor = m.m[code].cursor
if m.m[code].cursor == m.m[code].size {
defer delete(m.m, code)
if err = m.m[code].generateMeta(code); err != nil {
return 0, 0, err
}
}
return total, cursor, nil
}
// CheckAndIncrDownload reads the meta file, validates expiry and download limit,
// increments the download counter, and writes the meta file back.
// Returns the meta on success, or an error if the file is unavailable.
func (m *meta) CheckAndIncrDownload(code string) (*model.Meta, error) {
m.Lock()
defer m.Unlock()
metaPath := opt.MetaPath(code)
v := viper.New()
v.SetConfigFile(metaPath)
v.SetConfigType("env")
if err := v.ReadInConfig(); err != nil {
return nil, errors.New("文件不存在或已过期")
}
info := new(model.Meta)
if err := v.Unmarshal(info); err != nil {
return nil, errors.New("文件元数据损坏")
}
now := time.Now().Unix()
// Check expiry
if info.ExpiresAt > 0 && now > info.ExpiresAt {
// Clean up expired files
go func() {
_ = os.RemoveAll(opt.FilePath(code))
_ = os.RemoveAll(metaPath)
}()
return nil, errors.New("文件已过期")
}
// Check download limit
if info.MaxDownloads > 0 && info.Downloads >= info.MaxDownloads {
return nil, errors.New("文件下载次数已达上限")
}
// Increment downloads and write back
info.Downloads++
content := fmt.Sprintf(
"filename=%s\ncreated_at=%d\nsize=%d\nuploader=%s\nmax_downloads=%d\nexpires_at=%d\ndownloads=%d",
info.Filename, info.CreatedAt, info.Size, info.Uploader,
info.MaxDownloads, info.ExpiresAt, info.Downloads,
)
if err := os.WriteFile(metaPath, []byte(content), 0644); err != nil {
log.Warn("meta.CheckAndIncrDownload: write back failed: %s", err.Error())
}
// If this was the last allowed download, clean up after serving
if info.MaxDownloads > 0 && info.Downloads >= info.MaxDownloads {
go func() {
time.Sleep(5 * time.Second)
_ = os.RemoveAll(opt.FilePath(code))
_ = os.RemoveAll(metaPath)
}()
}
return info, nil
}
func (m *meta) Start(ctx context.Context) {
ticker := time.NewTicker(time.Minute)
m.ctx = ctx
if err := os.MkdirAll(opt.Cfg.DataPath, 0644); err != nil {
log.Fatal("controller.MetaManager.Start: mkdir datapath failed, path = %s, err = %s", opt.Cfg.DataPath, err.Error())
}
// Clean uploads with no activity for 2 minutes
go func() {
for {
select {
case <-ctx.Done():
return
case now := <-ticker.C:
for code, info := range m.m {
if now.Sub(info.last) > 2*time.Minute {
m.Lock()
if err := info.f.Close(); err != nil {
log.Warn("handler.Meta: [timer] close file failed, file = %s, err = %s", opt.FilePath(code), err.Error())
}
if err := os.RemoveAll(opt.FilePath(code)); err != nil {
log.Warn("handler.Meta: [timer] remove file failed, file = %s, err = %s", opt.FilePath(code), err.Error())
}
delete(m.m, code)
m.Unlock()
log.Warn("MetaController: code timeout removed, code = %s", code)
}
}
}
}
}()
// Clean expired files by walking the data directory
go func() {
if opt.Cfg.CleanInterval <= 0 {
log.Warn("meta.Clean: no clean interval set, plz clean manual!!!")
return
}
ticker := time.NewTicker(5 * time.Minute)
duration := time.Duration(opt.Cfg.CleanInterval) * time.Hour
for {
select {
case <-ctx.Done():
return
case now := <-ticker.C:
_ = filepath.Walk(opt.Cfg.DataPath, func(path string, info os.FileInfo, err error) error {
if info == nil {
return nil
}
if info.IsDir() {
return nil
}
name := filepath.Base(info.Name())
if !strings.HasPrefix(name, ".meta.") {
return nil
}
v := viper.New()
v.SetConfigFile(path)
v.SetConfigType("env")
if err = v.ReadInConfig(); err != nil {
return nil
}
mi := new(model.Meta)
if err = v.Unmarshal(mi); err != nil {
return nil
}
code := strings.TrimPrefix(name, ".meta.")
// Remove if past explicit expiry
if mi.ExpiresAt > 0 && now.Unix() > mi.ExpiresAt {
log.Debug("controller.meta: file expired, code = %s", code)
_ = os.RemoveAll(opt.FilePath(code))
_ = os.RemoveAll(path)
return nil
}
// Remove if past global clean interval
if now.Sub(time.UnixMilli(mi.CreatedAt)) > duration {
log.Debug("controller.meta: file out of date, code = %s", code)
_ = os.RemoveAll(opt.FilePath(code))
_ = os.RemoveAll(path)
}
return nil
})
}
}
}()
}
-277
View File
@@ -1,277 +0,0 @@
// room controller:
// local share websocket room controller
// same remote IP as a
package controller
import (
"context"
"encoding/json"
"github.com/google/uuid"
"github.com/gorilla/websocket"
"github.com/loveuer/nf/nft/log"
"github.com/loveuer/ushare/internal/pkg/tool"
"github.com/mileusna/useragent"
"sync"
"time"
)
type RoomClientType string
const (
ClientTypeDesktop RoomClientType = "desktop"
ClientTypeMobile RoomClientType = "mobile"
ClientTypeTablet RoomClientType = "tablet"
)
type RoomAppType string
const (
RoomAppTypeWeb = "web"
)
type RoomMessageType string
const (
RoomMessageTypeEnter RoomMessageType = "enter"
RoomMessageTypeLeave RoomMessageType = "leave"
)
type RoomOffer struct {
SDP string `json:"sdp"`
Type string `json:"type"`
}
type RoomCandidate struct {
Candidate string `json:"candidate"`
SdpMid string `json:"sdpMid"`
SdpMLineIndex int `json:"sdpMLineIndex"`
UsernameFragment string `json:"usernameFragment"`
}
type roomClient struct {
sync.Mutex
controller *roomController
conn *websocket.Conn
ClientType RoomClientType `json:"client_type"`
AppType RoomAppType `json:"app_type"`
IP string `json:"ip"`
Name string `json:"name"`
Id string `json:"id"`
RegisterAt time.Time `json:"register_at"`
msgChan chan any
}
func (rc *roomClient) start(ctx context.Context) {
// start write
go func() {
for {
select {
case <-ctx.Done():
_ = rc.conn.Close()
return
case msg, _ := <-rc.msgChan:
err := rc.conn.WriteJSON(msg)
log.Debug("RoomClient: write json message, IP = %s, Id = %s, Name = %s, err = %v", rc.IP, rc.Id, rc.Name, err)
if err != nil {
log.Error("RoomClient: write json message failed, IP = %s, Id = %s, Name = %s, err = %s", rc.IP, rc.Id, rc.Name, err.Error())
}
}
}
}()
// start read
go func() {
for {
mt, bs, err := rc.conn.ReadMessage()
if err != nil {
log.Error("RoomClient: read message failed, IP = %s, Id = %s, Name = %s, err = %s", rc.IP, rc.Id, rc.Name, err.Error())
rc.controller.Unregister(rc)
return
}
switch mt {
case websocket.PingMessage:
rs, _ := json.Marshal(map[string]any{"type": "pong", "time": time.Now().UnixMilli(), "Id": rc.Id, "Name": rc.Name})
if err := rc.conn.WriteMessage(websocket.PongMessage, rs); err != nil {
log.Error("RoomClient: response ping message failed, IP = %s, Id = %s, Name = %s, err = %s", rc.IP, rc.Id, rc.Name, err.Error())
}
case websocket.CloseMessage:
log.Debug("RoomClient: received close message, unregister IP = %s Id = %s, Name = %s", rc.IP, rc.Id, rc.Name)
rc.controller.Unregister(rc)
return
case websocket.TextMessage:
log.Debug("RoomClient: received text message, IP = %s, Id = %s, Name = %s, text = %s", rc.IP, rc.Id, rc.Name, string(bs))
case websocket.BinaryMessage:
log.Debug("RoomClient: received bytes message, IP = %s, Id = %s, Name = %s, text = %s", rc.IP, rc.Id, rc.Name, string(bs))
// todo
//msg := new(model.Message)
//if err = json.Unmarshal(bs, msg); err != nil {
// log.Error("RoomClient: unmarshal message failed, id = %s, name = %s, err = %s", rc.Id, rc.Name, err.Error())
// continue
//}
//
//switch msg.Type {
//case model.WSMessageTypeOffer:
// rc.Lock()
// rc.Offer = msg.Body
// rc.Unlock()
//case model.WSMessageTypeCandidate:
// rc.Lock()
// rc.Candidate = msg.Body
// rc.Unlock()
//}
}
}
}()
}
type roomController struct {
sync.Mutex
ctx context.Context
//rooms map[string]map[string]*roomClient // map[room_id(remote-IP)][Id]
pre map[string]*roomClient
clients map[string]*roomClient
}
var (
RoomController = &roomController{
pre: make(map[string]*roomClient),
clients: make(map[string]*roomClient),
}
)
func (rc *roomController) Start(ctx context.Context) {
rc.ctx = ctx
}
func (rc *roomController) Register(ip, userAgent string) *roomClient {
nrc := &roomClient{
controller: rc,
ClientType: ClientTypeDesktop,
AppType: RoomAppTypeWeb,
IP: ip,
Id: uuid.Must(uuid.NewV7()).String(),
Name: tool.RandomName(),
msgChan: make(chan any, 1),
RegisterAt: time.Now(),
}
ua := useragent.Parse(userAgent)
switch {
case ua.Mobile:
nrc.ClientType = ClientTypeMobile
case ua.Tablet:
nrc.ClientType = ClientTypeTablet
}
rc.Lock()
defer rc.Unlock()
rc.pre[nrc.Id] = nrc
return nrc
}
func (rc *roomController) Enter(conn *websocket.Conn, id string) *roomClient {
log.Debug("controller.room: registry client, id = %s", id)
rc.Lock()
defer rc.Unlock()
nrc, ok := rc.pre[id]
if !ok {
return nil
}
nrc.conn = conn
nrc.start(rc.ctx)
rc.Broadcast(map[string]any{"type": "enter", "time": time.Now().UnixMilli(), "body": nrc})
delete(rc.pre, nrc.Id)
rc.clients[nrc.Id] = nrc
return nrc
}
func (rc *roomController) List() []*roomClient {
clientList := make([]*roomClient, 0)
for _, client := range rc.clients {
clientList = append(clientList, client)
}
return clientList
}
func (rc *roomController) Broadcast(msg any) {
for _, client := range rc.clients {
select {
case client.msgChan <- msg:
case <-time.After(2 * time.Second):
log.Warn("RoomController: broadcast timeout, client Id = %s, IP = %s", client.Id, client.IP)
}
}
}
func (rc *roomController) Unregister(client *roomClient) {
log.Debug("controller.room: unregister client, IP = %s, Id = %s, Name = %s", client.IP, client.Id, client.Name)
rc.Lock()
delete(rc.clients, client.Id)
rc.Unlock()
rc.Broadcast(map[string]any{"type": RoomMessageTypeLeave, "time": time.Now().UnixMilli(), "body": client})
}
func (rc *roomController) Offer(id, from string, offer *RoomOffer) {
if _, ok := rc.clients[id]; !ok {
return
}
rc.clients[id].msgChan <- map[string]any{
"type": "offer",
"time": time.Now().UnixMilli(),
"data": map[string]any{
"id": id,
"from": from,
"offer": offer,
},
}
}
func (rc *roomController) Answer(id string, answer *RoomOffer) {
if _, ok := rc.clients[id]; !ok {
return
}
rc.clients[id].msgChan <- map[string]any{
"type": "answer",
"time": time.Now().UnixMilli(),
"data": map[string]any{
"id": id,
"answer": answer,
},
}
}
func (rc *roomController) Candidate(id string, candidate *RoomCandidate) {
if _, ok := rc.clients[id]; !ok {
return
}
for _, client := range rc.clients {
if client.Id == id {
continue
}
client.msgChan <- map[string]any{
"type": "candidate",
"time": time.Now().UnixMilli(),
"data": map[string]any{
"id": client.Id,
"candidate": candidate,
},
}
}
}
-105
View File
@@ -1,105 +0,0 @@
package controller
import (
"strings"
"time"
"github.com/loveuer/ushare/internal/model"
"github.com/loveuer/ushare/internal/pkg/db"
"github.com/loveuer/ushare/internal/pkg/tool"
"github.com/pkg/errors"
)
type tokenManager struct{}
var TokenManager = &tokenManager{}
// List returns all tokens belonging to a user (token value is not exposed).
func (tm *tokenManager) List(userID uint) ([]model.Token, error) {
var tokens []model.Token
if err := db.Default.Session().Where("user_id = ?", userID).Order("created_at desc").Find(&tokens).Error; err != nil {
return nil, errors.Wrap(err, "list tokens failed")
}
return tokens, nil
}
// Create generates a new API token for the given user and returns the full token value (only shown once).
func (tm *tokenManager) Create(userID uint, name string) (*model.Token, string, error) {
name = strings.TrimSpace(name)
if name == "" {
return nil, "", errors.New("token 名称不能为空")
}
rawToken := model.TokenPrefix + tool.RandomString(32)
t := &model.Token{
UserID: userID,
Name: name,
Token: rawToken,
}
if err := db.Default.Session().Create(t).Error; err != nil {
return nil, "", errors.Wrap(err, "create token failed")
}
return t, rawToken, nil
}
// Delete removes a token by ID, only if it belongs to the given user.
func (tm *tokenManager) Delete(userID uint, tokenID uint) error {
result := db.Default.Session().
Where("id = ? AND user_id = ?", tokenID, userID).
Delete(&model.Token{})
if result.Error != nil {
return errors.Wrap(result.Error, "delete token failed")
}
if result.RowsAffected == 0 {
return errors.New("token 不存在或无权限删除")
}
return nil
}
// Verify looks up a DB API token and returns a Session if valid.
func (tm *tokenManager) Verify(rawToken string) (*model.Session, error) {
var t model.Token
if err := db.Default.Session().Where("token = ?", rawToken).First(&t).Error; err != nil {
return nil, errors.New("无效的 API Token")
}
if t.ExpiresAt != nil && time.Now().After(*t.ExpiresAt) {
return nil, errors.New("API Token 已过期")
}
var user model.User
if err := db.Default.Session().First(&user, t.UserID).Error; err != nil {
return nil, errors.New("Token 关联用户不存在")
}
if !user.Active {
return nil, errors.New("账号已被禁用")
}
var role model.Role
if err := db.Default.Session().First(&role, user.RoleID).Error; err != nil {
return nil, errors.New("账号角色异常")
}
// Update last_used_at asynchronously
now := time.Now()
go db.Default.Session().Model(&t).Update("last_used_at", now) //nolint:errcheck
session := &model.Session{
UserID: user.ID,
Username: user.Username,
Role: role.Name,
RoleLabel: role.Label,
Permissions: role.PermissionList(),
LoginAt: now.Unix(),
Token: rawToken,
}
return session, nil
}
-158
View File
@@ -1,158 +0,0 @@
package controller
import (
"context"
"strings"
"sync"
"time"
"github.com/loveuer/nf/nft/log"
"github.com/loveuer/ushare/internal/model"
"github.com/loveuer/ushare/internal/opt"
"github.com/loveuer/ushare/internal/pkg/db"
"github.com/loveuer/ushare/internal/pkg/tool"
"github.com/pkg/errors"
)
type userManager struct {
sync.Mutex
ctx context.Context
sessions map[string]*model.Session
}
var UserManager = &userManager{
sessions: make(map[string]*model.Session),
}
func (um *userManager) seed(ctx context.Context) error {
// Seed default roles if they don't exist
defaultRoles := []model.Role{
{
Name: model.RoleAdmin,
Label: "管理员",
Permissions: strings.Join([]string{model.PermUserManage, model.PermUpload, model.PermTokenManage}, ","),
},
{
Name: model.RoleUser,
Label: "用户",
Permissions: model.PermUpload,
},
}
for i := range defaultRoles {
role := &defaultRoles[i]
var existing model.Role
if err := db.Default.Session(ctx).Where("name = ?", role.Name).First(&existing).Error; err != nil {
if err := db.Default.Session(ctx).Create(role).Error; err != nil {
return errors.Wrap(err, "seed role failed")
}
log.Debug("controller.userManager.seed: created role %s", role.Name)
}
}
// Seed default admin user only if no users exist
var count int64
db.Default.Session(ctx).Model(&model.User{}).Count(&count)
if count > 0 {
return nil
}
var adminRole model.Role
if err := db.Default.Session(ctx).Where("name = ?", model.RoleAdmin).First(&adminRole).Error; err != nil {
return errors.Wrap(err, "get admin role failed")
}
username := opt.Cfg.Username
if username == "" {
username = "admin"
}
// opt.Cfg.Password is already hashed by opt.Init(); store it directly.
adminUser := &model.User{
Username: username,
Password: opt.Cfg.Password,
RoleID: adminRole.ID,
Active: true,
}
if err := db.Default.Session(ctx).Create(adminUser).Error; err != nil {
return errors.Wrap(err, "seed admin user failed")
}
log.Debug("controller.userManager.seed: created admin user %s", username)
return nil
}
func (um *userManager) Login(username, password string) (*model.Session, error) {
now := time.Now()
user := new(model.User)
if err := db.Default.Session().
Where("username = ? AND active = ?", username, true).
First(user).Error; err != nil {
return nil, errors.New("账号或密码错误")
}
if !tool.ComparePassword(password, user.Password) {
return nil, errors.New("账号或密码错误")
}
var role model.Role
if err := db.Default.Session().First(&role, user.RoleID).Error; err != nil {
return nil, errors.New("账号角色异常,请联系管理员")
}
session := &model.Session{
UserID: user.ID,
Username: user.Username,
Role: role.Name,
RoleLabel: role.Label,
Permissions: role.PermissionList(),
LoginAt: now.Unix(),
Token: tool.RandomString(32),
}
um.Lock()
defer um.Unlock()
um.sessions[session.Token] = session
return session, nil
}
func (um *userManager) Verify(token string) (*model.Session, error) {
um.Lock()
defer um.Unlock()
session, ok := um.sessions[token]
if !ok {
return nil, errors.New("未登录或凭证已失效, 请重新登录")
}
return session, nil
}
func (um *userManager) Start(ctx context.Context) {
um.ctx = ctx
if err := um.seed(ctx); err != nil {
log.Fatal("controller.userManager.Start: seed failed: %s", err.Error())
}
go func() {
ticker := time.NewTicker(time.Minute)
for {
select {
case <-um.ctx.Done():
return
case now := <-ticker.C:
um.Lock()
for token, session := range um.sessions {
if now.Unix()-session.LoginAt > 8*3600 {
delete(um.sessions, token)
}
}
um.Unlock()
}
}
}()
}
-271
View File
@@ -1,271 +0,0 @@
package handler
import (
"net/http"
"strings"
"time"
"github.com/loveuer/nf"
"github.com/loveuer/nf/nft/log"
"github.com/loveuer/ushare/internal/model"
"github.com/loveuer/ushare/internal/pkg/db"
"github.com/loveuer/ushare/internal/pkg/tool"
"github.com/spf13/cast"
)
// userResp is the JSON response shape for a user including role info,
// built manually at the business layer instead of relying on GORM associations.
type userResp struct {
ID uint `json:"id"`
Username string `json:"username"`
RoleID uint `json:"role_id"`
Role model.Role `json:"role"`
Active bool `json:"active"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
func toUserResp(u model.User, r model.Role) userResp {
return userResp{
ID: u.ID,
Username: u.Username,
RoleID: u.RoleID,
Role: r,
Active: u.Active,
CreatedAt: u.CreatedAt,
UpdatedAt: u.UpdatedAt,
}
}
func AdminListUsers() nf.HandlerFunc {
return func(c *nf.Ctx) error {
var users []model.User
if err := db.Default.Session().Find(&users).Error; err != nil {
log.Error("handler.AdminListUsers: %s", err.Error())
return c.Status(http.StatusInternalServerError).JSON(map[string]string{"msg": "查询失败"})
}
// Collect unique role IDs and query them in one shot
roleIDSet := make(map[uint]struct{})
for _, u := range users {
roleIDSet[u.RoleID] = struct{}{}
}
roleIDs := make([]uint, 0, len(roleIDSet))
for id := range roleIDSet {
roleIDs = append(roleIDs, id)
}
var roles []model.Role
if err := db.Default.Session().Where("id IN ?", roleIDs).Find(&roles).Error; err != nil {
log.Error("handler.AdminListUsers: query roles: %s", err.Error())
return c.Status(http.StatusInternalServerError).JSON(map[string]string{"msg": "查询失败"})
}
roleMap := make(map[uint]model.Role, len(roles))
for _, r := range roles {
roleMap[r.ID] = r
}
resp := make([]userResp, 0, len(users))
for _, u := range users {
resp = append(resp, toUserResp(u, roleMap[u.RoleID]))
}
return c.Status(http.StatusOK).JSON(map[string]any{"data": resp})
}
}
func AdminCreateUser() nf.HandlerFunc {
return func(c *nf.Ctx) error {
type Req struct {
Username string `json:"username"`
Password string `json:"password"`
RoleID uint `json:"role_id"`
}
var req Req
if err := c.BodyParser(&req); err != nil {
return c.Status(http.StatusBadRequest).JSON(map[string]string{"msg": "参数错误"})
}
req.Username = strings.TrimSpace(req.Username)
if req.Username == "" {
return c.Status(http.StatusBadRequest).JSON(map[string]string{"msg": "用户名不能为空"})
}
if req.Password == "" {
return c.Status(http.StatusBadRequest).JSON(map[string]string{"msg": "密码不能为空"})
}
if req.RoleID == 0 {
return c.Status(http.StatusBadRequest).JSON(map[string]string{"msg": "角色不能为空"})
}
if err := tool.CheckPassword(req.Password); err != nil {
return c.Status(http.StatusBadRequest).JSON(map[string]string{"msg": err.Error()})
}
var count int64
db.Default.Session().Model(&model.User{}).Where("username = ?", req.Username).Count(&count)
if count > 0 {
return c.Status(http.StatusBadRequest).JSON(map[string]string{"msg": "用户名已存在"})
}
var role model.Role
if err := db.Default.Session().First(&role, req.RoleID).Error; err != nil {
return c.Status(http.StatusBadRequest).JSON(map[string]string{"msg": "无效的角色"})
}
user := &model.User{
Username: req.Username,
Password: tool.NewPassword(req.Password),
RoleID: req.RoleID,
Active: true,
}
if err := db.Default.Session().Create(user).Error; err != nil {
log.Error("handler.AdminCreateUser: %s", err.Error())
return c.Status(http.StatusInternalServerError).JSON(map[string]string{"msg": "创建用户失败"})
}
return c.Status(http.StatusOK).JSON(map[string]any{"data": toUserResp(*user, role)})
}
}
func AdminUpdateUser() nf.HandlerFunc {
return func(c *nf.Ctx) error {
type Req struct {
RoleID *uint `json:"role_id"`
Active *bool `json:"active"`
Password string `json:"password"`
}
id, err := cast.ToUintE(c.Param("id"))
if err != nil {
return c.Status(http.StatusBadRequest).JSON(map[string]string{"msg": "无效的用户ID"})
}
var req Req
if err := c.BodyParser(&req); err != nil {
return c.Status(http.StatusBadRequest).JSON(map[string]string{"msg": "参数错误"})
}
session := c.Locals("user").(*model.Session)
var user model.User
if err := db.Default.Session().First(&user, id).Error; err != nil {
return c.Status(http.StatusNotFound).JSON(map[string]string{"msg": "用户不存在"})
}
var currentRole model.Role
if err := db.Default.Session().First(&currentRole, user.RoleID).Error; err != nil {
return c.Status(http.StatusInternalServerError).JSON(map[string]string{"msg": "查询角色失败"})
}
updates := map[string]any{}
if req.RoleID != nil && *req.RoleID != user.RoleID {
var newRole model.Role
if err := db.Default.Session().First(&newRole, *req.RoleID).Error; err != nil {
return c.Status(http.StatusBadRequest).JSON(map[string]string{"msg": "无效的角色"})
}
// If demoting from admin, ensure at least one other active admin remains
if currentRole.Name == model.RoleAdmin && newRole.Name != model.RoleAdmin {
var adminCount int64
db.Default.Session().Model(&model.User{}).
Where("role_id = ? AND active = ? AND id != ?", user.RoleID, true, id).
Count(&adminCount)
if adminCount == 0 {
return c.Status(http.StatusBadRequest).JSON(map[string]string{"msg": "无法更改角色: 系统中至少需要一个管理员"})
}
}
updates["role_id"] = *req.RoleID
currentRole = newRole
}
if req.Active != nil && *req.Active != user.Active {
if user.ID == session.UserID && !*req.Active {
return c.Status(http.StatusBadRequest).JSON(map[string]string{"msg": "不能禁用自己的账号"})
}
if currentRole.Name == model.RoleAdmin && !*req.Active {
var adminCount int64
db.Default.Session().Model(&model.User{}).
Where("role_id = ? AND active = ? AND id != ?", user.RoleID, true, id).
Count(&adminCount)
if adminCount == 0 {
return c.Status(http.StatusBadRequest).JSON(map[string]string{"msg": "无法禁用: 系统中至少需要一个启用的管理员"})
}
}
updates["active"] = *req.Active
}
if req.Password != "" {
if err := tool.CheckPassword(req.Password); err != nil {
return c.Status(http.StatusBadRequest).JSON(map[string]string{"msg": err.Error()})
}
updates["password"] = tool.NewPassword(req.Password)
}
if len(updates) == 0 {
return c.Status(http.StatusBadRequest).JSON(map[string]string{"msg": "没有需要更新的字段"})
}
if err := db.Default.Session().Model(&user).Updates(updates).Error; err != nil {
log.Error("handler.AdminUpdateUser: %s", err.Error())
return c.Status(http.StatusInternalServerError).JSON(map[string]string{"msg": "更新失败"})
}
return c.Status(http.StatusOK).JSON(map[string]any{"data": toUserResp(user, currentRole)})
}
}
func AdminDeleteUser() nf.HandlerFunc {
return func(c *nf.Ctx) error {
id, err := cast.ToUintE(c.Param("id"))
if err != nil {
return c.Status(http.StatusBadRequest).JSON(map[string]string{"msg": "无效的用户ID"})
}
session := c.Locals("user").(*model.Session)
if session.UserID == id {
return c.Status(http.StatusBadRequest).JSON(map[string]string{"msg": "不能删除自己的账号"})
}
var user model.User
if err := db.Default.Session().First(&user, id).Error; err != nil {
return c.Status(http.StatusNotFound).JSON(map[string]string{"msg": "用户不存在"})
}
// Prevent deleting the last admin: check via role name
var userRole model.Role
if err := db.Default.Session().First(&userRole, user.RoleID).Error; err != nil {
return c.Status(http.StatusInternalServerError).JSON(map[string]string{"msg": "查询角色失败"})
}
if userRole.Name == model.RoleAdmin {
var adminCount int64
db.Default.Session().Model(&model.User{}).
Where("role_id = ? AND id != ?", user.RoleID, id).
Count(&adminCount)
if adminCount == 0 {
return c.Status(http.StatusBadRequest).JSON(map[string]string{"msg": "无法删除最后一个管理员"})
}
}
if err := db.Default.Session().Delete(&user).Error; err != nil {
log.Error("handler.AdminDeleteUser: %s", err.Error())
return c.Status(http.StatusInternalServerError).JSON(map[string]string{"msg": "删除失败"})
}
return c.Status(http.StatusOK).JSON(map[string]any{"data": "ok"})
}
}
func AdminListRoles() nf.HandlerFunc {
return func(c *nf.Ctx) error {
var roles []model.Role
if err := db.Default.Session().Find(&roles).Error; err != nil {
log.Error("handler.AdminListRoles: %s", err.Error())
return c.Status(http.StatusInternalServerError).JSON(map[string]string{"msg": "查询失败"})
}
return c.Status(http.StatusOK).JSON(map[string]any{"data": roles})
}
}
-107
View File
@@ -1,107 +0,0 @@
package handler
import (
"fmt"
"net/http"
"strings"
"github.com/loveuer/nf"
"github.com/loveuer/ushare/internal/controller"
"github.com/loveuer/ushare/internal/model"
)
func AuthVerify() nf.HandlerFunc {
tokenFn := func(c *nf.Ctx) (token string) {
if raw := c.Get("Authorization"); raw != "" {
// Strip "Bearer " prefix if present
if strings.HasPrefix(raw, "Bearer ") {
return strings.TrimPrefix(raw, "Bearer ")
}
return raw
}
token = c.Cookies("ushare")
return
}
return func(c *nf.Ctx) error {
token := tokenFn(c)
if token == "" {
return c.Status(http.StatusUnauthorized).JSON(map[string]string{"error": "unauthorized"})
}
var (
session *model.Session
err error
)
// API tokens have the "ust_" prefix; session tokens do not.
if strings.HasPrefix(token, model.TokenPrefix) {
session, err = controller.TokenManager.Verify(token)
} else {
session, err = controller.UserManager.Verify(token)
}
if err != nil {
return c.Status(http.StatusUnauthorized).JSON(map[string]string{"error": "unauthorized", "msg": err.Error()})
}
c.Locals("user", session)
return c.Next()
}
}
func AuthPermission(perm string) nf.HandlerFunc {
return func(c *nf.Ctx) error {
session, ok := c.Locals("user").(*model.Session)
if !ok || session == nil {
return c.Status(http.StatusUnauthorized).JSON(map[string]string{"error": "unauthorized"})
}
for _, p := range session.Permissions {
if p == perm {
return c.Next()
}
}
return c.Status(http.StatusForbidden).JSON(map[string]string{"error": "forbidden", "msg": "权限不足"})
}
}
func AuthMe() nf.HandlerFunc {
return func(c *nf.Ctx) error {
session, ok := c.Locals("user").(*model.Session)
if !ok || session == nil {
return c.Status(http.StatusUnauthorized).JSON(map[string]string{"error": "unauthorized"})
}
return c.Status(http.StatusOK).JSON(map[string]any{"data": session})
}
}
func AuthLogin() nf.HandlerFunc {
return func(c *nf.Ctx) error {
type Req struct {
Username string `json:"username"`
Password string `json:"password"`
}
var (
err error
req Req
session *model.Session
)
if err = c.BodyParser(&req); err != nil {
return c.Status(http.StatusBadRequest).JSON(map[string]string{"msg": "错误的用户名或密码<1>"})
}
if session, err = controller.UserManager.Login(req.Username, req.Password); err != nil {
return c.Status(http.StatusBadRequest).JSON(map[string]string{"msg": err.Error()})
}
header := fmt.Sprintf("ushare=%s; Path=/; Max-Age=%d", session.Token, 8*3600)
c.SetHeader("Set-Cookie", header)
return c.Status(http.StatusOK).JSON(map[string]any{"data": session})
}
}
-126
View File
@@ -1,126 +0,0 @@
package handler
import (
"github.com/gorilla/websocket"
"github.com/loveuer/nf"
"github.com/loveuer/nf/nft/log"
"github.com/loveuer/nf/nft/resp"
"github.com/loveuer/ushare/internal/controller"
"net/http"
)
func LocalRegister() nf.HandlerFunc {
return func(c *nf.Ctx) error {
var (
ip = c.IP(true)
ua = c.Get("User-Agent")
)
client := controller.RoomController.Register(ip, ua)
return resp.Resp200(c, client)
}
}
func LocalClients() nf.HandlerFunc {
return func(c *nf.Ctx) error {
list := controller.RoomController.List()
return resp.Resp200(c, list)
}
}
func LocalWS() nf.HandlerFunc {
upgrader := websocket.Upgrader{
ReadBufferSize: 1024,
WriteBufferSize: 1024,
CheckOrigin: func(r *http.Request) bool {
return true
},
}
return func(c *nf.Ctx) error {
id := c.Query("id")
if id == "" {
return c.Status(http.StatusBadRequest).JSON(map[string]string{"error": "id is empty"})
}
conn, err := upgrader.Upgrade(c.Writer, c.Request, nil)
if err != nil {
log.Error("LocalWS: failed to upgrade websocket connection, err = %s", err.Error())
return err
}
controller.RoomController.Enter(conn, id)
return nil
}
}
func LocalOffer() nf.HandlerFunc {
return func(c *nf.Ctx) error {
type Req struct {
Id string `json:"id"`
From string `json:"from"`
Offer *controller.RoomOffer `json:"offer"`
}
var (
err error
req = new(Req)
)
if err = c.BodyParser(req); err != nil {
return c.Status(http.StatusBadRequest).JSON(map[string]string{"err": err.Error()})
}
controller.RoomController.Offer(req.Id, req.From, req.Offer)
return resp.Resp200(c, req.Offer)
}
}
func LocalAnswer() nf.HandlerFunc {
return func(c *nf.Ctx) error {
type Req struct {
Id string `json:"id"`
Answer *controller.RoomOffer `json:"answer"`
}
var (
err error
req = new(Req)
)
if err = c.BodyParser(req); err != nil {
return c.Status(http.StatusBadRequest).JSON(map[string]string{"err": err.Error()})
}
controller.RoomController.Answer(req.Id, req.Answer)
return resp.Resp200(c, req)
}
}
func LocalCandidate() nf.HandlerFunc {
return func(c *nf.Ctx) error {
type Req struct {
Id string `json:"id"`
Candidate *controller.RoomCandidate `json:"candidate"`
}
var (
err error
req = new(Req)
)
if err = c.BodyParser(req); err != nil {
return c.Status(http.StatusBadRequest).JSON(map[string]string{"err": err.Error()})
}
controller.RoomController.Candidate(req.Id, req.Candidate)
return resp.Resp200(c, req)
}
}
+17 -146
View File
@@ -2,34 +2,36 @@ package handler
import (
"fmt"
"net/http"
"os"
"regexp"
"strings"
"github.com/loveuer/nf"
"github.com/loveuer/nf/nft/log"
"github.com/loveuer/ushare/internal/controller"
"github.com/loveuer/ushare/internal/model"
"github.com/loveuer/ushare/internal/opt"
"github.com/pkg/errors"
"github.com/spf13/cast"
"github.com/spf13/viper"
"net/http"
"os"
)
func Fetch() nf.HandlerFunc {
return func(c *nf.Ctx) error {
code := c.Param("code")
log.Debug("handler.Fetch: code = %s", code)
if _, err := os.Stat(opt.MetaPath(code)); err != nil {
code := c.Query("code")
info := new(model.Meta)
_, err := os.Stat(opt.MetaPath(code))
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return c.Status(http.StatusNotFound).JSON(map[string]string{"msg": "文件不存在"})
}
return c.SendStatus(http.StatusInternalServerError)
}
info, err := controller.MetaManager.CheckAndIncrDownload(code)
if err != nil {
return c.Status(http.StatusGone).JSON(map[string]string{"msg": err.Error()})
viper.SetConfigFile(opt.MetaPath(code))
viper.SetConfigType("env")
if err = viper.ReadInConfig(); err != nil {
return c.SendStatus(http.StatusInternalServerError)
}
if err = viper.Unmarshal(info); err != nil {
return c.SendStatus(http.StatusInternalServerError)
}
c.SetHeader("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, info.Filename))
@@ -38,134 +40,3 @@ func Fetch() nf.HandlerFunc {
return nil
}
}
func ShareNew() nf.HandlerFunc {
return func(c *nf.Ctx) error {
filename := strings.TrimSpace(c.Param("filename"))
if filename == "" {
return c.Status(http.StatusBadRequest).JSON(map[string]string{"msg": "filename required"})
}
size, err := cast.ToInt64E(c.Get(opt.HeaderSize))
if err != nil {
return c.Status(http.StatusBadRequest).JSON(map[string]string{"msg": "miss header: " + opt.HeaderSize})
}
maxDownloads := opt.DefaultMaxDownloads
if v := c.Get(opt.HeaderMaxDownload); v != "" {
if n, err := cast.ToIntE(v); err == nil && n >= 0 {
maxDownloads = n
}
}
expiresIn := int64(opt.DefaultExpiresIn)
if v := c.Get(opt.HeaderExpiresIn); v != "" {
if n, err := cast.ToInt64E(v); err == nil && n >= opt.MinExpiresIn {
expiresIn = n
}
}
code, err := controller.MetaManager.New(size, filename, c.IP(), maxDownloads, expiresIn)
if err != nil {
return c.Status(http.StatusInternalServerError).JSON(map[string]string{"msg": ""})
}
return c.Status(http.StatusOK).JSON(map[string]string{"code": code})
}
}
func ShareUpload() nf.HandlerFunc {
rangeValidator := regexp.MustCompile(`^bytes=\d+-\d+$`)
return func(c *nf.Ctx) error {
code := strings.TrimSpace(c.Param("code"))
if len(code) != opt.CodeLength {
return c.Status(http.StatusBadRequest).JSON(map[string]string{"msg": "invalid file code"})
}
log.Debug("handler.ShareUpload: code = %s", code)
ranger := strings.TrimSpace(c.Get("Range"))
if ranger == "" {
return c.Status(http.StatusBadRequest).JSON(map[string]string{"msg": "miss header: Range"})
}
log.Debug("handler.ShareUpload: code = %s, ranger = %s", code, ranger)
if !rangeValidator.MatchString(ranger) {
log.Warn("handler.ShareUpload: invalid range, ranger = %s", ranger)
return c.Status(http.StatusBadRequest).JSON(map[string]string{"msg": "Range invalid(1)"})
}
strs := strings.Split(strings.TrimPrefix(ranger, "bytes="), "-")
if len(strs) != 2 {
return c.Status(http.StatusBadRequest).JSON(map[string]string{"msg": "Range invalid(2)"})
}
start, err := cast.ToInt64E(strs[0])
if err != nil {
return c.Status(http.StatusBadRequest).JSON(map[string]string{"msg": "Range invalid(3)"})
}
end, err := cast.ToInt64E(strs[1])
if err != nil {
return c.Status(http.StatusBadRequest).JSON(map[string]string{"msg": "Range invalid(4)"})
}
log.Debug("handler.ShareUpload: code = %s, start = %d, end = %d", code, start, end)
total, cursor, err := controller.MetaManager.Write(code, start, end, c.Request.Body)
if err != nil {
log.Error("handler.ShareUpload: write error: %s", err)
return c.Status(http.StatusInternalServerError).JSON(map[string]string{"msg": ""})
}
return c.Status(http.StatusOK).JSON(map[string]any{"size": total, "cursor": cursor})
}
}
// ShareAPIUpload handles one-step file upload via API token.
// PUT /api/v1/upload/:filename
// Optional headers: X-Max-Downloads, X-Expires-In (seconds).
func ShareAPIUpload() nf.HandlerFunc {
return func(c *nf.Ctx) error {
filename := strings.TrimSpace(c.Param("filename"))
if filename == "" {
return c.Status(http.StatusBadRequest).JSON(map[string]string{"msg": "filename required"})
}
size, err := cast.ToInt64E(c.Request.ContentLength)
if err != nil || size <= 0 {
return c.Status(http.StatusBadRequest).JSON(map[string]string{"msg": "Content-Length header required"})
}
maxDownloads := opt.DefaultMaxDownloads
if v := c.Get(opt.HeaderMaxDownload); v != "" {
if n, err := cast.ToIntE(v); err == nil && n >= 0 {
maxDownloads = n
}
}
expiresIn := int64(opt.DefaultExpiresIn)
if v := c.Get(opt.HeaderExpiresIn); v != "" {
if n, err := cast.ToInt64E(v); err == nil && n >= opt.MinExpiresIn {
expiresIn = n
}
}
code, err := controller.MetaManager.New(size, filename, c.IP(), maxDownloads, expiresIn)
if err != nil {
return c.Status(http.StatusInternalServerError).JSON(map[string]string{"msg": "create upload failed"})
}
_, _, err = controller.MetaManager.Write(code, 0, size-1, c.Request.Body)
if err != nil {
log.Error("handler.ShareAPIUpload: write error: %s", err)
return c.Status(http.StatusInternalServerError).JSON(map[string]string{"msg": "upload failed"})
}
return c.Status(http.StatusOK).JSON(map[string]any{
"status": 200,
"data": map[string]string{"code": code},
})
}
}
-114
View File
@@ -1,114 +0,0 @@
package handler
import (
"github.com/loveuer/nf"
"github.com/loveuer/nf/nft/log"
"github.com/loveuer/ushare/internal/static"
"io"
"io/fs"
"net/http"
"strings"
)
func ServeFrontend() nf.HandlerFunc {
assets := static.Frontend()
return func(c *nf.Ctx) error {
path := strings.TrimPrefix(c.Path(), "/")
if path == "" || path == "/" {
path = "index.html"
}
file, err := assets.Open(path)
if err != nil {
if err.Error() == "file does not exist" {
return serveIndex(assets, c)
}
return c.SendStatus(http.StatusNotFound)
}
defer file.Close()
stat, err := file.Stat()
if err != nil {
return c.SendStatus(http.StatusInternalServerError)
}
if stat.IsDir() {
return serveIndex(assets, c)
}
io.Copy(c.Writer, file)
return nil
}
}
func ServeFrontendMiddleware() nf.HandlerFunc {
assets := static.Frontend()
return func(c *nf.Ctx) error {
path := c.Path()
if strings.HasPrefix(path, "/api") || strings.HasPrefix(path, "/ushare") {
return c.Next()
}
filePath := strings.TrimPrefix(path, "/")
if filePath == "" || filePath == "/" {
filePath = "index.html"
}
file, err := assets.Open(filePath)
if err != nil {
return serveIndex(assets, c)
}
defer file.Close()
stat, err := file.Stat()
if err != nil {
return c.SendStatus(http.StatusInternalServerError)
}
if stat.IsDir() {
return serveIndex(assets, c)
}
c.SetHeader("Content-Type", getContentType(filePath))
io.Copy(c.Writer, file)
return nil
}
}
func serveIndex(assets fs.FS, c *nf.Ctx) error {
index, err := assets.Open("index.html")
if err != nil {
log.Error("failed to open index.html: %v", err)
return c.SendStatus(http.StatusInternalServerError)
}
defer index.Close()
c.SetHeader("Content-Type", "text/html; charset=utf-8")
io.Copy(c.Writer, index)
return nil
}
func getContentType(path string) string {
if strings.HasSuffix(path, ".html") {
return "text/html; charset=utf-8"
}
if strings.HasSuffix(path, ".css") {
return "text/css; charset=utf-8"
}
if strings.HasSuffix(path, ".js") {
return "application/javascript; charset=utf-8"
}
if strings.HasSuffix(path, ".png") {
return "image/png"
}
if strings.HasSuffix(path, ".jpg") || strings.HasSuffix(path, ".jpeg") {
return "image/jpeg"
}
if strings.HasSuffix(path, ".svg") {
return "image/svg+xml"
}
return "application/octet-stream"
}
-85
View File
@@ -1,85 +0,0 @@
package handler
import (
"net/http"
"github.com/loveuer/nf"
"github.com/loveuer/ushare/internal/controller"
"github.com/loveuer/ushare/internal/model"
)
func TokenList() nf.HandlerFunc {
return func(c *nf.Ctx) error {
session, ok := c.Locals("user").(*model.Session)
if !ok || session == nil {
return c.Status(http.StatusUnauthorized).JSON(map[string]string{"error": "unauthorized"})
}
tokens, err := controller.TokenManager.List(session.UserID)
if err != nil {
return c.Status(http.StatusInternalServerError).JSON(map[string]string{"msg": err.Error()})
}
return c.Status(http.StatusOK).JSON(map[string]any{"data": tokens})
}
}
func TokenCreate() nf.HandlerFunc {
return func(c *nf.Ctx) error {
session, ok := c.Locals("user").(*model.Session)
if !ok || session == nil {
return c.Status(http.StatusUnauthorized).JSON(map[string]string{"error": "unauthorized"})
}
type Req struct {
Name string `json:"name"`
}
var req Req
if err := c.BodyParser(&req); err != nil {
return c.Status(http.StatusBadRequest).JSON(map[string]string{"msg": "请求格式错误"})
}
t, rawToken, err := controller.TokenManager.Create(session.UserID, req.Name)
if err != nil {
return c.Status(http.StatusBadRequest).JSON(map[string]string{"msg": err.Error()})
}
return c.Status(http.StatusOK).JSON(map[string]any{
"data": map[string]any{
"id": t.ID,
"name": t.Name,
"token": rawToken,
"created_at": t.CreatedAt,
},
})
}
}
func TokenDelete() nf.HandlerFunc {
return func(c *nf.Ctx) error {
session, ok := c.Locals("user").(*model.Session)
if !ok || session == nil {
return c.Status(http.StatusUnauthorized).JSON(map[string]string{"error": "unauthorized"})
}
type Req struct {
ID uint `json:"id"`
}
var req Req
if err := c.BodyParser(&req); err != nil {
return c.Status(http.StatusBadRequest).JSON(map[string]string{"msg": "请求格式错误"})
}
if req.ID == 0 {
return c.Status(http.StatusBadRequest).JSON(map[string]string{"msg": "token id 不能为空"})
}
if err := controller.TokenManager.Delete(session.UserID, req.ID); err != nil {
return c.Status(http.StatusBadRequest).JSON(map[string]string{"msg": err.Error()})
}
return c.Status(http.StatusOK).JSON(map[string]any{"data": "ok"})
}
}
+4 -7
View File
@@ -1,11 +1,8 @@
package model
type Meta struct {
Filename string `json:"filename" mapstructure:"filename"`
CreatedAt int64 `json:"created_at" mapstructure:"created_at"`
Size int64 `json:"size" mapstructure:"size"`
Uploader string `json:"uploader" mapstructure:"uploader"`
MaxDownloads int `json:"max_downloads" mapstructure:"max_downloads"`
ExpiresAt int64 `json:"expires_at" mapstructure:"expires_at"`
Downloads int `json:"downloads" mapstructure:"downloads"`
Filename string `json:"filename" mapstructure:"filename"`
CreatedAt int64 `json:"created_at" mapstructure:"created_at"`
Size int64 `json:"size" mapstructure:"size"`
UploaderIp string `json:"uploader_ip" mapstructure:"uploader_ip"`
}
-44
View File
@@ -1,44 +0,0 @@
package model
import (
"strings"
"time"
)
const (
PermUserManage = "user_manage"
PermUpload = "upload"
PermTokenManage = "token_manage"
RoleAdmin = "admin"
RoleUser = "user"
)
type Role struct {
ID uint `gorm:"primarykey" json:"id"`
Name string `gorm:"uniqueIndex;not null" json:"name"`
Label string `gorm:"not null" json:"label"`
Permissions string `gorm:"not null" json:"permissions"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
func (r *Role) HasPermission(perm string) bool {
for _, p := range r.PermissionList() {
if p == perm {
return true
}
}
return false
}
func (r *Role) PermissionList() []string {
list := make([]string, 0)
for _, p := range strings.Split(r.Permissions, ",") {
p = strings.TrimSpace(p)
if p != "" {
list = append(list, p)
}
}
return list
}
-18
View File
@@ -1,18 +0,0 @@
package model
import "time"
// Token is a personal API token for programmatic file upload.
// Token values are prefixed with "ust_" to distinguish them from session tokens.
type Token struct {
ID uint `gorm:"primarykey" json:"id"`
UserID uint `gorm:"not null;index" json:"user_id"`
Name string `gorm:"not null" json:"name"`
Token string `gorm:"uniqueIndex;not null" json:"-"`
CreatedAt time.Time `json:"created_at"`
LastUsedAt *time.Time `json:"last_used_at"`
ExpiresAt *time.Time `json:"expires_at"`
}
// TokenPrefix is the prefix for all API token values.
const TokenPrefix = "ust_"
-26
View File
@@ -1,26 +0,0 @@
package model
import "time"
// User is the GORM database model for persistent user storage.
type User struct {
ID uint `gorm:"primarykey" json:"id"`
Username string `gorm:"uniqueIndex;not null" json:"username"`
Password string `gorm:"not null" json:"-"`
RoleID uint `gorm:"not null" json:"role_id"`
Active bool `gorm:"default:true" json:"active"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// Session is the in-memory representation of an authenticated user.
// It is created on login and stored in the UserManager session map.
type Session struct {
UserID uint `json:"user_id"`
Username string `json:"username"`
Role string `json:"role"`
RoleLabel string `json:"role_label"`
Permissions []string `json:"permissions"`
LoginAt int64 `json:"login_at"`
Token string `json:"token"`
}
-14
View File
@@ -1,14 +0,0 @@
package model
type WSMessageType string
const (
WSMessageTypeOffer WSMessageType = "offer"
WSMessageTypeCandidate WSMessageType = "candidate"
)
type Message struct {
Type WSMessageType `json:"type"`
Time int64 `json:"time"`
Body any `json:"body"`
}
+3 -34
View File
@@ -1,42 +1,11 @@
package opt
import (
"context"
"github.com/loveuer/nf/nft/log"
"github.com/loveuer/ushare/internal/pkg/tool"
"os"
)
type config struct {
Debug bool
Address string
DataPath string
Username string
Password string
CleanInterval int
Debug bool
Address string
DataPath string
}
var (
Cfg = &config{}
)
func Init(_ context.Context) {
if Cfg.Username == "" {
Cfg.Username = "admin"
}
if Cfg.Password == "" {
Cfg.Password = "ushare@123"
}
Cfg.Password = tool.NewPassword(Cfg.Password)
log.Debug("opt.Init: username = %s, encrypted password = %s", Cfg.Username, Cfg.Password)
}
func LoadFromEnv() {
if username := os.Getenv("USHARE_USERNAME"); username != "" {
Cfg.Username = username
}
if password := os.Getenv("USHARE_PASSWORD"); password != "" {
Cfg.Password = password
}
}
+1 -12
View File
@@ -3,18 +3,7 @@ package opt
import "path/filepath"
const (
Meta = ".meta."
HeaderSize = "X-File-Size"
HeaderMaxDownload = "X-Max-Downloads"
HeaderExpiresIn = "X-Expires-In"
CodeLength = 8
// MinExpiresIn is the minimum allowed expiry in seconds (30s for testing).
MinExpiresIn = 30
// DefaultExpiresIn is the default expiry in seconds (8 hours).
DefaultExpiresIn = 8 * 3600
// DefaultMaxDownloads is the default max download count (0 = unlimited).
DefaultMaxDownloads = 3
Meta = ".meta."
)
func FilePath(code string) string {
-56
View File
@@ -1,56 +0,0 @@
package db
import (
"context"
"github.com/loveuer/ushare/internal/opt"
"github.com/loveuer/ushare/internal/pkg/tool"
"gorm.io/gorm"
)
var Default *Client
type DBType string
const (
DBTypeSqlite = "sqlite"
DBTypeMysql = "mysql"
DBTypePostgres = "postgres"
)
type Client struct {
ctx context.Context
cli *gorm.DB
dbType DBType
}
func (c *Client) Type() DBType {
return c.dbType
}
func (c *Client) Session(ctxs ...context.Context) *gorm.DB {
var ctx context.Context
if len(ctxs) > 0 && ctxs[0] != nil {
ctx = ctxs[0]
} else {
ctx = tool.Timeout(30)
}
session := c.cli.Session(&gorm.Session{Context: ctx})
if opt.Cfg.Debug {
session = session.Debug()
}
return session
}
func (c *Client) Migrate(models ...interface{}) error {
return c.cli.AutoMigrate(models...)
}
func (c *Client) Close() {
d, _ := c.cli.DB()
d.Close()
}
-65
View File
@@ -1,65 +0,0 @@
package db
import (
"context"
"fmt"
"strings"
"github.com/glebarez/sqlite"
"gorm.io/driver/mysql"
"gorm.io/driver/postgres"
"gorm.io/gorm"
)
const tip = `example:
for sqlite -> sqlite::<filepath>
sqlite::data.sqlite
sqlite::/data/data.db
for mysql -> mysql::<gorm_dsn>
mysql::user:pass@tcp(127.0.0.1:3306)/dbname?charset=utf8mb4&parseTime=True&loc=Local
for postgres -> postgres::<gorm_dsn>
postgres::host=localhost user=gorm password=gorm dbname=gorm port=9920 sslmode=disable TimeZone=Asia/Shanghai
`
func New(ctx context.Context, uri string) (*Client, error) {
parts := strings.SplitN(uri, "::", 2)
if len(parts) != 2 {
return nil, fmt.Errorf("db.Init: db uri invalid\n%s", tip)
}
c := &Client{}
var (
err error
dsn = parts[1]
)
switch parts[0] {
case "sqlite":
c.dbType = DBTypeSqlite
c.cli, err = gorm.Open(sqlite.Open(dsn))
case "mysql":
c.dbType = DBTypeMysql
c.cli, err = gorm.Open(mysql.Open(dsn))
case "postgres":
c.dbType = DBTypePostgres
c.cli, err = gorm.Open(postgres.Open(dsn))
default:
return nil, fmt.Errorf("db type only support: [sqlite, mysql, postgres], unsupported db type: %s", parts[0])
}
if err != nil {
return nil, fmt.Errorf("db.Init: open %s with dsn:%s, err: %w", parts[0], dsn, err)
}
return c, nil
}
func Init(ctx context.Context, uri string) (err error) {
if Default, err = New(ctx, uri); err != nil {
return err
}
return nil
}
-38
View File
@@ -1,38 +0,0 @@
package tool
import (
"context"
"time"
)
func Timeout(seconds ...int) (ctx context.Context) {
var (
duration time.Duration
)
if len(seconds) > 0 && seconds[0] > 0 {
duration = time.Duration(seconds[0]) * time.Second
} else {
duration = time.Duration(30) * time.Second
}
ctx, _ = context.WithTimeout(context.Background(), duration)
return
}
func TimeoutCtx(ctx context.Context, seconds ...int) context.Context {
var (
duration time.Duration
)
if len(seconds) > 0 && seconds[0] > 0 {
duration = time.Duration(seconds[0]) * time.Second
} else {
duration = time.Duration(30) * time.Second
}
nctx, _ := context.WithTimeout(ctx, duration)
return nctx
}
-50
View File
@@ -1,50 +0,0 @@
package tool
import "fmt"
func HumanDuration(nano int64) string {
duration := float64(nano)
unit := "ns"
if duration >= 1000 {
duration /= 1000
unit = "us"
}
if duration >= 1000 {
duration /= 1000
unit = "ms"
}
if duration >= 1000 {
duration /= 1000
unit = " s"
}
return fmt.Sprintf("%6.2f%s", duration, unit)
}
func HumanSize(size int64) string {
const (
_ = iota
KB = 1 << (10 * iota) // 1 KB = 1024 bytes
MB // 1 MB = 1024 KB
GB // 1 GB = 1024 MB
TB // 1 TB = 1024 GB
PB // 1 PB = 1024 TB
)
switch {
case size >= PB:
return fmt.Sprintf("%.2f PB", float64(size)/PB)
case size >= TB:
return fmt.Sprintf("%.2f TB", float64(size)/TB)
case size >= GB:
return fmt.Sprintf("%.2f GB", float64(size)/GB)
case size >= MB:
return fmt.Sprintf("%.2f MB", float64(size)/MB)
case size >= KB:
return fmt.Sprintf("%.2f KB", float64(size)/KB)
default:
return fmt.Sprintf("%d bytes", size)
}
}
-59
View File
@@ -1,59 +0,0 @@
package tool
import (
"net"
)
var (
privateIPv4Blocks []*net.IPNet
privateIPv6Blocks []*net.IPNet
)
func init() {
// IPv4私有地址段
for _, cidr := range []string{
"10.0.0.0/8", // A类私有地址
"172.16.0.0/12", // B类私有地址
"192.168.0.0/16", // C类私有地址
"169.254.0.0/16", // 链路本地地址
"127.0.0.0/8", // 环回地址
} {
_, block, _ := net.ParseCIDR(cidr)
privateIPv4Blocks = append(privateIPv4Blocks, block)
}
// IPv6私有地址段
for _, cidr := range []string{
"fc00::/7", // 唯一本地地址
"fe80::/10", // 链路本地地址
"::1/128", // 环回地址
} {
_, block, _ := net.ParseCIDR(cidr)
privateIPv6Blocks = append(privateIPv6Blocks, block)
}
}
func IsPrivateIP(ipStr string) bool {
ip := net.ParseIP(ipStr)
if ip == nil {
return false
}
// 处理IPv4和IPv4映射的IPv6地址
if ip4 := ip.To4(); ip4 != nil {
for _, block := range privateIPv4Blocks {
if block.Contains(ip4) {
return true
}
}
return false
}
// 处理IPv6地址
for _, block := range privateIPv6Blocks {
if block.Contains(ip) {
return true
}
}
return false
}
-68
View File
@@ -1,68 +0,0 @@
package tool
import "math"
func Map[T, R any](vals []T, fn func(item T, index int) R) []R {
var result = make([]R, len(vals))
for idx, v := range vals {
result[idx] = fn(v, idx)
}
return result
}
func Chunk[T any](vals []T, size int) [][]T {
if size <= 0 {
panic("Second parameter must be greater than 0")
}
chunksNum := len(vals) / size
if len(vals)%size != 0 {
chunksNum += 1
}
result := make([][]T, 0, chunksNum)
for i := 0; i < chunksNum; i++ {
last := (i + 1) * size
if last > len(vals) {
last = len(vals)
}
result = append(result, vals[i*size:last:last])
}
return result
}
// 对 vals 取样 x 个
func Sample[T any](vals []T, x int) []T {
if x < 0 {
panic("Second parameter can't be negative")
}
n := len(vals)
if n == 0 {
return []T{}
}
if x >= n {
return vals
}
// 处理x=1的特殊情况
if x == 1 {
return []T{vals[(n-1)/2]}
}
// 计算采样步长并生成结果数组
step := float64(n-1) / float64(x-1)
result := make([]T, x)
for i := 0; i < x; i++ {
// 计算采样位置并四舍五入
pos := float64(i) * step
index := int(math.Round(pos))
result[i] = vals[index]
}
return result
}
-11
View File
@@ -1,11 +0,0 @@
package tool
import "github.com/loveuer/nf/nft/log"
func Must(errs ...error) {
for _, err := range errs {
if err != nil {
log.Panic(err.Error())
}
}
}
-84
View File
@@ -1,84 +0,0 @@
package tool
import (
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"github.com/loveuer/nf/nft/log"
"golang.org/x/crypto/pbkdf2"
"regexp"
"strconv"
"strings"
)
const (
EncryptHeader string = "pbkdf2:sha256" // 用户密码加密
)
func NewPassword(password string) string {
return EncryptPassword(password, RandomString(8), int(RandomInt(50000)+100000))
}
func ComparePassword(in, db string) bool {
strs := strings.Split(db, "$")
if len(strs) != 3 {
log.Error("password in db invalid: %s", db)
return false
}
encs := strings.Split(strs[0], ":")
if len(encs) != 3 {
log.Error("password in db invalid: %s", db)
return false
}
encIteration, err := strconv.Atoi(encs[2])
if err != nil {
log.Error("password in db invalid: %s, convert iter err: %s", db, err)
return false
}
return EncryptPassword(in, strs[1], encIteration) == db
}
func EncryptPassword(password, salt string, iter int) string {
hash := pbkdf2.Key([]byte(password), []byte(salt), iter, 32, sha256.New)
encrypted := hex.EncodeToString(hash)
return fmt.Sprintf("%s:%d$%s$%s", EncryptHeader, iter, salt, encrypted)
}
func CheckPassword(password string) error {
if len(password) < 8 || len(password) > 32 {
return errors.New("密码长度不符合")
}
var (
err error
match bool
patternList = []string{`[0-9]+`, `[a-z]+`, `[A-Z]+`, `[!@#%]+`} //, `[~!@#$%^&*?_-]+`}
matchAccount = 0
tips = []string{"缺少数字", "缺少小写字母", "缺少大写字母", "缺少'!@#%'"}
locktips = make([]string, 0)
)
for idx, pattern := range patternList {
match, err = regexp.MatchString(pattern, password)
if err != nil {
log.Warn("regex match string err, reg_str: %s, err: %v", pattern, err)
return errors.New("密码强度不够")
}
if match {
matchAccount++
} else {
locktips = append(locktips, tips[idx])
}
}
if matchAccount < 3 {
return fmt.Errorf("密码强度不够, 可能 %s", strings.Join(locktips, ", "))
}
return nil
}
-20
View File
@@ -1,20 +0,0 @@
package tool
import "testing"
func TestEncPassword(t *testing.T) {
password := "123456"
result := EncryptPassword(password, RandomString(8), 50000)
t.Logf("sum => %s", result)
}
func TestPassword(t *testing.T) {
p := "wahaha@123"
p = NewPassword(p)
t.Logf("password => %s", p)
result := ComparePassword("wahaha@123", p)
t.Logf("compare result => %v", result)
}
-75
View File
@@ -1,75 +0,0 @@
package tool
import (
"crypto/rand"
"math/big"
mrand "math/rand"
)
var (
letters = []byte("0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ")
letterNum = []byte("0123456789")
letterLow = []byte("abcdefghijklmnopqrstuvwxyz")
letterCap = []byte("ABCDEFGHIJKLMNOPQRSTUVWXYZ")
letterSyb = []byte("!@#$%^&*()_+-=")
adjectives = []string{
"开心的", "灿烂的", "温暖的", "阳光的", "活泼的",
"聪明的", "优雅的", "幸运的", "甜蜜的", "勇敢的",
"宁静的", "热情的", "温柔的", "幽默的", "坚强的",
"迷人的", "神奇的", "快乐的", "健康的", "自由的",
"梦幻的", "勤劳的", "真诚的", "浪漫的", "自信的",
}
plants = []string{
"苹果", "香蕉", "橘子", "葡萄", "草莓",
"西瓜", "樱桃", "菠萝", "柠檬", "蜜桃",
"蓝莓", "芒果", "石榴", "甜瓜", "雪梨",
"番茄", "南瓜", "土豆", "青椒", "洋葱",
"黄瓜", "萝卜", "豌豆", "玉米", "蘑菇",
"菠菜", "茄子", "芹菜", "莲藕", "西兰花",
}
)
func RandomInt(max int64) int64 {
num, _ := rand.Int(rand.Reader, big.NewInt(max))
return num.Int64()
}
func RandomString(length int) string {
result := make([]byte, length)
for i := 0; i < length; i++ {
num, _ := rand.Int(rand.Reader, big.NewInt(int64(len(letters))))
result[i] = letters[num.Int64()]
}
return string(result)
}
func RandomPassword(length int, withSymbol bool) string {
result := make([]byte, length)
kind := 3
if withSymbol {
kind++
}
for i := 0; i < length; i++ {
switch i % kind {
case 0:
num, _ := rand.Int(rand.Reader, big.NewInt(int64(len(letterNum))))
result[i] = letterNum[num.Int64()]
case 1:
num, _ := rand.Int(rand.Reader, big.NewInt(int64(len(letterLow))))
result[i] = letterLow[num.Int64()]
case 2:
num, _ := rand.Int(rand.Reader, big.NewInt(int64(len(letterCap))))
result[i] = letterCap[num.Int64()]
case 3:
num, _ := rand.Int(rand.Reader, big.NewInt(int64(len(letterSyb))))
result[i] = letterSyb[num.Int64()]
}
}
return string(result)
}
func RandomName() string {
return adjectives[mrand.Intn(len(adjectives))] + plants[mrand.Intn(len(plants))]
}
-124
View File
@@ -1,124 +0,0 @@
package tool
import (
"encoding/json"
"fmt"
"github.com/jedib0t/go-pretty/v6/table"
"github.com/loveuer/nf/nft/log"
"io"
"os"
"reflect"
"strings"
)
func TablePrinter(data any, writers ...io.Writer) {
var w io.Writer = os.Stdout
if len(writers) > 0 && writers[0] != nil {
w = writers[0]
}
t := table.NewWriter()
structPrinter(t, "", data)
_, _ = fmt.Fprintln(w, t.Render())
}
func structPrinter(w table.Writer, prefix string, item any) {
Start:
rv := reflect.ValueOf(item)
if rv.IsZero() {
return
}
for rv.Type().Kind() == reflect.Pointer {
rv = rv.Elem()
}
switch rv.Type().Kind() {
case reflect.Invalid,
reflect.Uintptr,
reflect.Chan,
reflect.Func,
reflect.UnsafePointer:
case reflect.Bool,
reflect.Int,
reflect.Int8,
reflect.Int16,
reflect.Int32,
reflect.Int64,
reflect.Uint,
reflect.Uint8,
reflect.Uint16,
reflect.Uint32,
reflect.Uint64,
reflect.Float32,
reflect.Float64,
reflect.Complex64,
reflect.Complex128,
reflect.Interface:
w.AppendRow(table.Row{strings.TrimPrefix(prefix, "."), rv.Interface()})
case reflect.String:
val := rv.String()
if len(val) <= 160 {
w.AppendRow(table.Row{strings.TrimPrefix(prefix, "."), val})
return
}
w.AppendRow(table.Row{strings.TrimPrefix(prefix, "."), val[0:64] + "..." + val[len(val)-64:]})
case reflect.Array, reflect.Slice:
for i := 0; i < rv.Len(); i++ {
p := strings.Join([]string{prefix, fmt.Sprintf("[%d]", i)}, ".")
structPrinter(w, p, rv.Index(i).Interface())
}
case reflect.Map:
for _, k := range rv.MapKeys() {
structPrinter(w, fmt.Sprintf("%s.{%v}", prefix, k), rv.MapIndex(k).Interface())
}
case reflect.Pointer:
goto Start
case reflect.Struct:
for i := 0; i < rv.NumField(); i++ {
p := fmt.Sprintf("%s.%s", prefix, rv.Type().Field(i).Name)
field := rv.Field(i)
//log.Debug("TablePrinter: prefix: %s, field: %v", p, rv.Field(i))
if !field.CanInterface() {
return
}
structPrinter(w, p, field.Interface())
}
}
}
func TableMapPrinter(data []byte) {
m := make(map[string]any)
if err := json.Unmarshal(data, &m); err != nil {
log.Warn(err.Error())
return
}
t := table.NewWriter()
addRow(t, "", m)
fmt.Println(t.Render())
}
func addRow(w table.Writer, prefix string, m any) {
rv := reflect.ValueOf(m)
switch rv.Type().Kind() {
case reflect.Map:
for _, k := range rv.MapKeys() {
key := k.String()
if prefix != "" {
key = strings.Join([]string{prefix, k.String()}, ".")
}
addRow(w, key, rv.MapIndex(k).Interface())
}
case reflect.Slice, reflect.Array:
for i := 0; i < rv.Len(); i++ {
addRow(w, fmt.Sprintf("%s[%d]", prefix, i), rv.Index(i).Interface())
}
default:
w.AppendRow(table.Row{prefix, m})
}
}
-73
View File
@@ -1,73 +0,0 @@
package tool
import (
"fmt"
"math"
)
func Min[T ~int | ~uint | ~int8 | ~uint8 | ~int16 | ~uint16 | ~int32 | ~uint32 | ~int64 | ~uint64 | ~float32 | ~float64](a, b T) T {
if a <= b {
return a
}
return b
}
func Mins[T ~int | ~uint | ~int8 | ~uint8 | ~int16 | ~uint16 | ~int32 | ~uint32 | ~int64 | ~uint64 | ~float32 | ~float64](vals ...T) T {
var val T
if len(vals) == 0 {
return val
}
val = vals[0]
for _, item := range vals[1:] {
if item < val {
val = item
}
}
return val
}
func Max[T ~int | ~uint | ~int8 | ~uint8 | ~int16 | ~uint16 | ~int32 | ~uint32 | ~int64 | ~uint64 | ~float32 | ~float64](a, b T) T {
if a >= b {
return a
}
return b
}
func Maxs[T ~int | ~uint | ~int8 | ~uint8 | ~int16 | ~uint16 | ~int32 | ~uint32 | ~int64 | ~uint64 | ~float32 | ~float64](vals ...T) T {
var val T
if len(vals) == 0 {
return val
}
for _, item := range vals {
if item > val {
val = item
}
}
return val
}
func Sum[T ~int | ~uint | ~int8 | ~uint8 | ~int16 | ~uint16 | ~int32 | ~uint32 | ~int64 | ~uint64 | ~float32 | ~float64](vals ...T) T {
var sum T = 0
for i := range vals {
sum += vals[i]
}
return sum
}
func Percent(val, minVal, maxVal, minPercent, maxPercent float64) string {
return fmt.Sprintf(
"%d%%",
int(math.Round(
((val-minVal)/(maxVal-minVal)*(maxPercent-minPercent)+minPercent)*100,
)),
)
}
-70
View File
@@ -1,70 +0,0 @@
package tool
import "testing"
func TestPercent(t *testing.T) {
type args struct {
val float64
minVal float64
maxVal float64
minPercent float64
maxPercent float64
}
tests := []struct {
name string
args args
want string
}{
{
name: "case 1",
args: args{
val: 0.5,
minVal: 0,
maxVal: 1,
minPercent: 0,
maxPercent: 1,
},
want: "50%",
},
{
name: "case 2",
args: args{
val: 0.3,
minVal: 0.1,
maxVal: 0.6,
minPercent: 0,
maxPercent: 1,
},
want: "40%",
},
{
name: "case 3",
args: args{
val: 700,
minVal: 700,
maxVal: 766,
minPercent: 0.1,
maxPercent: 0.7,
},
want: "10%",
},
{
name: "case 4",
args: args{
val: 766,
minVal: 700,
maxVal: 766,
minPercent: 0.1,
maxPercent: 0.7,
},
want: "70%",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := Percent(tt.args.val, tt.args.minVal, tt.args.maxVal, tt.args.minPercent, tt.args.maxPercent); got != tt.want {
t.Errorf("Percent() = %v, want %v", got, tt.want)
}
})
}
}
-14
View File
@@ -1,14 +0,0 @@
package static
import (
"embed"
"io/fs"
)
//go:embed frontend/dist
var FrontendFS embed.FS
func Frontend() fs.FS {
sub, _ := fs.Sub(FrontendFS, "frontend/dist")
return sub
}
+1 -36
View File
@@ -3,58 +3,23 @@ package main
import (
"context"
"flag"
"os"
"path/filepath"
"github.com/loveuer/nf/nft/log"
"github.com/loveuer/ushare/internal/api"
"github.com/loveuer/ushare/internal/controller"
"github.com/loveuer/ushare/internal/model"
"github.com/loveuer/ushare/internal/opt"
"github.com/loveuer/ushare/internal/pkg/db"
"github.com/loveuer/ushare/internal/pkg/tool"
"os/signal"
"syscall"
)
func init() {
flag.BoolVar(&opt.Cfg.Debug, "debug", false, "debug mode")
flag.StringVar(&opt.Cfg.Address, "address", "0.0.0.0:9119", "")
flag.StringVar(&opt.Cfg.Address, "address", "0.0.0.0:80", "")
flag.StringVar(&opt.Cfg.DataPath, "data", "/data", "")
flag.IntVar(&opt.Cfg.CleanInterval, "clean", 24, "清理文件的周期, 单位: 小时, 0 则表示不自动清理")
flag.Parse()
opt.LoadFromEnv()
if opt.Cfg.Debug {
log.SetLogLevel(log.LogLevelDebug)
tool.TablePrinter(opt.Cfg)
}
}
func main() {
ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer cancel()
opt.Init(ctx)
if err := os.MkdirAll(opt.Cfg.DataPath, 0755); err != nil {
log.Fatal("main: create data path failed: %s", err.Error())
}
dbPath := filepath.Join(opt.Cfg.DataPath, ".ushare.db")
if err := db.Init(ctx, "sqlite::"+dbPath); err != nil {
log.Fatal("main: init db failed: %s", err.Error())
}
log.Debug("main: db initialized at %s", dbPath)
if err := db.Default.Migrate(&model.Role{}, &model.User{}, &model.Token{}); err != nil {
log.Fatal("main: db migrate failed: %s", err.Error())
}
controller.UserManager.Start(ctx)
controller.MetaManager.Start(ctx)
controller.RoomController.Start(ctx)
api.Start(ctx)
<-ctx.Done()
-49
View File
@@ -1,49 +0,0 @@
#!/bin/bash
set -e
echo "=========================================="
echo " Building UShare Single Binary"
echo "=========================================="
echo ""
# 清理旧的构建产物
echo "[Cleanup] Removing old build files..."
rm -rf dist
rm -f ushare
rm -rf internal/static/frontend
# 构建前端
echo ""
echo "[Frontend] Building..."
cd frontend
pnpm run build
cd ..
# 复制前端构建产物到 internal/static
echo "[Frontend] Copying dist files..."
mkdir -p internal/static/frontend
cp -r frontend/dist internal/static/frontend/
# 构建后端(包含嵌入的前端文件)
echo ""
echo "[Backend] Building with embedded frontend..."
mkdir -p dist
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags '-s -w' -o dist/ushare .
# 清理临时文件
echo ""
echo "[Cleanup] Removing temporary files..."
rm -rf internal/static/frontend
echo ""
echo "=========================================="
echo " Build Complete!"
echo " Binary: dist/ushare"
echo "=========================================="
echo ""
echo "Usage:"
echo " ./dist/ushare -debug -address 0.0.0.0:9119 -data ./data -auth \"admin:password\""
echo ""
echo " Development: ./dev.sh"
echo " Production: ./make.sh && ./dist/ushare ..."
-149
View File
@@ -1,149 +0,0 @@
<!DOCTYPE html>
<html lang="zh">
<head>
<meta charset="UTF-8">
<title>柔和气泡</title>
<style>
body {
margin: 0;
height: 100vh;
background: linear-gradient(45deg, #e6e9f0, #eef1f5);
overflow: hidden;
position: relative;
}
.bubble {
position: absolute;
width: 100px;
height: 100px;
border-radius: 50%;
display: flex;
justify-content: center;
align-items: center;
text-align: center;
cursor: pointer;
font-family: 'Microsoft Yahei', sans-serif;
font-size: 14px;
color: rgba(255, 255, 255, 0.9);
text-shadow: 1px 1px 3px rgba(0,0,0,0.3);
transition: transform 0.3s ease;
animation: float 6s ease-in-out infinite;
/* 泡泡细节 */
background: radial-gradient(circle at 30% 30%,
rgba(255, 255, 255, 0.8) 10%,
rgba(255, 255, 255, 0.3) 50%,
transparent 100%);
border: 2px solid rgba(255, 255, 255, 0.5);
box-shadow: inset 0 -5px 15px rgba(255,255,255,0.3),
0 5px 15px rgba(0,0,0,0.1);
}
/* 高光效果 */
.bubble::before {
content: '';
position: absolute;
width: 20px;
height: 20px;
background: rgba(255, 255, 255, 0.7);
border-radius: 50%;
top: 15px;
right: 15px;
filter: blur(2px);
}
.bubble:hover {
transform: scale(1.05) rotate(3deg);
}
@keyframes float {
0%, 100% { transform: translateY(0) rotate(0deg); }
33% { transform: translateY(-20px) rotate(3deg); }
66% { transform: translateY(10px) rotate(-3deg); }
}
</style>
</head>
<body>
<script>
const prefixes = ['宁静', '温暖', '甜蜜', '柔和', '灿烂', '神秘', '清爽'];
const suffixes = ['梦境', '时光', '旋律', '花园', '云端', '海洋', '晨露'];
const existingPositions = [];
const BUBBLE_SIZE = 100;
// 检测碰撞
function checkCollision(x, y) {
return existingPositions.some(pos => {
const distance = Math.sqrt(
Math.pow(x - pos.x, 2) +
Math.pow(y - pos.y, 2)
);
return distance < BUBBLE_SIZE * 1.2;
});
}
function createBubble() {
let x, y;
let attempts = 0;
// 生成非重叠位置
do {
x = Math.random() * (window.innerWidth - BUBBLE_SIZE);
y = Math.random() * (window.innerHeight - BUBBLE_SIZE);
attempts++;
} while (checkCollision(x, y) && attempts < 100);
if (attempts >= 100) return;
existingPositions.push({x, y});
const bubble = document.createElement('div');
bubble.className = 'bubble';
// 随机颜色
const hue = Math.random() * 360;
bubble.style.backgroundColor = `hsla(${hue},
${Math.random() * 30 + 40}%,
${Math.random() * 10 + 75}%, 0.9)`;
// 随机名称
bubble.textContent = `${prefixes[Math.random()*prefixes.length|0]}的${suffixes[Math.random()*suffixes.length|0]}`;
// 位置和动画
bubble.style.left = x + 'px';
bubble.style.top = y + 'px';
bubble.style.animationDelay = Math.random() * 2 + 's';
// 点击效果
bubble.addEventListener('click', () => {
bubble.style.transition = 'transform 0.5s, opacity 0.5s';
bubble.style.transform = 'scale(1.5)';
bubble.style.opacity = '0';
setTimeout(() => {
bubble.remove();
existingPositions.splice(existingPositions.findIndex(
pos => pos.x === x && pos.y === y), 1);
createBubble();
}, 500);
});
document.body.appendChild(bubble);
}
// 初始化15个气泡
for (let i = 0; i < 15; i++) createBubble();
// 自动补充气泡
setInterval(() => {
if (document.querySelectorAll('.bubble').length < 20) {
createBubble();
}
}, 3000);
// 窗口大小变化时重置
window.addEventListener('resize', () => {
existingPositions.length = 0;
document.querySelectorAll('.bubble').forEach(bubble => bubble.remove());
for (let i = 0; i < 15; i++) createBubble();
});
</script>
</body>
</html>
-166
View File
@@ -1,166 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Login Page</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
}
body {
background-color: #f5f5f5;
height: 100vh;
display: flex;
justify-content: center;
align-items: center;
}
.login-container {
width: 100%;
padding: 20px;
display: flex;
justify-content: center;
align-items: center;
}
.login-box {
background: white;
padding: 40px;
border-radius: 10px;
box-shadow: 0 0 20px rgba(0, 0, 0, 0.1);
width: 100%;
max-width: 400px;
}
h2 {
text-align: center;
color: #333;
margin-bottom: 30px;
}
.form-group {
margin-bottom: 20px;
}
label {
display: block;
margin-bottom: 8px;
color: #555;
}
input[type="text"],
input[type="password"] {
width: 100%;
padding: 12px;
border: 1px solid #ddd;
border-radius: 5px;
font-size: 16px;
transition: border-color 0.3s ease;
}
input[type="text"]:focus,
input[type="password"]:focus {
border-color: #4CAF50;
outline: none;
}
.form-options {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}
.remember-me {
display: flex;
align-items: center;
gap: 5px;
color: #666;
}
.forgot-password {
color: #4CAF50;
text-decoration: none;
font-size: 14px;
}
.forgot-password:hover {
text-decoration: underline;
}
.login-button {
width: 100%;
padding: 12px;
background-color: #4CAF50;
color: white;
border: none;
border-radius: 5px;
font-size: 16px;
cursor: pointer;
transition: background-color 0.3s ease;
}
.login-button:hover {
background-color: #45a049;
}
.signup-link {
text-align: center;
margin-top: 20px;
color: #666;
}
.signup-link a {
color: #4CAF50;
text-decoration: none;
}
.signup-link a:hover {
text-decoration: underline;
}
/* Responsive Design */
@media (max-width: 480px) {
.login-box {
padding: 20px;
}
.form-options {
flex-direction: column;
gap: 10px;
align-items: flex-start;
}
}
</style>
</head>
<body>
<div class="login-container">
<div class="login-box">
<h2>Welcome Back</h2>
<form class="login-form">
<div class="form-group">
<label for="username">Username</label>
<input type="text" id="username" name="username" required>
</div>
<div class="form-group">
<label for="password">Password</label>
<input type="password" id="password" name="password" required>
</div>
<div class="form-options">
<label class="remember-me">
<input type="checkbox"> Remember me
</label>
<a href="#" class="forgot-password">Forgot Password?</a>
</div>
<button type="submit" class="login-button">Log In</button>
</form>
<p class="signup-link">Don't have an account? <a href="#">Sign up</a></p>
</div>
</div>
</body>
</html>
-179
View File
@@ -1,179 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>智能文本区域</title>
<style>
.container {
width: 400px;
height: 300px;
margin: 20px;
border: 1px solid #ddd;
border-radius: 8px;
overflow: hidden;
position: relative;
}
.input-box {
width: 100%;
height: 100%;
padding: 10px;
box-sizing: border-box;
border: none;
resize: none;
outline: none;
font-family: Arial, sans-serif;
transition: padding 0.3s ease;
}
.input-box.has-files {
padding-top: 50px;
}
.file-list {
position: absolute;
top: 0;
left: 0;
right: 0;
padding: 8px 10px;
background: rgba(255, 255, 255, 0.95);
border-bottom: 1px solid #eee;
display: none;
flex-wrap: wrap;
gap: 6px;
max-height: 60px;
overflow-y: auto;
}
.file-list.has-files {
display: flex;
}
.file-item {
display: flex;
align-items: center;
background: #f0f6ff;
border: 1px solid #c2d9ff;
border-radius: 15px;
padding: 4px 12px;
font-size: 13px;
animation: slideIn 0.2s ease;
}
@keyframes slideIn {
from { transform: translateY(-10px); opacity: 0; }
to { transform: translateY(0); opacity: 1; }
}
.delete-btn {
width: 16px;
height: 16px;
border: none;
background: #ff6b6b;
color: white;
border-radius: 50%;
margin-left: 8px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
}
.delete-btn:hover {
background: #ff5252;
}
.buttons {
position: absolute;
bottom: 10px;
right: 10px;
display: flex;
gap: 8px;
}
.action-btn {
padding: 8px 16px;
background: #4dabf7;
color: white;
border: none;
border-radius: 20px;
cursor: pointer;
transition: all 0.2s;
}
.action-btn:hover {
background: #339af0;
transform: translateY(-1px);
}
</style>
</head>
<body>
<div class="container">
<textarea class="input-box" placeholder="开始输入..."></textarea>
<div class="file-list" id="fileList"></div>
<div class="buttons">
<input type="file" id="fileInput" hidden multiple>
<button class="action-btn" onclick="document.getElementById('fileInput').click()">📁 选择文件</button>
<button class="action-btn" onclick="send()">✈️ 发送</button>
</div>
</div>
<script>
const inputBox = document.querySelector('.input-box');
const fileInput = document.getElementById('fileInput');
const fileList = document.getElementById('fileList');
// 文件选择事件
fileInput.addEventListener('change', function() {
updateFileList();
adjustInputPadding();
});
// 动态调整输入框间距
function adjustInputPadding() {
inputBox.classList.toggle('has-files', fileInput.files.length > 0);
fileList.classList.toggle('has-files')
}
// 更新文件列表显示
function updateFileList() {
fileList.innerHTML = '';
Array.from(fileInput.files).forEach((file, index) => {
const item = document.createElement('div');
item.className = 'file-item';
item.innerHTML = `
<span>${file.name}</span>
<button class="delete-btn" onclick="removeFile(${index})">×</button>
`;
fileList.appendChild(item);
});
}
// 删除单个文件
function removeFile(index) {
const dt = new DataTransfer();
Array.from(fileInput.files).forEach((file, i) => {
if (i !== index) dt.items.add(file);
});
fileInput.files = dt.files;
updateFileList();
adjustInputPadding();
}
// 发送功能
function send() {
const text = inputBox.value;
const files = fileInput.files;
// 清空内容
inputBox.value = '';
fileInput.value = '';
fileList.innerHTML = '';
adjustInputPadding();
}
</script>
</body>
</html>