10 Commits

Author SHA1 Message Date
loveuer
ef6347a8b4 feat: add token-based API access (v0.6.0)
Some checks are pending
Release Binaries / Build and Release (.exe, amd64, windows, windows-amd64) (push) Waiting to run
Release Binaries / Build and Release (amd64, darwin, darwin-amd64) (push) Waiting to run
Release Binaries / Build and Release (amd64, linux, linux-amd64) (push) Waiting to run
Release Binaries / Build and Release (arm64, darwin, darwin-arm64) (push) Waiting to run
Release Binaries / Build and Release (arm64, linux, linux-arm64) (push) Waiting to run
- Add Token GORM model with UserID/Name/Token/LastUsedAt/ExpiresAt fields
- Add TokenManager controller: List/Create/Delete/Verify operations
- Add token HTTP handlers: list, create, revoke
- Update AuthVerify to support Bearer token auth; API tokens use "ust_" prefix to distinguish from session tokens
- Add one-step file upload endpoint: PUT /api/v1/upload/:filename (returns {"status":200,"data":{"code":"..."}})
- Add token management routes: GET/POST/DELETE /api/token
- Add /self page: personal center with account info, token management table, and curl usage guide
- Add "个人中心 / API Token" nav link for users with token_manage permission

🤖 Generated with [Qoder][https://qoder.com]
2026-02-28 01:32:08 -08:00
loveuer
6286332896 chore: remove gitea ci workflows
Some checks are pending
Release Binaries / Build and Release (.exe, amd64, windows, windows-amd64) (push) Waiting to run
Release Binaries / Build and Release (amd64, darwin, darwin-amd64) (push) Waiting to run
Release Binaries / Build and Release (amd64, linux, linux-amd64) (push) Waiting to run
Release Binaries / Build and Release (arm64, darwin, darwin-arm64) (push) Waiting to run
Release Binaries / Build and Release (arm64, linux, linux-arm64) (push) Waiting to run
🤖 Generated with [Qoder][https://qoder.com]
2026-02-28 00:17:34 -08:00
loveuer
38986be874 ci: add release binary workflow, simplify docker build
- build.yaml: remove docker login/push, only verify image builds
- release.yaml: new workflow to cross-compile binaries for
  linux/amd64, linux/arm64, darwin/amd64, darwin/arm64, windows/amd64
  and publish them as Gitea Release assets on tag push

🤖 Generated with [Qoder][https://qoder.com]
2026-02-27 22:49:20 -08:00
loveuer
90093f79c9 fix: copy frontend dist into backend-builder for go:embed
Some checks failed
/ build ushare (push) Failing after 2m53s
/ clean (push) Successful in 3s
The backend Go build embeds the frontend via //go:embed frontend/dist
in internal/static/static.go. The Dockerfile was missing a
COPY --from=frontend-builder step to place the built dist at the
expected path before running go build, causing the build to fail.

🤖 Generated with [Qoder][https://qoder.com]
2026-02-27 19:49:50 -08:00
loveuer
5f187bb5d6 feat: add user management system with roles and permissions
Some checks failed
/ build ushare (push) Failing after 1m40s
/ clean (push) Successful in 2s
- Introduce SQLite persistence via GORM (stored at <data>/.ushare.db)
- Add Role model with two built-in roles: admin (all perms) and user (upload only)
- Add three permissions: user_manage, upload, token_manage (reserved)
- Rewrite UserManager: DB-backed login with in-memory session tokens
- Auto-seed default roles and admin user on first startup
- Add AuthPermission middleware for fine-grained permission checks
- Add /api/uauth/me endpoint for current session info
- Add /api/admin/* CRUD routes for user and role management
- Add admin console page (/admin) with user table and role permissions view
- Show admin console link in share page for users with user_manage permission

🤖 Generated with [Qoder][https://qoder.com]
2026-02-27 19:40:31 -08:00
loveuer
909a016a44 docs: add comprehensive README
Some checks failed
/ build ushare (push) Has been cancelled
/ clean (push) Has been cancelled
- Add project introduction and features
- Add quick start guide (development and production)
- Add configuration documentation
- Add usage instructions
- Add mobile support section
- Add development guide and directory structure
2026-01-18 17:52:39 +08:00
loveuer
96fe642175 fix: correct pnpm setup in GitHub Actions
- Use pnpm/action-setup@v2 instead of npm install -g
- Add proper pnpm cache configuration
- Fix 'Unable to locate executable file: pnpm' error
2026-01-18 17:35:34 +08:00
loveuer
d38fa7a507 feat: optimize mobile responsive layout for share page
- Add responsive design for mobile devices (≤768px)
- Switch from horizontal to vertical layout on mobile
- Hide middle divider panel on mobile
- Optimize button sizes for mobile touch interaction
- Improve input field and button alignment on mobile
2026-01-18 17:12:07 +08:00
loveuer
8a063d15d9 ci: add GitHub Actions workflow for releasing binaries
Some checks failed
/ build ushare (push) Has been cancelled
/ clean (push) Has been cancelled
- Add .github/workflows/release.yml
- Build binaries for multiple platforms (linux/amd64, linux/arm64, darwin/amd64, darwin/arm64, windows/amd64)
- Embed frontend into binary
- Generate SHA256 checksums for each binary
- Automatically release on tag push
2026-01-18 14:04:36 +08:00
loveuer
56874b754e feat: implement single binary build and env-based auth
Some checks failed
/ build ushare (push) Has been cancelled
/ clean (push) Has been cancelled
- Unify login page styling with share page
- Add AGENTS.md with build commands and code style guidelines
- Add dev.sh and make.sh for development and production builds
- Implement single binary build with embedded frontend using embed.FS
- Change auth configuration from CLI flag to env variables (USHARE_USERNAME, USHARE_PASSWORD)
- Set default credentials: admin / ushare@123
- Fix static file serving for SPA routes
2026-01-17 23:28:21 +08:00
36 changed files with 2990 additions and 272 deletions

View File

@@ -1,59 +0,0 @@
run-name: build ushare
on:
push:
tags:
- 'v*'
jobs:
build ushare:
runs-on: tencent-sg
steps:
- name: prepare enviroment
uses: actions/checkout@v4
- name: prints date
run: date '+%Y-%m-%dT%H:%M:%S'
- name: print operator
run: whoami
- name: print tag name
run: echo "Tag name = ${{ gitea.ref_name }}"
- name: build prepare config
run: |
cat << EOF > .docker.config.json
${{ secrets.DOCKER_CONFIG }}
EOF
- name: print work dir and files
run: pwd & ls -alsh .
- name: build image by docker build
run: docker build -t gitea.loveuer.com/loveuer/build/ushare:${{ gitea.ref_name }} .
- name: login repository
run: echo ${{ secrets.DOCKER_REPOSITORY_PASSWORD }} | docker login --username loveuer --password-stdin gitea.loveuer.com/loveuer
- name: push image to repository
run: docker push gitea.loveuer.com/loveuer/build/ushare:${{ gitea.ref_name }}
# - name: build by kaniko in docker
# run: |
# docker run --rm -v $(pwd):/workspace \
# -v $(pwd)/.docker.config.json:/kaniko/.docker/config.json:ro \
# alpine:latest \
# ls -alsh /workspace
# gcr.io/kaniko-project/executor:latest \
# --dockerfile=/workspace/Dockerfile \
# --context=/workspace \
# --destination=gitea.loveuer.com/loveuer/build/u-api:${{ gitea.ref_name }} \
# --single-snapshot
clean:
if: always()
runs-on: tencent-sg
steps:
- name: clean docker config
run: |
rm -rf .docker.config.json

View File

@@ -0,0 +1,22 @@
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 }} .

View File

@@ -0,0 +1,90 @@
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
.github/workflows/release.yml vendored Normal file
View File

@@ -0,0 +1,106 @@
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

16
.gitignore vendored
View File

@@ -1,5 +1,19 @@
# IDE
.idea .idea
.vscode .vscode
# OS
.DS_Store .DS_Store
# Build output
dist dist
xtest
# Data directories
data/
x-*/
# Temporary build files
internal/static/frontend
# Compiled binaries
ushare

250
AGENTS.md Normal file
View File

@@ -0,0 +1,250 @@
# 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
```

View File

@@ -17,6 +17,8 @@ COPY go.sum /app/go.sum
RUN go mod download RUN go mod download
COPY main.go /app/main.go COPY main.go /app/main.go
COPY internal /app/internal 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 . RUN go build -ldflags '-s -w' -o ushare .
# 第三阶段:生成最终镜像 # 第三阶段:生成最终镜像

168
README.md Normal file
View File

@@ -0,0 +1,168 @@
# 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

63
dev.sh Executable file
View File

@@ -0,0 +1,63 @@
#!/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

89
frontend/src/api/admin.ts Normal file
View File

@@ -0,0 +1,89 @@
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;
},
};

53
frontend/src/api/token.ts Normal file
View File

@@ -0,0 +1,53 @@
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 || '删除失败');
}
},
};

View File

@@ -6,12 +6,16 @@ import {Login} from "./page/login.tsx";
import {FileSharing} from "./page/share/share.tsx"; import {FileSharing} from "./page/share/share.tsx";
import {LocalSharing} from "./page/local/local.tsx"; import {LocalSharing} from "./page/local/local.tsx";
import {TestPage} from "./page/test/test.tsx"; import {TestPage} from "./page/test/test.tsx";
import {AdminPage} from "./page/admin/admin.tsx";
import {SelfPage} from "./page/self/self.tsx";
const container = document.getElementById('root') const container = document.getElementById('root')
const root = createRoot(container!) const root = createRoot(container!)
const router = createBrowserRouter([ const router = createBrowserRouter([
{path: "/login", element: <Login />}, {path: "/login", element: <Login />},
{path: "/share", element: <FileSharing />}, {path: "/share", element: <FileSharing />},
{path: "/admin", element: <AdminPage />},
{path: "/self", element: <SelfPage />},
{path: "/test", element: <TestPage />}, {path: "/test", element: <TestPage />},
{path: "*", element: <LocalSharing />}, {path: "*", element: <LocalSharing />},
]) ])

View File

@@ -0,0 +1,533 @@
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>
);
};

View File

@@ -1,7 +1,7 @@
import React, { useState } from "react"; import React, { useState } from "react";
import { createUseStyles } from "react-jss"; import { createUseStyles } from "react-jss";
import { CloudBackground } from "../component/fluid/cloud.tsx"; import { useAuth } from "../api/auth.ts";
import {useAuth} from "../api/auth.ts"; import { UButton } from "../component/button/u-button.tsx";
const useClass = createUseStyles({ const useClass = createUseStyles({
container: { container: {
@@ -14,88 +14,42 @@ const useClass = createUseStyles({
display: "flex", display: "flex",
justifyContent: "center", justifyContent: "center",
alignItems: "center", alignItems: "center",
position: 'relative', backgroundColor: "#e3f2fd",
},
login_container: {
background: "rgba(255,255,255,.5)",
boxShadow: "0 2px 10px rgba(0, 0, 0, 0.1)",
width: "350px",
height: '100%',
position: 'absolute',
left: '70%',
display: "flex",
alignItems: "center",
justifyContent: "center",
}, },
form: { form: {
height: '100%', backgroundColor: "#C8E6C9",
width: '100%', boxShadow: "inset 0 0 15px rgba(56, 142, 60, 0.15)",
padding: "30px",
borderRadius: "15px",
width: "350px",
display: "flex", display: "flex",
justifyContent: "center", flexDirection: "column",
alignItems: "center",
flexDirection: 'column',
color: "#1a73e8",
padding: '40px',
}, },
input: { title: {
width: '100%', color: "#2c9678",
marginTop: '20px', marginTop: 0,
"& > input": { marginBottom: "25px",
width: "calc(100% - 30px)",
padding: "12px 15px",
border: "1px solid #ddd",
borderRadius: "6px",
fontSize: "16px",
transition: "border-color 0.3s",
"&:focus": {
outline: "none",
borderColor: "#1a73e8",
boxShadow: "0 0 0 2px rgba(26, 115, 232, 0.2)",
},
"&:hover": {
borderColor: "#1a73e8",
}
},
}, },
button: {
marginTop: '20px',
width: '100%',
"& > button": {
width: "100%",
padding: "12px",
background: "#1a73e8",
color: "white",
border: "none",
borderRadius: "6px",
fontSize: "16px",
cursor: "pointer",
transition: "background 0.3s",
"&:hover": {
background: "#1557b0",
},
},
},
inputContainer: { inputContainer: {
position: 'relative', position: 'relative',
width: '100%', width: '100%',
marginTop: '20px', marginTop: '15px',
}, },
inputField: { inputField: {
width: "calc(100% - 52px)", width: "100%",
padding: "12px 35px 12px 15px", padding: "11px",
border: "1px solid #ddd", border: "2px solid #ddd",
borderRadius: "6px", borderRadius: "5px",
fontSize: "16px", fontSize: "16px",
boxSizing: "border-box",
transition: "border-color 0.3s", transition: "border-color 0.3s",
background: "rgba(255,255,255,0.8)",
"&:focus": { "&:focus": {
outline: "none", outline: "none",
borderColor: "#1a73e8", borderColor: "#2c9678",
boxShadow: "0 0 0 2px rgba(26, 115, 232, 0.2)",
}, },
"&:hover": { "&:hover": {
borderColor: "#1a73e8", borderColor: "#2c9678",
} }
}, },
iconButton: { iconButton: {
@@ -110,12 +64,19 @@ const useClass = createUseStyles({
"&:hover": { "&:hover": {
color: '#333', color: '#333',
} }
},
button: {
marginTop: '25px',
width: '100%',
"& > button": {
width: "100%",
},
} }
}) })
export const Login: React.FC = () => { export const Login: React.FC = () => {
const classes = useClass() const classes = useClass()
const {login} = useAuth() const { login } = useAuth()
const [username, setUsername] = useState("") const [username, setUsername] = useState("")
const [password, setPassword] = useState("") const [password, setPassword] = useState("")
const [showPassword, setShowPassword] = useState(false) const [showPassword, setShowPassword] = useState(false)
@@ -125,59 +86,54 @@ export const Login: React.FC = () => {
await login(username, password) await login(username, password)
window.location.href = "/" window.location.href = "/"
} catch (_e) { } catch (_e) {
} }
} }
return <div className={classes.container}> return <div className={classes.container}>
<CloudBackground/> <div className={classes.form}>
<div className={classes.login_container}> <h2 className={classes.title}>UShare</h2>
<div className={classes.form}>
<h2>UShare</h2>
{/* 用户名输入框 */} <div className={classes.inputContainer}>
<div className={classes.inputContainer}> <input
<input className={classes.inputField}
className={classes.inputField} placeholder="请输入账号"
placeholder="请输入账号" value={username}
value={username} onChange={(e) => setUsername(e.target.value)}
onChange={(e) => setUsername(e.target.value)} />
/> {username && (
{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 <button
className={classes.iconButton} className={classes.iconButton}
onMouseDown={() => setShowPassword(true)} onClick={() => setUsername("")}
onMouseUp={() => setShowPassword(false)} style={{ right: '10px', fontSize: '16px' }}
onMouseLeave={() => setShowPassword(false)}
style={{ right: '10px', fontSize: '12px' }}
> >
{showPassword ? "👁" : "👁"} ×
</button> </button>
</div> )}
</div>
<div className={classes.button}> <div className={classes.inputContainer}>
<button onClick={onLogin}></button> <input
</div> 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> </div>
</div> </div>
} }

View File

@@ -0,0 +1,429 @@
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>
);
};

View File

@@ -1,6 +1,6 @@
import {createUseStyles} from "react-jss"; import {createUseStyles} from "react-jss";
import {UButton} from "../../../component/button/u-button.tsx"; import {UButton} from "../../../component/button/u-button.tsx";
import React, {useState} from "react"; import React, {useEffect, useState} from "react";
import {useStore} from "../../../store/share.ts"; import {useStore} from "../../../store/share.ts";
import {message} from "../../../hook/message/u-message.tsx"; import {message} from "../../../hook/message/u-message.tsx";
import {useFileUpload} from "../../../api/upload.ts"; import {useFileUpload} from "../../../api/upload.ts";
@@ -11,6 +11,12 @@ const useUploadStyle = createUseStyles({
display: "flex", display: "flex",
justifyContent: "center", justifyContent: "center",
alignItems: "center", alignItems: "center",
minHeight: "50vh",
"@media (max-width: 768px)": {
minHeight: "auto",
padding: "20px 10px",
},
}, },
form: { form: {
backgroundColor: "#C8E6C9", backgroundColor: "#C8E6C9",
@@ -19,10 +25,20 @@ const useUploadStyle = createUseStyles({
borderRadius: "15px", borderRadius: "15px",
width: "70%", width: "70%",
margin: "20px 60px 20px 0", margin: "20px 60px 20px 0",
/*todo margin 不用 px*/
"@media (max-width: 768px)": {
width: "90%",
margin: "20px 0",
padding: "20px",
},
}, },
title: { title: {
color: "#2c9678" color: "#2c9678",
"@media (max-width: 768px)": {
fontSize: "1.5rem",
marginBottom: "15px",
},
}, },
file: { file: {
display: 'none', display: 'none',
@@ -33,13 +49,33 @@ const useUploadStyle = createUseStyles({
}, },
name: { name: {
color: "#2c9678", color: "#2c9678",
marginLeft: '10px' marginLeft: '10px',
"@media (max-width: 768px)": {
fontSize: "14px",
},
}, },
clean: { clean: {
borderRadius: '50%', borderRadius: '50%',
cursor: 'pointer', cursor: 'pointer',
'&:hover': {} '&:hover': {}
} },
adminLink: {
display: 'block',
textAlign: 'center',
marginTop: '16px',
color: '#2c9678',
fontSize: '12px',
textDecoration: 'none',
opacity: 0.8,
'&:hover': {opacity: 1, textDecoration: 'underline'},
},
navLinks: {
display: 'flex',
justifyContent: 'center',
gap: '16px',
marginTop: '16px',
},
}) })
const useShowStyle = createUseStyles({ const useShowStyle = createUseStyles({
@@ -48,14 +84,12 @@ const useShowStyle = createUseStyles({
display: "flex", display: "flex",
justifyContent: "center", justifyContent: "center",
alignItems: "center", alignItems: "center",
position: "relative", // 为关闭按钮提供定位基准 minHeight: "50vh",
},
title: { "@media (max-width: 768px)": {
color: "#2c9678", minHeight: "auto",
marginTop: 0, padding: "20px 10px",
display: "flex", },
justifyContent: "space-between",
alignItems: "center",
}, },
form: { form: {
backgroundColor: "#C8E6C9", backgroundColor: "#C8E6C9",
@@ -65,6 +99,25 @@ const useShowStyle = createUseStyles({
width: "70%", width: "70%",
margin: "20px 60px 20px 0", margin: "20px 60px 20px 0",
position: "relative", 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: { closeButton: {
position: "absolute", position: "absolute",
@@ -78,9 +131,7 @@ const useShowStyle = createUseStyles({
height: "24px", height: "24px",
cursor: "pointer", cursor: "pointer",
"&:hover": { "&:hover": {
// background: "#cc0000",
boxShadow: "20px 20px 60px #fff, -20px -20px 60px #fff", boxShadow: "20px 20px 60px #fff, -20px -20px 60px #fff",
// boxShadow: "20px 20px 60px #eee",
}, },
}, },
codeWrapper: { codeWrapper: {
@@ -89,6 +140,10 @@ const useShowStyle = createUseStyles({
borderRadius: "8px", borderRadius: "8px",
margin: "15px 0", margin: "15px 0",
overflowX: "auto", overflowX: "auto",
"@media (max-width: 768px)": {
padding: "0 10px",
},
}, },
pre: { pre: {
display: 'flex', display: 'flex',
@@ -98,6 +153,12 @@ const useShowStyle = createUseStyles({
height: '24px', height: '24px',
"& > code": { "& > code": {
marginLeft: "0", marginLeft: "0",
fontSize: "14px",
wordBreak: "break-all",
"@media (max-width: 768px)": {
fontSize: "12px",
},
} }
}, },
copyButton: { copyButton: {
@@ -112,6 +173,11 @@ const useShowStyle = createUseStyles({
"&:hover": { "&:hover": {
background: "#1f6d5a", background: "#1f6d5a",
}, },
"@media (max-width: 768px)": {
padding: "6px 12px",
fontSize: "12px",
},
}, },
}); });
@@ -129,6 +195,19 @@ const PanelLeftUpload: React.FC<{ set_code: (code:string) => void }> = ({set_cod
const style = useUploadStyle() const style = useUploadStyle()
const {file, setFile} = useStore() const {file, setFile} = useStore()
const {uploadFile, progress, loading} = useFileUpload(); const {uploadFile, progress, loading} = useFileUpload();
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(() => {});
}, []);
function onFileSelect() { function onFileSelect() {
// @ts-ignore // @ts-ignore
@@ -175,6 +254,12 @@ const PanelLeftUpload: React.FC<{ set_code: (code:string) => void }> = ({set_cod
<div className={style.name}>{file.name}</div> <div className={style.name}>{file.name}</div>
</div> </div>
} }
{isAdmin && (
<a href="/admin" className={style.adminLink}></a>
)}
{hasTokenPerm && (
<a href="/self" className={style.adminLink}> / API Token</a>
)}
</div> </div>
</div> </div>
} }

View File

@@ -5,6 +5,10 @@ const useStyle = createUseStyles({
backgroundColor: 'lightgray', backgroundColor: 'lightgray',
position: "relative", position: "relative",
overflow: "hidden", overflow: "hidden",
"@media (max-width: 768px)": {
display: "none",
},
}, },
left: { left: {
backgroundColor: "#e3f2fd", backgroundColor: "#e3f2fd",

View File

@@ -1,6 +1,6 @@
import {createUseStyles} from "react-jss"; import { createUseStyles } from "react-jss";
import {UButton} from "../../../component/button/u-button.tsx"; import { UButton } from "../../../component/button/u-button.tsx";
import {useStore} from "../../../store/share.ts"; import { useStore } from "../../../store/share.ts";
const useStyle = createUseStyles({ const useStyle = createUseStyles({
container: { container: {
@@ -8,6 +8,12 @@ const useStyle = createUseStyles({
display: "flex", display: "flex",
justifyContent: "center", justifyContent: "center",
alignItems: "center", alignItems: "center",
minHeight: "50vh",
"@media (max-width: 768px)": {
minHeight: "auto",
padding: "20px 10px",
},
}, },
form: { form: {
backgroundColor: "#BBDEFB", backgroundColor: "#BBDEFB",
@@ -16,25 +22,58 @@ const useStyle = createUseStyles({
borderRadius: "15px", borderRadius: "15px",
width: "70%", width: "70%",
margin: "20px 0 20px 60px", margin: "20px 0 20px 60px",
/*todo margin 不用 px*/
"@media (max-width: 768px)": {
width: "90%",
margin: "20px 0",
padding: "20px",
},
}, },
title: { title: {
color: '#1661ab', // 靛青 color: '#1661ab',
"@media (max-width: 768px)": {
fontSize: "1.5rem",
marginBottom: "15px",
},
}, },
code: { code: {
padding: '11px', padding: '11px',
margin: '20px 0', margin: '0',
width: '200px', width: '200px',
border: '2px solid #ddd', border: '2px solid #ddd',
borderRadius: '5px', borderRadius: '5px',
'&:active': { '&:active': {
border: '2px solid #1661ab', 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 = () => { export const PanelRight = () => {
const style = useStyle() const style = useStyle()
const {code, setCode} = useStore() const { code, setCode } = useStore()
function onCodeChange(e: React.ChangeEvent<HTMLInputElement>) { function onCodeChange(e: React.ChangeEvent<HTMLInputElement>) {
setCode(e.currentTarget.value) setCode(e.currentTarget.value)
@@ -52,7 +91,7 @@ export const PanelRight = () => {
return <div className={style.container}> return <div className={style.container}>
<div className={style.form}> <div className={style.form}>
<h2 className={style.title}></h2> <h2 className={style.title}></h2>
<div> <div className={style.inputContainer}>
<input <input
type="text" type="text"
className={style.code} className={style.code}
@@ -60,7 +99,9 @@ export const PanelRight = () => {
value={code} value={code}
onChange={onCodeChange} onChange={onCodeChange}
/> />
<UButton style={{marginLeft: '10px'}} onClick={onFetchFile}></UButton> <div className={style.buttonContainer}>
<UButton onClick={onFetchFile}></UButton>
</div>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -13,6 +13,12 @@ const useStyle = createUseStyles({
height: "100vh", height: "100vh",
display: "grid", display: "grid",
gridTemplateColumns: "40% 20% 40%", gridTemplateColumns: "40% 20% 40%",
"@media (max-width: 768px)": {
gridTemplateColumns: "100%",
gridTemplateRows: "auto auto",
overflowY: "auto",
},
}, },
}) })
@@ -20,7 +26,7 @@ export const FileSharing = () => {
const style = useStyle() const style = useStyle()
return <div className={style.container}> return <div className={style.container}>
<PanelLeft /> <PanelLeft />
<PanelMid/> <PanelMid />
<PanelRight/> <PanelRight/>
</div> </div>
}; };

View File

@@ -5,6 +5,7 @@ import react from '@vitejs/plugin-react'
export default defineConfig({ export default defineConfig({
plugins: [react()], plugins: [react()],
server: { server: {
host: '0.0.0.0',
proxy: { proxy: {
'/api': { '/api': {
target: 'http://127.0.0.1:9119', target: 'http://127.0.0.1:9119',

View File

@@ -2,27 +2,34 @@ package api
import ( import (
"context" "context"
"net"
"net/http"
"github.com/loveuer/nf" "github.com/loveuer/nf"
"github.com/loveuer/nf/nft/log" "github.com/loveuer/nf/nft/log"
"github.com/loveuer/nf/nft/tool" "github.com/loveuer/nf/nft/tool"
"github.com/loveuer/ushare/internal/handler" "github.com/loveuer/ushare/internal/handler"
"github.com/loveuer/ushare/internal/model"
"github.com/loveuer/ushare/internal/opt" "github.com/loveuer/ushare/internal/opt"
"net"
"net/http"
) )
func Start(ctx context.Context) <-chan struct{} { func Start(ctx context.Context) <-chan struct{} {
app := nf.New(nf.Config{BodyLimit: 10 * 1024 * 1024 * 1024}) app := nf.New(nf.Config{BodyLimit: 10 * 1024 * 1024 * 1024})
app.Get("/api/available", func(c *nf.Ctx) error { app.Get("/api/healthz", func(c *nf.Ctx) error {
return c.SendStatus(http.StatusOK) return c.SendStatus(http.StatusOK)
}) })
app.Get("/ushare/:code", handler.Fetch()) // Auth
app.Put("/api/ushare/:filename", handler.AuthVerify(), handler.ShareNew()) // 获取上传 code, 分片大小
app.Post("/api/ushare/:code", handler.ShareUpload()) // 分片上传接口
app.Post("/api/uauth/login", handler.AuthLogin()) 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 := app.Group("/api/ulocal")
api.Post("/register", handler.LocalRegister()) api.Post("/register", handler.LocalRegister())
@@ -33,6 +40,30 @@ func Start(ctx context.Context) <-chan struct{} {
api.Get("/ws", handler.LocalWS()) 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())
ready := make(chan struct{}) ready := make(chan struct{})
ln, err := net.Listen("tcp", opt.Cfg.Address) ln, err := net.Listen("tcp", opt.Cfg.Address)
if err != nil { if err != nil {

View File

@@ -0,0 +1,97 @@
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
err := db.Default.Session().
Where("token = ?", rawToken).
Preload("User").
Preload("User.Role").
First(&t).Error
if err != nil {
return nil, errors.New("无效的 API Token")
}
if t.ExpiresAt != nil && time.Now().After(*t.ExpiresAt) {
return nil, errors.New("API Token 已过期")
}
// 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: t.User.ID,
Username: t.User.Username,
Role: t.User.Role.Name,
RoleLabel: t.User.Role.Label,
Permissions: t.User.Role.PermissionList(),
LoginAt: now.Unix(),
Token: rawToken,
}
return session, nil
}

View File

@@ -2,75 +2,149 @@ package controller
import ( import (
"context" "context"
"github.com/loveuer/ushare/internal/model" "strings"
"github.com/loveuer/ushare/internal/opt"
"github.com/loveuer/ushare/internal/pkg/tool"
"github.com/pkg/errors"
"sync" "sync"
"time" "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 { type userManager struct {
sync.Mutex sync.Mutex
ctx context.Context ctx context.Context
um map[string]*model.User sessions map[string]*model.Session
} }
func (um *userManager) Login(username string, password string) (*model.User, error) { var UserManager = &userManager{
var ( sessions: make(map[string]*model.Session),
now = time.Now() }
)
if username != "admin" { func (um *userManager) seed(ctx context.Context) error {
return nil, errors.New("账号或密码错误") // 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,
},
} }
if !tool.ComparePassword(password, opt.Cfg.Auth) { for i := range defaultRoles {
return nil, errors.New("账号或密码错误") 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)
}
} }
op := &model.User{ // Seed default admin user only if no users exist
Id: 1, 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, Username: username,
LoginAt: now.Unix(), Password: opt.Cfg.Password,
Token: tool.RandomString(32), 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).
Preload("Role").
First(user).Error; err != nil {
return nil, errors.New("账号或密码错误")
}
if !tool.ComparePassword(password, user.Password) {
return nil, errors.New("账号或密码错误")
}
session := &model.Session{
UserID: user.ID,
Username: user.Username,
Role: user.Role.Name,
RoleLabel: user.Role.Label,
Permissions: user.Role.PermissionList(),
LoginAt: now.Unix(),
Token: tool.RandomString(32),
} }
um.Lock() um.Lock()
defer um.Unlock() defer um.Unlock()
um.um[op.Token] = op um.sessions[session.Token] = session
return op, nil return session, nil
} }
func (um *userManager) Verify(token string) (*model.User, error) { func (um *userManager) Verify(token string) (*model.Session, error) {
um.Lock() um.Lock()
defer um.Unlock() defer um.Unlock()
op, ok := um.um[token] session, ok := um.sessions[token]
if !ok { if !ok {
return nil, errors.New("未登录或凭证已失效, 请重新登录") return nil, errors.New("未登录或凭证已失效, 请重新登录")
} }
return op, nil return session, nil
} }
func (um *userManager) Start(ctx context.Context) { func (um *userManager) Start(ctx context.Context) {
um.ctx = ctx um.ctx = ctx
if err := um.seed(ctx); err != nil {
log.Fatal("controller.userManager.Start: seed failed: %s", err.Error())
}
go func() { go func() {
ticker := time.NewTicker(time.Minute) ticker := time.NewTicker(time.Minute)
for { for {
select { select {
case <-um.ctx.Done(): case <-um.ctx.Done():
return return
case now := <-ticker.C: case now := <-ticker.C:
um.Lock() um.Lock()
for _, op := range um.um { for token, session := range um.sessions {
if now.Sub(time.UnixMilli(op.LoginAt)) > 8*time.Hour { if now.Unix()-session.LoginAt > 8*3600 {
delete(um.um, op.Token) delete(um.sessions, token)
} }
} }
um.Unlock() um.Unlock()
@@ -78,9 +152,3 @@ func (um *userManager) Start(ctx context.Context) {
} }
}() }()
} }
var (
UserManager = &userManager{
um: make(map[string]*model.User),
}
)

211
internal/handler/admin.go Normal file
View File

@@ -0,0 +1,211 @@
package handler
import (
"net/http"
"strings"
"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"
)
func AdminListUsers() nf.HandlerFunc {
return func(c *nf.Ctx) error {
var users []model.User
if err := db.Default.Session().Preload("Role").Find(&users).Error; err != nil {
log.Error("handler.AdminListUsers: %s", err.Error())
return c.Status(http.StatusInternalServerError).JSON(map[string]string{"msg": "查询失败"})
}
return c.Status(http.StatusOK).JSON(map[string]any{"data": users})
}
}
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": "用户名已存在"})
}
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": "创建用户失败"})
}
if err := db.Default.Session().Preload("Role").First(user, user.ID).Error; err != nil {
log.Error("handler.AdminCreateUser: preload role: %s", err.Error())
}
return c.Status(http.StatusOK).JSON(map[string]any{"data": user})
}
}
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)
user := new(model.User)
if err := db.Default.Session().Preload("Role").First(user, id).Error; err != nil {
return c.Status(http.StatusNotFound).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 user.Role.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
}
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 user.Role.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": "更新失败"})
}
if err := db.Default.Session().Preload("Role").First(user, user.ID).Error; err != nil {
log.Error("handler.AdminUpdateUser: preload: %s", err.Error())
}
return c.Status(http.StatusOK).JSON(map[string]any{"data": user})
}
}
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": "不能删除自己的账号"})
}
user := new(model.User)
if err := db.Default.Session().Preload("Role").First(user, id).Error; err != nil {
return c.Status(http.StatusNotFound).JSON(map[string]string{"msg": "用户不存在"})
}
// Prevent deleting the last admin
if user.Role.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})
}
}

View File

@@ -2,45 +2,82 @@ package handler
import ( import (
"fmt" "fmt"
"net/http"
"strings"
"github.com/loveuer/nf" "github.com/loveuer/nf"
"github.com/loveuer/ushare/internal/controller" "github.com/loveuer/ushare/internal/controller"
"github.com/loveuer/ushare/internal/model" "github.com/loveuer/ushare/internal/model"
"github.com/loveuer/ushare/internal/opt"
"net/http"
) )
func AuthVerify() nf.HandlerFunc { func AuthVerify() nf.HandlerFunc {
tokenFn := func(c *nf.Ctx) (token string) { tokenFn := func(c *nf.Ctx) (token string) {
if token = c.Get("Authorization"); token != "" { if raw := c.Get("Authorization"); raw != "" {
return // Strip "Bearer " prefix if present
if strings.HasPrefix(raw, "Bearer ") {
return strings.TrimPrefix(raw, "Bearer ")
}
return raw
} }
token = c.Cookies("ushare") token = c.Cookies("ushare")
return return
} }
return func(c *nf.Ctx) error { return func(c *nf.Ctx) error {
if opt.Cfg.Auth == "" {
return c.Next()
}
token := tokenFn(c) token := tokenFn(c)
if token == "" { if token == "" {
return c.Status(http.StatusUnauthorized).JSON(map[string]string{"error": "unauthorized"}) return c.Status(http.StatusUnauthorized).JSON(map[string]string{"error": "unauthorized"})
} }
op, err := controller.UserManager.Verify(token) 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 { if err != nil {
return c.Status(http.StatusUnauthorized).JSON(map[string]string{"error": "unauthorized", "msg": err.Error()}) return c.Status(http.StatusUnauthorized).JSON(map[string]string{"error": "unauthorized", "msg": err.Error()})
} }
c.Locals("user", op) c.Locals("user", session)
return c.Next() 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 { func AuthLogin() nf.HandlerFunc {
return func(c *nf.Ctx) error { return func(c *nf.Ctx) error {
type Req struct { type Req struct {
@@ -49,22 +86,22 @@ func AuthLogin() nf.HandlerFunc {
} }
var ( var (
err error err error
req Req req Req
op *model.User session *model.Session
) )
if err = c.BodyParser(&req); err != nil { if err = c.BodyParser(&req); err != nil {
return c.Status(http.StatusBadRequest).JSON(map[string]string{"msg": "错误的用户名或密码<1>"}) return c.Status(http.StatusBadRequest).JSON(map[string]string{"msg": "错误的用户名或密码<1>"})
} }
if op, err = controller.UserManager.Login(req.Username, req.Password); err != nil { if session, err = controller.UserManager.Login(req.Username, req.Password); err != nil {
return c.Status(http.StatusBadRequest).JSON(map[string]string{"msg": err.Error()}) return c.Status(http.StatusBadRequest).JSON(map[string]string{"msg": err.Error()})
} }
header := fmt.Sprintf("ushare=%s; Path=/; Max-Age=%d", op.Token, 8*3600) header := fmt.Sprintf("ushare=%s; Path=/; Max-Age=%d", session.Token, 8*3600)
c.SetHeader("Set-Cookie", header) c.SetHeader("Set-Cookie", header)
return c.Status(http.StatusOK).JSON(map[string]any{"data": op}) return c.Status(http.StatusOK).JSON(map[string]any{"data": session})
} }
} }

View File

@@ -2,6 +2,11 @@ package handler
import ( import (
"fmt" "fmt"
"net/http"
"os"
"regexp"
"strings"
"github.com/loveuer/nf" "github.com/loveuer/nf"
"github.com/loveuer/nf/nft/log" "github.com/loveuer/nf/nft/log"
"github.com/loveuer/ushare/internal/controller" "github.com/loveuer/ushare/internal/controller"
@@ -10,10 +15,6 @@ import (
"github.com/pkg/errors" "github.com/pkg/errors"
"github.com/spf13/cast" "github.com/spf13/cast"
"github.com/spf13/viper" "github.com/spf13/viper"
"net/http"
"os"
"regexp"
"strings"
) )
func Fetch() nf.HandlerFunc { func Fetch() nf.HandlerFunc {
@@ -116,3 +117,36 @@ func ShareUpload() nf.HandlerFunc {
return c.Status(http.StatusOK).JSON(map[string]any{"size": total, "cursor": cursor}) 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
// Accepts the raw file body and Content-Length header, returns the download code.
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"})
}
code, err := controller.MetaManager.New(size, filename, c.IP())
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
internal/handler/static.go Normal file
View File

@@ -0,0 +1,114 @@
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
internal/handler/token.go Normal file
View File

@@ -0,0 +1,85 @@
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"})
}
}

44
internal/model/role.go Normal file
View File

@@ -0,0 +1,44 @@
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
}

19
internal/model/token.go Normal file
View File

@@ -0,0 +1,19 @@
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"`
User User `gorm:"foreignKey:UserID" json:"-"`
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_"

View File

@@ -1,10 +1,27 @@
package model package model
import "time"
// User is the GORM database model for persistent user storage.
type User struct { type User struct {
Id int `json:"id"` ID uint `gorm:"primarykey" json:"id"`
Username string `json:"username"` Username string `gorm:"uniqueIndex;not null" json:"username"`
Key string `json:"key"` Password string `gorm:"not null" json:"-"`
Password string `json:"-"` RoleID uint `gorm:"not null" json:"role_id"`
LoginAt int64 `json:"login_at"` Role Role `gorm:"foreignKey:RoleID" json:"role"`
Token string `json:"token"` 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"`
} }

View File

@@ -4,13 +4,15 @@ import (
"context" "context"
"github.com/loveuer/nf/nft/log" "github.com/loveuer/nf/nft/log"
"github.com/loveuer/ushare/internal/pkg/tool" "github.com/loveuer/ushare/internal/pkg/tool"
"os"
) )
type config struct { type config struct {
Debug bool Debug bool
Address string Address string
DataPath string DataPath string
Auth string Username string
Password string
CleanInterval int CleanInterval int
} }
@@ -19,8 +21,22 @@ var (
) )
func Init(_ context.Context) { func Init(_ context.Context) {
if Cfg.Auth != "" { if Cfg.Username == "" {
Cfg.Auth = tool.NewPassword(Cfg.Auth) Cfg.Username = "admin"
log.Debug("opt.Init: encrypted password = %s", Cfg.Auth) }
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
} }
} }

View File

@@ -46,6 +46,10 @@ func (c *Client) Session(ctxs ...context.Context) *gorm.DB {
return session return session
} }
func (c *Client) Migrate(models ...interface{}) error {
return c.cli.AutoMigrate(models...)
}
func (c *Client) Close() { func (c *Client) Close() {
d, _ := c.cli.DB() d, _ := c.cli.DB()
d.Close() d.Close()

14
internal/static/static.go Normal file
View File

@@ -0,0 +1,14 @@
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
}

23
main.go
View File

@@ -3,10 +3,15 @@ package main
import ( import (
"context" "context"
"flag" "flag"
"os"
"path/filepath"
"github.com/loveuer/nf/nft/log" "github.com/loveuer/nf/nft/log"
"github.com/loveuer/ushare/internal/api" "github.com/loveuer/ushare/internal/api"
"github.com/loveuer/ushare/internal/controller" "github.com/loveuer/ushare/internal/controller"
"github.com/loveuer/ushare/internal/model"
"github.com/loveuer/ushare/internal/opt" "github.com/loveuer/ushare/internal/opt"
"github.com/loveuer/ushare/internal/pkg/db"
"github.com/loveuer/ushare/internal/pkg/tool" "github.com/loveuer/ushare/internal/pkg/tool"
"os/signal" "os/signal"
"syscall" "syscall"
@@ -16,10 +21,11 @@ func init() {
flag.BoolVar(&opt.Cfg.Debug, "debug", false, "debug mode") 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:9119", "")
flag.StringVar(&opt.Cfg.DataPath, "data", "/data", "") flag.StringVar(&opt.Cfg.DataPath, "data", "/data", "")
flag.StringVar(&opt.Cfg.Auth, "auth", "", "auth required(admin, password)")
flag.IntVar(&opt.Cfg.CleanInterval, "clean", 24, "清理文件的周期, 单位: 小时, 0 则表示不自动清理") flag.IntVar(&opt.Cfg.CleanInterval, "clean", 24, "清理文件的周期, 单位: 小时, 0 则表示不自动清理")
flag.Parse() flag.Parse()
opt.LoadFromEnv()
if opt.Cfg.Debug { if opt.Cfg.Debug {
log.SetLogLevel(log.LogLevelDebug) log.SetLogLevel(log.LogLevelDebug)
tool.TablePrinter(opt.Cfg) tool.TablePrinter(opt.Cfg)
@@ -31,6 +37,21 @@ func main() {
defer cancel() defer cancel()
opt.Init(ctx) 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.UserManager.Start(ctx)
controller.MetaManager.Start(ctx) controller.MetaManager.Start(ctx)
controller.RoomController.Start(ctx) controller.RoomController.Start(ctx)

49
make.sh Executable file
View File

@@ -0,0 +1,49 @@
#!/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 ..."