Compare commits
9 Commits
34930a3992
...
v0.5.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6286332896 | ||
|
|
38986be874 | ||
|
|
90093f79c9 | ||
|
|
5f187bb5d6 | ||
|
|
909a016a44 | ||
|
|
96fe642175 | ||
|
|
d38fa7a507 | ||
|
|
8a063d15d9 | ||
|
|
56874b754e |
@@ -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
|
||||
22
.gitea_backup/workflows/build.yaml
Normal file
22
.gitea_backup/workflows/build.yaml
Normal 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 }} .
|
||||
90
.gitea_backup/workflows/release.yaml
Normal file
90
.gitea_backup/workflows/release.yaml
Normal 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
106
.github/workflows/release.yml
vendored
Normal 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
16
.gitignore
vendored
@@ -1,5 +1,19 @@
|
||||
# IDE
|
||||
.idea
|
||||
.vscode
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
|
||||
# Build output
|
||||
dist
|
||||
xtest
|
||||
|
||||
# Data directories
|
||||
data/
|
||||
x-*/
|
||||
|
||||
# Temporary build files
|
||||
internal/static/frontend
|
||||
|
||||
# Compiled binaries
|
||||
ushare
|
||||
|
||||
250
AGENTS.md
Normal file
250
AGENTS.md
Normal 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
|
||||
```
|
||||
@@ -17,6 +17,8 @@ COPY go.sum /app/go.sum
|
||||
RUN go mod download
|
||||
COPY main.go /app/main.go
|
||||
COPY internal /app/internal
|
||||
# 将前端构建产物复制到 go:embed 所需的路径
|
||||
COPY --from=frontend-builder /app/frontend/dist /app/internal/static/frontend/dist
|
||||
RUN go build -ldflags '-s -w' -o ushare .
|
||||
|
||||
# 第三阶段:生成最终镜像
|
||||
|
||||
168
README.md
Normal file
168
README.md
Normal 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
63
dev.sh
Executable 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
89
frontend/src/api/admin.ts
Normal 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;
|
||||
},
|
||||
};
|
||||
@@ -6,12 +6,14 @@ import {Login} from "./page/login.tsx";
|
||||
import {FileSharing} from "./page/share/share.tsx";
|
||||
import {LocalSharing} from "./page/local/local.tsx";
|
||||
import {TestPage} from "./page/test/test.tsx";
|
||||
import {AdminPage} from "./page/admin/admin.tsx";
|
||||
|
||||
const container = document.getElementById('root')
|
||||
const root = createRoot(container!)
|
||||
const router = createBrowserRouter([
|
||||
{path: "/login", element: <Login />},
|
||||
{path: "/share", element: <FileSharing />},
|
||||
{path: "/admin", element: <AdminPage />},
|
||||
{path: "/test", element: <TestPage />},
|
||||
{path: "*", element: <LocalSharing />},
|
||||
])
|
||||
|
||||
533
frontend/src/page/admin/admin.tsx
Normal file
533
frontend/src/page/admin/admin.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, { useState } from "react";
|
||||
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({
|
||||
container: {
|
||||
@@ -14,88 +14,42 @@ const useClass = createUseStyles({
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
position: 'relative',
|
||||
},
|
||||
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",
|
||||
backgroundColor: "#e3f2fd",
|
||||
},
|
||||
form: {
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
backgroundColor: "#C8E6C9",
|
||||
boxShadow: "inset 0 0 15px rgba(56, 142, 60, 0.15)",
|
||||
padding: "30px",
|
||||
borderRadius: "15px",
|
||||
width: "350px",
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
flexDirection: 'column',
|
||||
color: "#1a73e8",
|
||||
padding: '40px',
|
||||
flexDirection: "column",
|
||||
},
|
||||
input: {
|
||||
width: '100%',
|
||||
marginTop: '20px',
|
||||
"& > input": {
|
||||
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)",
|
||||
title: {
|
||||
color: "#2c9678",
|
||||
marginTop: 0,
|
||||
marginBottom: "25px",
|
||||
},
|
||||
"&: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: {
|
||||
position: 'relative',
|
||||
width: '100%',
|
||||
marginTop: '20px',
|
||||
marginTop: '15px',
|
||||
},
|
||||
inputField: {
|
||||
width: "calc(100% - 52px)",
|
||||
padding: "12px 35px 12px 15px",
|
||||
border: "1px solid #ddd",
|
||||
borderRadius: "6px",
|
||||
width: "100%",
|
||||
padding: "11px",
|
||||
border: "2px solid #ddd",
|
||||
borderRadius: "5px",
|
||||
fontSize: "16px",
|
||||
boxSizing: "border-box",
|
||||
transition: "border-color 0.3s",
|
||||
background: "rgba(255,255,255,0.8)",
|
||||
"&:focus": {
|
||||
outline: "none",
|
||||
borderColor: "#1a73e8",
|
||||
boxShadow: "0 0 0 2px rgba(26, 115, 232, 0.2)",
|
||||
borderColor: "#2c9678",
|
||||
},
|
||||
"&:hover": {
|
||||
borderColor: "#1a73e8",
|
||||
borderColor: "#2c9678",
|
||||
}
|
||||
},
|
||||
iconButton: {
|
||||
@@ -110,12 +64,19 @@ const useClass = createUseStyles({
|
||||
"&:hover": {
|
||||
color: '#333',
|
||||
}
|
||||
},
|
||||
button: {
|
||||
marginTop: '25px',
|
||||
width: '100%',
|
||||
"& > button": {
|
||||
width: "100%",
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
export const Login: React.FC = () => {
|
||||
const classes = useClass()
|
||||
const {login} = useAuth()
|
||||
const { login } = useAuth()
|
||||
const [username, setUsername] = useState("")
|
||||
const [password, setPassword] = useState("")
|
||||
const [showPassword, setShowPassword] = useState(false)
|
||||
@@ -130,12 +91,9 @@ export const Login: React.FC = () => {
|
||||
}
|
||||
|
||||
return <div className={classes.container}>
|
||||
<CloudBackground/>
|
||||
<div className={classes.login_container}>
|
||||
<div className={classes.form}>
|
||||
<h2>UShare</h2>
|
||||
<h2 className={classes.title}>UShare</h2>
|
||||
|
||||
{/* 用户名输入框 */}
|
||||
<div className={classes.inputContainer}>
|
||||
<input
|
||||
className={classes.inputField}
|
||||
@@ -154,7 +112,6 @@ export const Login: React.FC = () => {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 密码输入框 */}
|
||||
<div className={classes.inputContainer}>
|
||||
<input
|
||||
className={classes.inputField}
|
||||
@@ -170,13 +127,12 @@ export const Login: React.FC = () => {
|
||||
onMouseLeave={() => setShowPassword(false)}
|
||||
style={{ right: '10px', fontSize: '12px' }}
|
||||
>
|
||||
{showPassword ? "👁" : "👁"}
|
||||
{showPassword ? "🫣" : "🙈"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className={classes.button}>
|
||||
<button onClick={onLogin}>登录</button>
|
||||
</div>
|
||||
<UButton onClick={onLogin}>登录</UButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import {createUseStyles} from "react-jss";
|
||||
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 {message} from "../../../hook/message/u-message.tsx";
|
||||
import {useFileUpload} from "../../../api/upload.ts";
|
||||
@@ -11,6 +11,12 @@ const useUploadStyle = createUseStyles({
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
minHeight: "50vh",
|
||||
|
||||
"@media (max-width: 768px)": {
|
||||
minHeight: "auto",
|
||||
padding: "20px 10px",
|
||||
},
|
||||
},
|
||||
form: {
|
||||
backgroundColor: "#C8E6C9",
|
||||
@@ -19,10 +25,20 @@ const useUploadStyle = createUseStyles({
|
||||
borderRadius: "15px",
|
||||
width: "70%",
|
||||
margin: "20px 60px 20px 0",
|
||||
/*todo margin 不用 px*/
|
||||
|
||||
"@media (max-width: 768px)": {
|
||||
width: "90%",
|
||||
margin: "20px 0",
|
||||
padding: "20px",
|
||||
},
|
||||
},
|
||||
title: {
|
||||
color: "#2c9678"
|
||||
color: "#2c9678",
|
||||
|
||||
"@media (max-width: 768px)": {
|
||||
fontSize: "1.5rem",
|
||||
marginBottom: "15px",
|
||||
},
|
||||
},
|
||||
file: {
|
||||
display: 'none',
|
||||
@@ -33,13 +49,27 @@ const useUploadStyle = createUseStyles({
|
||||
},
|
||||
name: {
|
||||
color: "#2c9678",
|
||||
marginLeft: '10px'
|
||||
marginLeft: '10px',
|
||||
|
||||
"@media (max-width: 768px)": {
|
||||
fontSize: "14px",
|
||||
},
|
||||
},
|
||||
clean: {
|
||||
borderRadius: '50%',
|
||||
cursor: 'pointer',
|
||||
'&:hover': {}
|
||||
}
|
||||
},
|
||||
adminLink: {
|
||||
display: 'block',
|
||||
textAlign: 'center',
|
||||
marginTop: '16px',
|
||||
color: '#2c9678',
|
||||
fontSize: '12px',
|
||||
textDecoration: 'none',
|
||||
opacity: 0.8,
|
||||
'&:hover': {opacity: 1, textDecoration: 'underline'},
|
||||
},
|
||||
})
|
||||
|
||||
const useShowStyle = createUseStyles({
|
||||
@@ -48,14 +78,12 @@ const useShowStyle = createUseStyles({
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
position: "relative", // 为关闭按钮提供定位基准
|
||||
minHeight: "50vh",
|
||||
|
||||
"@media (max-width: 768px)": {
|
||||
minHeight: "auto",
|
||||
padding: "20px 10px",
|
||||
},
|
||||
title: {
|
||||
color: "#2c9678",
|
||||
marginTop: 0,
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
},
|
||||
form: {
|
||||
backgroundColor: "#C8E6C9",
|
||||
@@ -65,6 +93,25 @@ const useShowStyle = createUseStyles({
|
||||
width: "70%",
|
||||
margin: "20px 60px 20px 0",
|
||||
position: "relative",
|
||||
|
||||
"@media (max-width: 768px)": {
|
||||
width: "90%",
|
||||
margin: "20px 0",
|
||||
padding: "20px",
|
||||
},
|
||||
},
|
||||
title: {
|
||||
color: "#2c9678",
|
||||
marginTop: 0,
|
||||
marginBottom: "25px",
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
|
||||
"@media (max-width: 768px)": {
|
||||
fontSize: "1.5rem",
|
||||
marginBottom: "15px",
|
||||
},
|
||||
},
|
||||
closeButton: {
|
||||
position: "absolute",
|
||||
@@ -78,9 +125,7 @@ const useShowStyle = createUseStyles({
|
||||
height: "24px",
|
||||
cursor: "pointer",
|
||||
"&:hover": {
|
||||
// background: "#cc0000",
|
||||
boxShadow: "20px 20px 60px #fff, -20px -20px 60px #fff",
|
||||
// boxShadow: "20px 20px 60px #eee",
|
||||
},
|
||||
},
|
||||
codeWrapper: {
|
||||
@@ -89,6 +134,10 @@ const useShowStyle = createUseStyles({
|
||||
borderRadius: "8px",
|
||||
margin: "15px 0",
|
||||
overflowX: "auto",
|
||||
|
||||
"@media (max-width: 768px)": {
|
||||
padding: "0 10px",
|
||||
},
|
||||
},
|
||||
pre: {
|
||||
display: 'flex',
|
||||
@@ -98,6 +147,12 @@ const useShowStyle = createUseStyles({
|
||||
height: '24px',
|
||||
"& > code": {
|
||||
marginLeft: "0",
|
||||
fontSize: "14px",
|
||||
wordBreak: "break-all",
|
||||
|
||||
"@media (max-width: 768px)": {
|
||||
fontSize: "12px",
|
||||
},
|
||||
}
|
||||
},
|
||||
copyButton: {
|
||||
@@ -112,6 +167,11 @@ const useShowStyle = createUseStyles({
|
||||
"&:hover": {
|
||||
background: "#1f6d5a",
|
||||
},
|
||||
|
||||
"@media (max-width: 768px)": {
|
||||
padding: "6px 12px",
|
||||
fontSize: "12px",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -129,6 +189,17 @@ const PanelLeftUpload: React.FC<{ set_code: (code:string) => void }> = ({set_cod
|
||||
const style = useUploadStyle()
|
||||
const {file, setFile} = useStore()
|
||||
const {uploadFile, progress, loading} = useFileUpload();
|
||||
const [isAdmin, setIsAdmin] = 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'));
|
||||
}
|
||||
}).catch(() => {});
|
||||
}, []);
|
||||
|
||||
function onFileSelect() {
|
||||
// @ts-ignore
|
||||
@@ -175,6 +246,9 @@ const PanelLeftUpload: React.FC<{ set_code: (code:string) => void }> = ({set_cod
|
||||
<div className={style.name}>{file.name}</div>
|
||||
</div>
|
||||
}
|
||||
{isAdmin && (
|
||||
<a href="/admin" className={style.adminLink}>管理控制台</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -5,6 +5,10 @@ const useStyle = createUseStyles({
|
||||
backgroundColor: 'lightgray',
|
||||
position: "relative",
|
||||
overflow: "hidden",
|
||||
|
||||
"@media (max-width: 768px)": {
|
||||
display: "none",
|
||||
},
|
||||
},
|
||||
left: {
|
||||
backgroundColor: "#e3f2fd",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import {createUseStyles} from "react-jss";
|
||||
import {UButton} from "../../../component/button/u-button.tsx";
|
||||
import {useStore} from "../../../store/share.ts";
|
||||
import { createUseStyles } from "react-jss";
|
||||
import { UButton } from "../../../component/button/u-button.tsx";
|
||||
import { useStore } from "../../../store/share.ts";
|
||||
|
||||
const useStyle = createUseStyles({
|
||||
container: {
|
||||
@@ -8,6 +8,12 @@ const useStyle = createUseStyles({
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
minHeight: "50vh",
|
||||
|
||||
"@media (max-width: 768px)": {
|
||||
minHeight: "auto",
|
||||
padding: "20px 10px",
|
||||
},
|
||||
},
|
||||
form: {
|
||||
backgroundColor: "#BBDEFB",
|
||||
@@ -16,25 +22,58 @@ const useStyle = createUseStyles({
|
||||
borderRadius: "15px",
|
||||
width: "70%",
|
||||
margin: "20px 0 20px 60px",
|
||||
/*todo margin 不用 px*/
|
||||
|
||||
"@media (max-width: 768px)": {
|
||||
width: "90%",
|
||||
margin: "20px 0",
|
||||
padding: "20px",
|
||||
},
|
||||
},
|
||||
title: {
|
||||
color: '#1661ab', // 靛青
|
||||
color: '#1661ab',
|
||||
|
||||
"@media (max-width: 768px)": {
|
||||
fontSize: "1.5rem",
|
||||
marginBottom: "15px",
|
||||
},
|
||||
},
|
||||
code: {
|
||||
padding: '11px',
|
||||
margin: '20px 0',
|
||||
margin: '0',
|
||||
width: '200px',
|
||||
border: '2px solid #ddd',
|
||||
borderRadius: '5px',
|
||||
'&:active': {
|
||||
border: '2px solid #1661ab',
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
"@media (max-width: 768px)": {
|
||||
width: '100%',
|
||||
boxSizing: 'border-box',
|
||||
padding: '10px',
|
||||
fontSize: '14px',
|
||||
},
|
||||
},
|
||||
inputContainer: {
|
||||
display: 'flex',
|
||||
gap: '10px',
|
||||
|
||||
"@media (max-width: 768px)": {
|
||||
flexDirection: 'column',
|
||||
alignItems: 'stretch',
|
||||
},
|
||||
},
|
||||
buttonContainer: {
|
||||
display: 'flex',
|
||||
|
||||
"@media (max-width: 768px)": {
|
||||
justifyContent: 'left',
|
||||
},
|
||||
},
|
||||
})
|
||||
export const PanelRight = () => {
|
||||
const style = useStyle()
|
||||
const {code, setCode} = useStore()
|
||||
const { code, setCode } = useStore()
|
||||
|
||||
function onCodeChange(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
setCode(e.currentTarget.value)
|
||||
@@ -52,7 +91,7 @@ export const PanelRight = () => {
|
||||
return <div className={style.container}>
|
||||
<div className={style.form}>
|
||||
<h2 className={style.title}>获取文件</h2>
|
||||
<div>
|
||||
<div className={style.inputContainer}>
|
||||
<input
|
||||
type="text"
|
||||
className={style.code}
|
||||
@@ -60,7 +99,9 @@ export const PanelRight = () => {
|
||||
value={code}
|
||||
onChange={onCodeChange}
|
||||
/>
|
||||
<UButton style={{marginLeft: '10px'}} onClick={onFetchFile}>获取文件</UButton>
|
||||
<div className={style.buttonContainer}>
|
||||
<UButton onClick={onFetchFile}>获取文件</UButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -13,6 +13,12 @@ const useStyle = createUseStyles({
|
||||
height: "100vh",
|
||||
display: "grid",
|
||||
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()
|
||||
return <div className={style.container}>
|
||||
<PanelLeft />
|
||||
<PanelMid/>
|
||||
<PanelMid />
|
||||
<PanelRight/>
|
||||
</div>
|
||||
};
|
||||
@@ -5,6 +5,7 @@ import react from '@vitejs/plugin-react'
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
host: '0.0.0.0',
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://127.0.0.1:9119',
|
||||
|
||||
@@ -2,27 +2,34 @@ package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
"net/http"
|
||||
|
||||
"github.com/loveuer/nf"
|
||||
"github.com/loveuer/nf/nft/log"
|
||||
"github.com/loveuer/nf/nft/tool"
|
||||
"github.com/loveuer/ushare/internal/handler"
|
||||
"github.com/loveuer/ushare/internal/model"
|
||||
"github.com/loveuer/ushare/internal/opt"
|
||||
"net"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
func Start(ctx context.Context) <-chan struct{} {
|
||||
app := nf.New(nf.Config{BodyLimit: 10 * 1024 * 1024 * 1024})
|
||||
|
||||
app.Get("/api/available", func(c *nf.Ctx) error {
|
||||
app.Get("/api/healthz", func(c *nf.Ctx) error {
|
||||
return c.SendStatus(http.StatusOK)
|
||||
})
|
||||
|
||||
app.Get("/ushare/:code", handler.Fetch())
|
||||
app.Put("/api/ushare/:filename", handler.AuthVerify(), handler.ShareNew()) // 获取上传 code, 分片大小
|
||||
app.Post("/api/ushare/:code", handler.ShareUpload()) // 分片上传接口
|
||||
// Auth
|
||||
app.Post("/api/uauth/login", handler.AuthLogin())
|
||||
app.Get("/api/uauth/me", handler.AuthVerify(), handler.AuthMe())
|
||||
|
||||
// File sharing
|
||||
app.Get("/ushare/:code", handler.Fetch())
|
||||
app.Put("/api/ushare/:filename", handler.AuthVerify(), handler.AuthPermission(model.PermUpload), handler.ShareNew())
|
||||
app.Post("/api/ushare/:code", handler.ShareUpload())
|
||||
|
||||
// Local sharing (WebRTC signaling)
|
||||
{
|
||||
api := app.Group("/api/ulocal")
|
||||
api.Post("/register", handler.LocalRegister())
|
||||
@@ -33,6 +40,19 @@ func Start(ctx context.Context) <-chan struct{} {
|
||||
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())
|
||||
}
|
||||
|
||||
// Frontend static files
|
||||
app.Use(handler.ServeFrontendMiddleware())
|
||||
|
||||
ready := make(chan struct{})
|
||||
ln, err := net.Listen("tcp", opt.Cfg.Address)
|
||||
if err != nil {
|
||||
|
||||
@@ -2,75 +2,149 @@ package controller
|
||||
|
||||
import (
|
||||
"context"
|
||||
"github.com/loveuer/ushare/internal/model"
|
||||
"github.com/loveuer/ushare/internal/opt"
|
||||
"github.com/loveuer/ushare/internal/pkg/tool"
|
||||
"github.com/pkg/errors"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/loveuer/nf/nft/log"
|
||||
"github.com/loveuer/ushare/internal/model"
|
||||
"github.com/loveuer/ushare/internal/opt"
|
||||
"github.com/loveuer/ushare/internal/pkg/db"
|
||||
"github.com/loveuer/ushare/internal/pkg/tool"
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
type userManager struct {
|
||||
sync.Mutex
|
||||
ctx context.Context
|
||||
um map[string]*model.User
|
||||
sessions map[string]*model.Session
|
||||
}
|
||||
|
||||
func (um *userManager) Login(username string, password string) (*model.User, error) {
|
||||
var (
|
||||
now = time.Now()
|
||||
)
|
||||
var UserManager = &userManager{
|
||||
sessions: make(map[string]*model.Session),
|
||||
}
|
||||
|
||||
if username != "admin" {
|
||||
return nil, errors.New("账号或密码错误")
|
||||
func (um *userManager) seed(ctx context.Context) error {
|
||||
// Seed default roles if they don't exist
|
||||
defaultRoles := []model.Role{
|
||||
{
|
||||
Name: model.RoleAdmin,
|
||||
Label: "管理员",
|
||||
Permissions: strings.Join([]string{model.PermUserManage, model.PermUpload, model.PermTokenManage}, ","),
|
||||
},
|
||||
{
|
||||
Name: model.RoleUser,
|
||||
Label: "用户",
|
||||
Permissions: model.PermUpload,
|
||||
},
|
||||
}
|
||||
|
||||
if !tool.ComparePassword(password, opt.Cfg.Auth) {
|
||||
return nil, errors.New("账号或密码错误")
|
||||
for i := range defaultRoles {
|
||||
role := &defaultRoles[i]
|
||||
var existing model.Role
|
||||
if err := db.Default.Session(ctx).Where("name = ?", role.Name).First(&existing).Error; err != nil {
|
||||
if err := db.Default.Session(ctx).Create(role).Error; err != nil {
|
||||
return errors.Wrap(err, "seed role failed")
|
||||
}
|
||||
log.Debug("controller.userManager.seed: created role %s", role.Name)
|
||||
}
|
||||
}
|
||||
|
||||
op := &model.User{
|
||||
Id: 1,
|
||||
// Seed default admin user only if no users exist
|
||||
var count int64
|
||||
db.Default.Session(ctx).Model(&model.User{}).Count(&count)
|
||||
if count > 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
var adminRole model.Role
|
||||
if err := db.Default.Session(ctx).Where("name = ?", model.RoleAdmin).First(&adminRole).Error; err != nil {
|
||||
return errors.Wrap(err, "get admin role failed")
|
||||
}
|
||||
|
||||
username := opt.Cfg.Username
|
||||
if username == "" {
|
||||
username = "admin"
|
||||
}
|
||||
|
||||
// opt.Cfg.Password is already hashed by opt.Init(); store it directly.
|
||||
adminUser := &model.User{
|
||||
Username: username,
|
||||
Password: opt.Cfg.Password,
|
||||
RoleID: adminRole.ID,
|
||||
Active: true,
|
||||
}
|
||||
|
||||
if err := db.Default.Session(ctx).Create(adminUser).Error; err != nil {
|
||||
return errors.Wrap(err, "seed admin user failed")
|
||||
}
|
||||
|
||||
log.Debug("controller.userManager.seed: created admin user %s", username)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (um *userManager) Login(username, password string) (*model.Session, error) {
|
||||
now := time.Now()
|
||||
|
||||
user := new(model.User)
|
||||
if err := db.Default.Session().
|
||||
Where("username = ? AND active = ?", username, true).
|
||||
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()
|
||||
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()
|
||||
defer um.Unlock()
|
||||
|
||||
op, ok := um.um[token]
|
||||
session, ok := um.sessions[token]
|
||||
if !ok {
|
||||
return nil, errors.New("未登录或凭证已失效, 请重新登录")
|
||||
}
|
||||
|
||||
return op, nil
|
||||
return session, nil
|
||||
}
|
||||
|
||||
func (um *userManager) Start(ctx context.Context) {
|
||||
um.ctx = ctx
|
||||
|
||||
if err := um.seed(ctx); err != nil {
|
||||
log.Fatal("controller.userManager.Start: seed failed: %s", err.Error())
|
||||
}
|
||||
|
||||
go func() {
|
||||
|
||||
ticker := time.NewTicker(time.Minute)
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-um.ctx.Done():
|
||||
return
|
||||
case now := <-ticker.C:
|
||||
um.Lock()
|
||||
for _, op := range um.um {
|
||||
if now.Sub(time.UnixMilli(op.LoginAt)) > 8*time.Hour {
|
||||
delete(um.um, op.Token)
|
||||
for token, session := range um.sessions {
|
||||
if now.Unix()-session.LoginAt > 8*3600 {
|
||||
delete(um.sessions, token)
|
||||
}
|
||||
}
|
||||
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
211
internal/handler/admin.go
Normal 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})
|
||||
}
|
||||
}
|
||||
@@ -2,11 +2,11 @@ package handler
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/loveuer/nf"
|
||||
"github.com/loveuer/ushare/internal/controller"
|
||||
"github.com/loveuer/ushare/internal/model"
|
||||
"github.com/loveuer/ushare/internal/opt"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
func AuthVerify() nf.HandlerFunc {
|
||||
@@ -14,33 +14,54 @@ func AuthVerify() nf.HandlerFunc {
|
||||
if token = c.Get("Authorization"); token != "" {
|
||||
return
|
||||
}
|
||||
|
||||
token = c.Cookies("ushare")
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
return func(c *nf.Ctx) error {
|
||||
if opt.Cfg.Auth == "" {
|
||||
return c.Next()
|
||||
}
|
||||
|
||||
token := tokenFn(c)
|
||||
if token == "" {
|
||||
return c.Status(http.StatusUnauthorized).JSON(map[string]string{"error": "unauthorized"})
|
||||
}
|
||||
|
||||
op, err := controller.UserManager.Verify(token)
|
||||
session, err := controller.UserManager.Verify(token)
|
||||
if err != nil {
|
||||
return c.Status(http.StatusUnauthorized).JSON(map[string]string{"error": "unauthorized", "msg": err.Error()})
|
||||
}
|
||||
|
||||
c.Locals("user", op)
|
||||
c.Locals("user", session)
|
||||
|
||||
return c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
func AuthPermission(perm string) nf.HandlerFunc {
|
||||
return func(c *nf.Ctx) error {
|
||||
session, ok := c.Locals("user").(*model.Session)
|
||||
if !ok || session == nil {
|
||||
return c.Status(http.StatusUnauthorized).JSON(map[string]string{"error": "unauthorized"})
|
||||
}
|
||||
|
||||
for _, p := range session.Permissions {
|
||||
if p == perm {
|
||||
return c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
return c.Status(http.StatusForbidden).JSON(map[string]string{"error": "forbidden", "msg": "权限不足"})
|
||||
}
|
||||
}
|
||||
|
||||
func AuthMe() nf.HandlerFunc {
|
||||
return func(c *nf.Ctx) error {
|
||||
session, ok := c.Locals("user").(*model.Session)
|
||||
if !ok || session == nil {
|
||||
return c.Status(http.StatusUnauthorized).JSON(map[string]string{"error": "unauthorized"})
|
||||
}
|
||||
return c.Status(http.StatusOK).JSON(map[string]any{"data": session})
|
||||
}
|
||||
}
|
||||
|
||||
func AuthLogin() nf.HandlerFunc {
|
||||
return func(c *nf.Ctx) error {
|
||||
type Req struct {
|
||||
@@ -51,20 +72,20 @@ func AuthLogin() nf.HandlerFunc {
|
||||
var (
|
||||
err error
|
||||
req Req
|
||||
op *model.User
|
||||
session *model.Session
|
||||
)
|
||||
|
||||
if err = c.BodyParser(&req); err != nil {
|
||||
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()})
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
return c.Status(http.StatusOK).JSON(map[string]any{"data": op})
|
||||
return c.Status(http.StatusOK).JSON(map[string]any{"data": session})
|
||||
}
|
||||
}
|
||||
|
||||
114
internal/handler/static.go
Normal file
114
internal/handler/static.go
Normal 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"
|
||||
}
|
||||
44
internal/model/role.go
Normal file
44
internal/model/role.go
Normal 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
|
||||
}
|
||||
@@ -1,10 +1,27 @@
|
||||
package model
|
||||
|
||||
import "time"
|
||||
|
||||
// User is the GORM database model for persistent user storage.
|
||||
type User struct {
|
||||
Id int `json:"id"`
|
||||
ID uint `gorm:"primarykey" json:"id"`
|
||||
Username string `gorm:"uniqueIndex;not null" json:"username"`
|
||||
Password string `gorm:"not null" json:"-"`
|
||||
RoleID uint `gorm:"not null" json:"role_id"`
|
||||
Role Role `gorm:"foreignKey:RoleID" json:"role"`
|
||||
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"`
|
||||
Key string `json:"key"`
|
||||
Password string `json:"-"`
|
||||
Role string `json:"role"`
|
||||
RoleLabel string `json:"role_label"`
|
||||
Permissions []string `json:"permissions"`
|
||||
LoginAt int64 `json:"login_at"`
|
||||
Token string `json:"token"`
|
||||
}
|
||||
|
||||
@@ -4,13 +4,15 @@ import (
|
||||
"context"
|
||||
"github.com/loveuer/nf/nft/log"
|
||||
"github.com/loveuer/ushare/internal/pkg/tool"
|
||||
"os"
|
||||
)
|
||||
|
||||
type config struct {
|
||||
Debug bool
|
||||
Address string
|
||||
DataPath string
|
||||
Auth string
|
||||
Username string
|
||||
Password string
|
||||
CleanInterval int
|
||||
}
|
||||
|
||||
@@ -19,8 +21,22 @@ var (
|
||||
)
|
||||
|
||||
func Init(_ context.Context) {
|
||||
if Cfg.Auth != "" {
|
||||
Cfg.Auth = tool.NewPassword(Cfg.Auth)
|
||||
log.Debug("opt.Init: encrypted password = %s", Cfg.Auth)
|
||||
if Cfg.Username == "" {
|
||||
Cfg.Username = "admin"
|
||||
}
|
||||
if Cfg.Password == "" {
|
||||
Cfg.Password = "ushare@123"
|
||||
}
|
||||
|
||||
Cfg.Password = tool.NewPassword(Cfg.Password)
|
||||
log.Debug("opt.Init: username = %s, encrypted password = %s", Cfg.Username, Cfg.Password)
|
||||
}
|
||||
|
||||
func LoadFromEnv() {
|
||||
if username := os.Getenv("USHARE_USERNAME"); username != "" {
|
||||
Cfg.Username = username
|
||||
}
|
||||
if password := os.Getenv("USHARE_PASSWORD"); password != "" {
|
||||
Cfg.Password = password
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,6 +46,10 @@ func (c *Client) Session(ctxs ...context.Context) *gorm.DB {
|
||||
return session
|
||||
}
|
||||
|
||||
func (c *Client) Migrate(models ...interface{}) error {
|
||||
return c.cli.AutoMigrate(models...)
|
||||
}
|
||||
|
||||
func (c *Client) Close() {
|
||||
d, _ := c.cli.DB()
|
||||
d.Close()
|
||||
|
||||
14
internal/static/static.go
Normal file
14
internal/static/static.go
Normal 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
23
main.go
@@ -3,10 +3,15 @@ package main
|
||||
import (
|
||||
"context"
|
||||
"flag"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/loveuer/nf/nft/log"
|
||||
"github.com/loveuer/ushare/internal/api"
|
||||
"github.com/loveuer/ushare/internal/controller"
|
||||
"github.com/loveuer/ushare/internal/model"
|
||||
"github.com/loveuer/ushare/internal/opt"
|
||||
"github.com/loveuer/ushare/internal/pkg/db"
|
||||
"github.com/loveuer/ushare/internal/pkg/tool"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
@@ -16,10 +21,11 @@ func init() {
|
||||
flag.BoolVar(&opt.Cfg.Debug, "debug", false, "debug mode")
|
||||
flag.StringVar(&opt.Cfg.Address, "address", "0.0.0.0:9119", "")
|
||||
flag.StringVar(&opt.Cfg.DataPath, "data", "/data", "")
|
||||
flag.StringVar(&opt.Cfg.Auth, "auth", "", "auth required(admin, password)")
|
||||
flag.IntVar(&opt.Cfg.CleanInterval, "clean", 24, "清理文件的周期, 单位: 小时, 0 则表示不自动清理")
|
||||
flag.Parse()
|
||||
|
||||
opt.LoadFromEnv()
|
||||
|
||||
if opt.Cfg.Debug {
|
||||
log.SetLogLevel(log.LogLevelDebug)
|
||||
tool.TablePrinter(opt.Cfg)
|
||||
@@ -31,6 +37,21 @@ func main() {
|
||||
defer cancel()
|
||||
|
||||
opt.Init(ctx)
|
||||
|
||||
if err := os.MkdirAll(opt.Cfg.DataPath, 0755); err != nil {
|
||||
log.Fatal("main: create data path failed: %s", err.Error())
|
||||
}
|
||||
|
||||
dbPath := filepath.Join(opt.Cfg.DataPath, ".ushare.db")
|
||||
if err := db.Init(ctx, "sqlite::"+dbPath); err != nil {
|
||||
log.Fatal("main: init db failed: %s", err.Error())
|
||||
}
|
||||
log.Debug("main: db initialized at %s", dbPath)
|
||||
|
||||
if err := db.Default.Migrate(&model.Role{}, &model.User{}); err != nil {
|
||||
log.Fatal("main: db migrate failed: %s", err.Error())
|
||||
}
|
||||
|
||||
controller.UserManager.Start(ctx)
|
||||
controller.MetaManager.Start(ctx)
|
||||
controller.RoomController.Start(ctx)
|
||||
|
||||
49
make.sh
Executable file
49
make.sh
Executable 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 ..."
|
||||
Reference in New Issue
Block a user