From 56874b754e790f90ebaa8ec073910ab563f63271 Mon Sep 17 00:00:00 2001 From: loveuer Date: Sat, 17 Jan 2026 23:28:21 +0800 Subject: [PATCH] feat: implement single binary build and env-based auth - 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 --- .gitignore | 16 ++- AGENTS.md | 250 ++++++++++++++++++++++++++++++++++++ dev.sh | 63 +++++++++ frontend/src/page/login.tsx | 180 ++++++++++---------------- frontend/vite.config.ts | 1 + internal/api/api.go | 10 +- internal/controller/user.go | 4 +- internal/handler/auth.go | 2 +- internal/handler/static.go | 114 ++++++++++++++++ internal/opt/opt.go | 24 +++- internal/static/static.go | 14 ++ main.go | 3 +- make.sh | 49 +++++++ 13 files changed, 606 insertions(+), 124 deletions(-) create mode 100644 AGENTS.md create mode 100755 dev.sh create mode 100644 internal/handler/static.go create mode 100644 internal/static/static.go create mode 100755 make.sh diff --git a/.gitignore b/.gitignore index fa7b149..4181094 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,19 @@ +# IDE .idea .vscode + +# OS .DS_Store + +# Build output dist -xtest \ No newline at end of file + +# Data directories +data/ +x-*/ + +# Temporary build files +internal/static/frontend + +# Compiled binaries +ushare diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..0ed7550 --- /dev/null +++ b/AGENTS.md @@ -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` +- 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 = ({ 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()((_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 +``` diff --git a/dev.sh b/dev.sh new file mode 100755 index 0000000..230cd4a --- /dev/null +++ b/dev.sh @@ -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 diff --git a/frontend/src/page/login.tsx b/frontend/src/page/login.tsx index 0dfc70c..857df14 100644 --- a/frontend/src/page/login.tsx +++ b/frontend/src/page/login.tsx @@ -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)", - }, - "&:hover": { - borderColor: "#1a73e8", - } - }, + title: { + color: "#2c9678", + marginTop: 0, + marginBottom: "25px", }, - 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) @@ -125,59 +86,54 @@ export const Login: React.FC = () => { await login(username, password) window.location.href = "/" } catch (_e) { - + } } return
- -
-
-

UShare

+
+

UShare

- {/* 用户名输入框 */} -
- setUsername(e.target.value)} - /> - {username && ( - - )} -
- - {/* 密码输入框 */} -
- setPassword(e.target.value)} - /> +
+ setUsername(e.target.value)} + /> + {username && ( -
+ )} +
-
- -
+
+ setPassword(e.target.value)} + /> + +
+ +
+ 登录
-} \ No newline at end of file +} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 3f2308a..01192cd 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -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', diff --git a/internal/api/api.go b/internal/api/api.go index a12e562..6e9ffe0 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -2,19 +2,20 @@ 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/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) }) @@ -33,6 +34,9 @@ func Start(ctx context.Context) <-chan struct{} { api.Get("/ws", handler.LocalWS()) } + // 静态文件服务 - 作为中间件处理 + app.Use(handler.ServeFrontendMiddleware()) + ready := make(chan struct{}) ln, err := net.Listen("tcp", opt.Cfg.Address) if err != nil { diff --git a/internal/controller/user.go b/internal/controller/user.go index 9a6de51..0462a4e 100644 --- a/internal/controller/user.go +++ b/internal/controller/user.go @@ -21,11 +21,11 @@ func (um *userManager) Login(username string, password string) (*model.User, err now = time.Now() ) - if username != "admin" { + if username != opt.Cfg.Username { return nil, errors.New("账号或密码错误") } - if !tool.ComparePassword(password, opt.Cfg.Auth) { + if !tool.ComparePassword(password, opt.Cfg.Password) { return nil, errors.New("账号或密码错误") } diff --git a/internal/handler/auth.go b/internal/handler/auth.go index 35e8d09..d62d990 100644 --- a/internal/handler/auth.go +++ b/internal/handler/auth.go @@ -21,7 +21,7 @@ func AuthVerify() nf.HandlerFunc { } return func(c *nf.Ctx) error { - if opt.Cfg.Auth == "" { + if opt.Cfg.Username == "" || opt.Cfg.Password == "" { return c.Next() } diff --git a/internal/handler/static.go b/internal/handler/static.go new file mode 100644 index 0000000..0ca7d38 --- /dev/null +++ b/internal/handler/static.go @@ -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" +} diff --git a/internal/opt/opt.go b/internal/opt/opt.go index 5da258d..e3281c7 100644 --- a/internal/opt/opt.go +++ b/internal/opt/opt.go @@ -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 } } diff --git a/internal/static/static.go b/internal/static/static.go new file mode 100644 index 0000000..3721590 --- /dev/null +++ b/internal/static/static.go @@ -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 +} diff --git a/main.go b/main.go index 243779d..e4281e8 100644 --- a/main.go +++ b/main.go @@ -16,10 +16,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) diff --git a/make.sh b/make.sh new file mode 100755 index 0000000..ed2d5b2 --- /dev/null +++ b/make.sh @@ -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 ..."