chore: 云端服务源码不再纳入版本库
26 个文件变更
+6
-4266
fangqihang1717@163.com
| •.gitignore | +2 -3 |
| •README.md | +4 -10 |
| •cloud/deploy/clearlove-cloud.service | +0 -25 |
| •cloud/deploy/nginx.conf | +0 -40 |
| •cloud/go.mod | +0 -21 |
| •cloud/go.sum | +0 -49 |
| •cloud/internal/config/config.go | +0 -90 |
| •cloud/internal/database/database.go | +0 -190 |
| •cloud/internal/handlers/admin.go | +0 -282 |
| •cloud/internal/handlers/dev.go | +0 -594 |
| •cloud/internal/handlers/handlers.go | +0 -255 |
| •cloud/internal/handlers/site.go | +0 -196 |
| •cloud/internal/handlers/store.go | +0 -175 |
| •cloud/internal/mailer/mailer.go | +0 -66 |
| •cloud/internal/middleware/middleware.go | +0 -86 |
| •cloud/internal/models/models.go | +0 -212 |
| •cloud/internal/util/util.go | +0 -170 |
| •cloud/main.go | +0 -128 |
| •cloud/web/static/site.css | +0 -497 |
| •cloud/web/static/site.js | +0 -139 |
| •cloud/web/templates/admin.html | +0 -218 |
| •cloud/web/templates/admin_layout.html | +0 -50 |
| •cloud/web/templates/dev.html | +0 -208 |
| •cloud/web/templates/dev_layout.html | +0 -44 |
| •cloud/web/templates/layout.html | +0 -50 |
| •cloud/web/templates/pages.html | +0 -468 |
变更内容
diff --git a/.gitignore b/.gitignore
index 07c10cd..c17e9dc 100644
--- a/.gitignore
+++ b/.gitignore
@@ -5,9 +5,8 @@
*.exe
*.log
-# 云端服务运行时目录
-cloud/data/
-cloud/uploads/
+# 云端服务源码不对外发布(仅保留在本地,不纳入版本库)
+cloud/
# 本地 IDE / 助手数据
.codebuddy/
diff --git a/README.md b/README.md
index e01bc25..756b199 100644
--- a/README.md
+++ b/README.md
@@ -30,7 +30,7 @@ ClearLove 表白墙是一套面向个人与小型社区的**表白墙 / 匿名
- 📱 **移动端适配** —— 前台瀑布流与后台管理均适配 PC / 手机
- 🔒 **安全默认** —— bcrypt、HMAC 会话、CSRF、XSS 转义、IP/指纹封禁
-> 云端插件社区(独立模块 [`cloud/`](cloud/))同时提供官网、插件市场、开发者发布与开放 API,部署于 <https://clearlove.kazx.top>。
+> 官方插件社区部署于 <https://clearlove.kazx.top>,提供插件市场、开发者发布与开放 API,表白墙后台可一键安装其中的插件。
---
@@ -331,14 +331,9 @@ docker run -d --name clearlove --restart=always \
├── web/
│ ├── templates/ # 全部页面模板(内嵌)
│ └── static/ # CSS / JS / 默认主题(内嵌)
-├── docs/
-│ ├── THEME.md # 主题开发文档
-│ └── PLUGIN.md # 插件开发文档
-└── cloud/ # 云端插件社区(独立 Go 模块,SQLite)
- ├── main.go
- ├── internal/ # 配置 / 数据库 / 模型 / 中间件 / 处理器
- ├── web/ # 官网、插件市场、开发者后台、管理后台
- └── deploy/ # systemd 单元与 Nginx 示例
+└── docs/
+ ├── THEME.md # 主题开发文档
+ └── PLUGIN.md # 插件开发文档
```
运行时生成(**不在版本库中,升级时请勿删除**):
@@ -426,7 +421,6 @@ docker run -d --name clearlove --restart=always \
- [主题开发](docs/THEME.md) —— 目录结构与 CSS 变量
- [插件开发](docs/PLUGIN.md) —— 钩子规范与打包要求
-- 云端服务部署:见 [`cloud/deploy/`](cloud/deploy/) 中的 systemd 单元与 Nginx 示例
---
diff --git a/cloud/deploy/clearlove-cloud.service b/cloud/deploy/clearlove-cloud.service
deleted file mode 100644
index f964233..0000000
--- a/cloud/deploy/clearlove-cloud.service
+++ /dev/null
@@ -1,25 +0,0 @@
-# ClearLove 插件社区 · 云端服务 systemd 单元
-# 部署:
-# 1) mkdir -p /opt/clearlove-cloud && 上传 clearlove-cloud 二进制到该目录
-# 2) cp clearlove-cloud.service /etc/systemd/system/
-# 3) systemctl daemon-reload && systemctl enable --now clearlove-cloud
-[Unit]
-Description=ClearLove Plugin Community (Cloud Service)
-After=network.target
-
-[Service]
-Type=simple
-WorkingDirectory=/opt/clearlove-cloud
-ExecStart=/opt/clearlove-cloud/clearlove-cloud
-Restart=always
-RestartSec=3
-
-# 监听端口与对外地址(Nginx 反代时保持本机监听即可)
-Environment=CLOUD_PORT=16869
-Environment=CLOUD_BASE_URL=https://clearlove.kazx.top
-# 数据与插件包目录(默认位于 WorkingDirectory 下的 data/ 与 uploads/)
-Environment=CLOUD_DATA_DIR=/opt/clearlove-cloud/data
-Environment=CLOUD_UPLOAD_DIR=/opt/clearlove-cloud/uploads
-
-[Install]
-WantedBy=multi-user.target
diff --git a/cloud/deploy/nginx.conf b/cloud/deploy/nginx.conf
deleted file mode 100644
index f0f2770..0000000
--- a/cloud/deploy/nginx.conf
+++ /dev/null
@@ -1,40 +0,0 @@
-# ClearLove 插件社区 · Nginx 反向代理示例(域名:clearlove.kazx.top)
-# 证书可使用 certbot 申请:certbot --nginx -d clearlove.kazx.top
-
-server {
- listen 80;
- server_name clearlove.kazx.top;
- return 301 https://$host$request_uri;
-}
-
-server {
- listen 443 ssl;
- http2 on;
- server_name clearlove.kazx.top;
-
- ssl_certificate /etc/letsencrypt/live/clearlove.kazx.top/fullchain.pem;
- ssl_certificate_key /etc/letsencrypt/live/clearlove.kazx.top/privkey.pem;
- ssl_protocols TLSv1.2 TLSv1.3;
-
- # 插件包上传/下载上限(云端限制单包 50MB)
- client_max_body_size 60m;
-
- location / {
- proxy_pass http://127.0.0.1:16869;
- proxy_http_version 1.1;
- proxy_set_header Host $host;
- proxy_set_header X-Real-IP $remote_addr;
- proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
- proxy_set_header X-Forwarded-Proto $scheme;
- # 插件包上传/下载可能较慢
- proxy_read_timeout 300s;
- proxy_send_timeout 300s;
- }
-
- # 静态资源可选开缓存(服务内已内嵌,交由 Go 处理亦可)
- location /static/ {
- proxy_pass http://127.0.0.1:16869;
- proxy_set_header Host $host;
- expires 7d;
- }
-}
diff --git a/cloud/go.mod b/cloud/go.mod
deleted file mode 100644
index 2a58002..0000000
--- a/cloud/go.mod
+++ /dev/null
@@ -1,21 +0,0 @@
-module clearlove-cloud
-
-go 1.27.0
-
-require (
- golang.org/x/crypto v0.41.0
- modernc.org/sqlite v1.38.0
-)
-
-require (
- github.com/dustin/go-humanize v1.0.1 // indirect
- github.com/google/uuid v1.6.0 // indirect
- github.com/mattn/go-isatty v0.0.20 // indirect
- github.com/ncruces/go-strftime v0.1.9 // indirect
- github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
- golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 // indirect
- golang.org/x/sys v0.35.0 // indirect
- modernc.org/libc v1.65.10 // indirect
- modernc.org/mathutil v1.7.1 // indirect
- modernc.org/memory v1.11.0 // indirect
-)
diff --git a/cloud/go.sum b/cloud/go.sum
deleted file mode 100644
index b6d7422..0000000
--- a/cloud/go.sum
+++ /dev/null
@@ -1,49 +0,0 @@
-github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
-github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
-github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
-github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
-github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
-github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
-github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
-github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
-github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
-github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
-github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
-github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
-golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4=
-golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc=
-golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 h1:R84qjqJb5nVJMxqWYb3np9L5ZsaDtB+a39EqjV0JSUM=
-golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0/go.mod h1:S9Xr4PYopiDyqSyp5NjCrhFrqg6A5zA2E/iPHPhqnS8=
-golang.org/x/mod v0.24.0 h1:ZfthKaKaT4NrhGVZHO1/WDTwGES4De8KtWO0SIbNJMU=
-golang.org/x/mod v0.24.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
-golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ=
-golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
-golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI=
-golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
-golang.org/x/tools v0.33.0 h1:4qz2S3zmRxbGIhDIAgjxvFutSvH5EfnsYrRBj0UI0bc=
-golang.org/x/tools v0.33.0/go.mod h1:CIJMaWEY88juyUfo7UbgPqbC8rU2OqfAV1h2Qp0oMYI=
-modernc.org/cc/v4 v4.26.1 h1:+X5NtzVBn0KgsBCBe+xkDC7twLb/jNVj9FPgiwSQO3s=
-modernc.org/cc/v4 v4.26.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
-modernc.org/ccgo/v4 v4.28.0 h1:rjznn6WWehKq7dG4JtLRKxb52Ecv8OUGah8+Z/SfpNU=
-modernc.org/ccgo/v4 v4.28.0/go.mod h1:JygV3+9AV6SmPhDasu4JgquwU81XAKLd3OKTUDNOiKE=
-modernc.org/fileutil v1.3.3 h1:3qaU+7f7xxTUmvU1pJTZiDLAIoJVdUSSauJNHg9yXoA=
-modernc.org/fileutil v1.3.3/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
-modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
-modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
-modernc.org/libc v1.65.10 h1:ZwEk8+jhW7qBjHIT+wd0d9VjitRyQef9BnzlzGwMODc=
-modernc.org/libc v1.65.10/go.mod h1:StFvYpx7i/mXtBAfVOjaU0PWZOvIRoZSgXhrwXzr8Po=
-modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
-modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
-modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
-modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
-modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
-modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
-modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
-modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
-modernc.org/sqlite v1.38.0 h1:+4OrfPQ8pxHKuWG4md1JpR/EYAh3Md7TdejuuzE7EUI=
-modernc.org/sqlite v1.38.0/go.mod h1:1Bj+yES4SVvBZ4cBOpVZ6QgesMCKpJZDq0nxYzOpmNE=
-modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
-modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
-modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
-modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
diff --git a/cloud/internal/config/config.go b/cloud/internal/config/config.go
deleted file mode 100644
index 7a7e3b0..0000000
--- a/cloud/internal/config/config.go
+++ /dev/null
@@ -1,90 +0,0 @@
-// Package config 云端服务全局配置。
-// 首次启动自动生成 data/config.json(含会话签名密钥),无需手工配置。
-package config
-
-import (
- "crypto/rand"
- "encoding/hex"
- "encoding/json"
- "os"
- "path/filepath"
-)
-
-// Version 云端服务版本号
-const Version = "1.0.0"
-
-// DefaultBaseURL 官方域名(用于生成绝对地址与展示)
-const DefaultBaseURL = "https://clearlove.kazx.top"
-
-// Config 持久化到 data/config.json
-type Config struct {
- DataDir string `json:"data_dir"` // 数据目录
- UploadDir string `json:"upload_dir"` // 插件包目录
- Secret string `json:"secret"` // 会话 HMAC 签名密钥
- BaseURL string `json:"base_url"` // 对外访问地址
-}
-
-// Cfg 全局配置单例
-var Cfg Config
-
-func (c *Config) dir() string {
- if c.DataDir == "" {
- return "data"
- }
- return c.DataDir
-}
-
-// File 配置文件路径
-func (c *Config) File() string { return filepath.Join(c.dir(), "config.json") }
-
-// Load 加载配置;目录可用环境变量覆盖:
-//
-// CLOUD_DATA_DIR / CLOUD_UPLOAD_DIR / CLOUD_BASE_URL
-func Load() {
- Cfg = Config{DataDir: "data", UploadDir: "uploads", BaseURL: DefaultBaseURL}
- applyEnv()
- if b, err := os.ReadFile(Cfg.File()); err == nil {
- _ = json.Unmarshal(b, &Cfg)
- }
- if Cfg.DataDir == "" {
- Cfg.DataDir = "data"
- }
- if Cfg.UploadDir == "" {
- Cfg.UploadDir = "uploads"
- }
- if Cfg.BaseURL == "" {
- Cfg.BaseURL = DefaultBaseURL
- }
- applyEnv()
- // 首次运行生成签名密钥并落盘
- if Cfg.Secret == "" {
- b := make([]byte, 32)
- _, _ = rand.Read(b)
- Cfg.Secret = hex.EncodeToString(b)
- _ = Save()
- }
-}
-
-func applyEnv() {
- if v := os.Getenv("CLOUD_DATA_DIR"); v != "" {
- Cfg.DataDir = v
- }
- if v := os.Getenv("CLOUD_UPLOAD_DIR"); v != "" {
- Cfg.UploadDir = v
- }
- if v := os.Getenv("CLOUD_BASE_URL"); v != "" {
- Cfg.BaseURL = v
- }
-}
-
-// Save 写回配置文件
-func Save() error {
- if err := os.MkdirAll(Cfg.dir(), 0o755); err != nil {
- return err
- }
- b, err := json.MarshalIndent(&Cfg, "", " ")
- if err != nil {
- return err
- }
- return os.WriteFile(Cfg.File(), b, 0o600)
-}
diff --git a/cloud/internal/database/database.go b/cloud/internal/database/database.go
deleted file mode 100644
index a3fc444..0000000
--- a/cloud/internal/database/database.go
+++ /dev/null
@@ -1,190 +0,0 @@
-// Package database 云端数据库(SQLite / modernc.org/sqlite 纯 Go 无 CGO)。
-// 时间统一以 RFC3339(UTC)字符串存储,便于排序与展示。
-package database
-
-import (
- "database/sql"
- "fmt"
- "os"
- "path/filepath"
- "strings"
- "time"
-
- _ "modernc.org/sqlite"
-
- "clearlove-cloud/internal/config"
- "clearlove-cloud/internal/util"
-)
-
-// DB 全局数据库句柄
-var DB *sql.DB
-
-// schemaDDL 表结构(幂等,可重复执行)
-const schemaDDL = `
-CREATE TABLE IF NOT EXISTS settings (
- k TEXT PRIMARY KEY,
- v TEXT
-);
-CREATE TABLE IF NOT EXISTS admins (
- id INTEGER PRIMARY KEY AUTOINCREMENT,
- username TEXT NOT NULL UNIQUE,
- password TEXT NOT NULL,
- created_at TEXT
-);
-CREATE TABLE IF NOT EXISTS developers (
- id INTEGER PRIMARY KEY AUTOINCREMENT,
- username TEXT NOT NULL UNIQUE,
- email TEXT NOT NULL UNIQUE,
- password TEXT NOT NULL,
- bio TEXT DEFAULT '',
- homepage TEXT DEFAULT '',
- status INTEGER DEFAULT 1,
- created_at TEXT
-);
-CREATE TABLE IF NOT EXISTS email_codes (
- id INTEGER PRIMARY KEY AUTOINCREMENT,
- email TEXT NOT NULL,
- code TEXT NOT NULL,
- purpose TEXT DEFAULT 'register',
- expires TEXT,
- used INTEGER DEFAULT 0,
- created_at TEXT
-);
-CREATE TABLE IF NOT EXISTS plugins (
- id INTEGER PRIMARY KEY AUTOINCREMENT,
- dev_id INTEGER DEFAULT 0,
- slug TEXT NOT NULL UNIQUE,
- name TEXT DEFAULT '',
- description TEXT DEFAULT '',
- readme TEXT DEFAULT '',
- category TEXT DEFAULT 'other',
- tags TEXT DEFAULT '',
- author TEXT DEFAULT '',
- homepage TEXT DEFAULT '',
- license TEXT DEFAULT '',
- status INTEGER DEFAULT 0,
- downloads INTEGER DEFAULT 0,
- installs INTEGER DEFAULT 0,
- created_at TEXT,
- updated_at TEXT
-);
-CREATE TABLE IF NOT EXISTS plugin_versions (
- id INTEGER PRIMARY KEY AUTOINCREMENT,
- plugin_id INTEGER DEFAULT 0,
- version TEXT DEFAULT '',
- changelog TEXT DEFAULT '',
- file_path TEXT DEFAULT '',
- file_size INTEGER DEFAULT 0,
- sha256 TEXT DEFAULT '',
- downloads INTEGER DEFAULT 0,
- created_at TEXT
-);
-CREATE TABLE IF NOT EXISTS visits (
- id INTEGER PRIMARY KEY AUTOINCREMENT,
- day TEXT DEFAULT '',
- path TEXT DEFAULT '',
- ip TEXT DEFAULT '',
- created_at TEXT
-);
-CREATE TABLE IF NOT EXISTS download_logs (
- id INTEGER PRIMARY KEY AUTOINCREMENT,
- plugin_id INTEGER DEFAULT 0,
- version TEXT DEFAULT '',
- ip TEXT DEFAULT '',
- created_at TEXT
-);
-`
-
-// Connect 打开 SQLite 连接(WAL + busy_timeout,兼顾并发读与写入安全)
-func Connect() error {
- path := filepath.Join(config.Cfg.DataDir, "cloud.db")
- if dir := filepath.Dir(path); dir != "" && dir != "." {
- if err := os.MkdirAll(dir, 0o755); err != nil {
- return fmt.Errorf("创建数据目录失败: %w", err)
- }
- }
- dsn := path + "?_pragma=journal_mode(WAL)&_pragma=busy_timeout(5000)&_pragma=foreign_keys(1)"
- db, err := sql.Open("sqlite", dsn)
- if err != nil {
- return err
- }
- db.SetMaxOpenConns(8)
- if err := db.Ping(); err != nil {
- return err
- }
- DB = db
- return nil
-}
-
-// Migrate 建表 + 建索引 + 初始化管理员与默认设置
-func Migrate() error {
- for _, stmt := range strings.Split(schemaDDL, ";") {
- stmt = strings.TrimSpace(stmt)
- if stmt == "" {
- continue
- }
- if _, err := DB.Exec(stmt); err != nil {
- return fmt.Errorf("迁移失败: %v SQL: %.120s", err, stmt)
- }
- }
- indexes := []string{
- "CREATE INDEX IF NOT EXISTS idx_plugins_status ON plugins (status)",
- "CREATE INDEX IF NOT EXISTS idx_versions_plugin ON plugin_versions (plugin_id)",
- "CREATE INDEX IF NOT EXISTS idx_visits_day ON visits (day)",
- "CREATE INDEX IF NOT EXISTS idx_codes_email ON email_codes (email)",
- "CREATE INDEX IF NOT EXISTS idx_downloads_plugin ON download_logs (plugin_id)",
- }
- for _, ix := range indexes {
- _, _ = DB.Exec(ix)
- }
- if err := seedAdmin(); err != nil {
- return err
- }
- if err := seedSettings(); err != nil {
- return err
- }
- util.Log("info", "云端数据库迁移完成")
- return nil
-}
-
-// seedAdmin 无管理员时创建初始管理员(fqh / 20081018)
-func seedAdmin() error {
- var n int64
- if err := DB.QueryRow("SELECT COUNT(1) FROM admins").Scan(&n); err != nil {
- return err
- }
- if n > 0 {
- return nil
- }
- _, err := DB.Exec("INSERT INTO admins(username,password,created_at) VALUES(?,?,?)",
- "fqh", util.HashPassword("20081018"), now())
- util.Log("info", "已初始化管理员账号 fqh")
- return err
-}
-
-// seedSettings 写入默认站点设置(已存在则不覆盖)
-func seedSettings() error {
- defaults := map[string]string{
- "site_name": "ClearLove 插件社区",
- "slogan": "Clearlove2.0 全新上线,用 Go 构建你的专属表白墙",
- "site_desc": "基于 Go 的单二进制表白墙系统,支持插件扩展、双数据库与 AI 内容审核。",
- "download_url": "https://github.com/clearlove/clearlove/releases",
- "footer_text": "ClearLove 表白墙 · 用 Go 构建你的专属表白墙",
- "icp": "",
- "smtp_host": "",
- "smtp_port": "465",
- "smtp_user": "",
- "smtp_pass": "",
- "smtp_from": "",
- "allow_register": "1",
- }
- for k, v := range defaults {
- if _, err := DB.Exec("INSERT OR IGNORE INTO settings(k,v) VALUES(?,?)", k, v); err != nil {
- return err
- }
- }
- return nil
-}
-
-// now 迁移期使用的时间函数(避免引入 models 造成循环依赖)
-func now() string { return time.Now().UTC().Format(time.RFC3339) }
diff --git a/cloud/internal/handlers/admin.go b/cloud/internal/handlers/admin.go
deleted file mode 100644
index 187e8d2..0000000
--- a/cloud/internal/handlers/admin.go
+++ /dev/null
@@ -1,282 +0,0 @@
-// 云端管理后台:登录、活跃统计、插件审核、开发者管理、站点设置。
-package handlers
-
-import (
- "net/http"
- "strings"
- "time"
-
- "clearlove-cloud/internal/database"
- "clearlove-cloud/internal/models"
- "clearlove-cloud/internal/util"
-)
-
-// ---------- 登录 ----------
-
-// AdminLoginPage 后台登录页
-func AdminLoginPage(w http.ResponseWriter, r *http.Request) {
- if CurrentAdmin(r) != nil {
- http.Redirect(w, r, "/admin", http.StatusFound)
- return
- }
- Render(w, r, "admin_layout.html", "pg_admin_login", map[string]any{"err": r.URL.Query().Get("err")})
-}
-
-// AdminLoginSubmit 后台登录(12 小时会话)
-func AdminLoginSubmit(w http.ResponseWriter, r *http.Request) {
- username := strings.TrimSpace(r.FormValue("username"))
- password := r.FormValue("password")
- var id int64
- var hash string
- err := database.DB.QueryRow("SELECT id,password FROM admins WHERE username=?", username).Scan(&id, &hash)
- if err != nil || !util.CheckPassword(hash, password) {
- Render(w, r, "admin_layout.html", "pg_admin_login", map[string]any{"err": "用户名或密码错误"})
- return
- }
- http.SetCookie(w, &http.Cookie{
- Name: "clv_admin", Value: util.SignSession(id, "admin", 12*time.Hour), Path: "/",
- MaxAge: 12 * 3600, HttpOnly: true, SameSite: http.SameSiteLaxMode,
- })
- http.Redirect(w, r, "/admin", http.StatusSeeOther)
-}
-
-// AdminLogout 后台退出
-func AdminLogout(w http.ResponseWriter, r *http.Request) {
- http.SetCookie(w, &http.Cookie{Name: "clv_admin", Value: "", Path: "/", MaxAge: -1})
- http.Redirect(w, r, "/admin/login", http.StatusSeeOther)
-}
-
-// ---------- 仪表盘 ----------
-
-// DayStat 单日活跃
-type DayStat struct {
- Day string `json:"day"`
- PV int64 `json:"pv"`
- UV int64 `json:"uv"`
-}
-
-// trend7 近 7 天 PV/UV(缺失日期补零)
-func trend7() []DayStat {
- out := make([]DayStat, 0, 7)
- m := map[string]DayStat{}
- start := time.Now().UTC().AddDate(0, 0, -6).Format("2006-01-02")
- rows, err := database.DB.Query(
- "SELECT day, COUNT(1), COUNT(DISTINCT ip) FROM visits WHERE day>=? GROUP BY day", start)
- if err == nil {
- for rows.Next() {
- var d DayStat
- if rows.Scan(&d.Day, &d.PV, &d.UV) == nil {
- m[d.Day] = d
- }
- }
- rows.Close()
- }
- for i := 6; i >= 0; i-- {
- day := time.Now().UTC().AddDate(0, 0, -i).Format("2006-01-02")
- if d, ok := m[day]; ok {
- out = append(out, d)
- } else {
- out = append(out, DayStat{Day: day})
- }
- }
- return out
-}
-
-// AdminDash 仪表盘:社区活跃人数与平台概览
-func AdminDash(w http.ResponseWriter, r *http.Request) {
- a := requireAdmin(w, r)
- if a == nil {
- return
- }
- today := time.Now().UTC().Format("2006-01-02")
- weekAgo := time.Now().UTC().AddDate(0, 0, -7).Format("2006-01-02")
- trend := trend7()
- // 峰值用于柱状图高度
- var maxPV int64 = 1
- for _, d := range trend {
- if d.PV > maxPV {
- maxPV = d.PV
- }
- }
- Render(w, r, "admin_layout.html", "pg_admin_dash", map[string]any{
- "stats": siteStats(),
- "pvToday": models.QueryInt("SELECT COUNT(1) FROM visits WHERE day=?", today),
- "uvToday": models.QueryInt("SELECT COUNT(DISTINCT ip) FROM visits WHERE day=?", today),
- "uvWeek": models.QueryInt("SELECT COUNT(DISTINCT ip) FROM visits WHERE day>=?", weekAgo),
- "pvTotal": models.QueryInt("SELECT COUNT(1) FROM visits"),
- "pending": models.QueryInt("SELECT COUNT(1) FROM plugins WHERE status=0"),
- "devTotal": models.QueryInt("SELECT COUNT(1) FROM developers"),
- "trend": trend,
- "maxPV": maxPV,
- "devActive": models.QueryInt("SELECT COUNT(DISTINCT dev_id) FROM plugins WHERE updated_at>=?", weekAgo),
- })
-}
-
-// ---------- 插件审核 ----------
-
-// AdminPlugins 插件审核列表
-func AdminPlugins(w http.ResponseWriter, r *http.Request) {
- a := requireAdmin(w, r)
- if a == nil {
- return
- }
- status := r.URL.Query().Get("status")
- where := ""
- args := []any{}
- switch status {
- case "0", "1", "2", "3":
- where = " WHERE p.status=?"
- args = append(args, status)
- }
- rows, err := database.DB.Query(pluginSelect+where+" ORDER BY p.status ASC, p.id DESC LIMIT 300", args...)
- if err != nil {
- http.Error(w, "查询失败", http.StatusInternalServerError)
- return
- }
- defer rows.Close()
- Render(w, r, "admin_layout.html", "pg_admin_plugins", map[string]any{
- "plugins": scanPlugins(rows),
- "status": status,
- })
-}
-
-// AdminPluginStatus 审核动作:上架 / 下架 / 驳回 / 删除
-func AdminPluginStatus(w http.ResponseWriter, r *http.Request) {
- a := requireAdmin(w, r)
- if a == nil {
- return
- }
- id := formInt64(r, "id")
- switch r.FormValue("op") {
- case "approve":
- _, _ = database.DB.Exec("UPDATE plugins SET status=1, updated_at=? WHERE id=?", models.Now(), id)
- case "offline":
- _, _ = database.DB.Exec("UPDATE plugins SET status=2, updated_at=? WHERE id=?", models.Now(), id)
- case "reject":
- _, _ = database.DB.Exec("UPDATE plugins SET status=3, updated_at=? WHERE id=?", models.Now(), id)
- case "delete":
- var slug string
- _ = database.DB.QueryRow("SELECT slug FROM plugins WHERE id=?", id).Scan(&slug)
- _, _ = database.DB.Exec("DELETE FROM plugin_versions WHERE plugin_id=?", id)
- _, _ = database.DB.Exec("DELETE FROM download_logs WHERE plugin_id=?", id)
- _, _ = database.DB.Exec("DELETE FROM plugins WHERE id=?", id)
- }
- http.Redirect(w, r, "/admin/plugins", http.StatusSeeOther)
-}
-
-// ---------- 开发者管理 ----------
-
-// AdminDevelopers 开发者列表
-func AdminDevelopers(w http.ResponseWriter, r *http.Request) {
- a := requireAdmin(w, r)
- if a == nil {
- return
- }
- rows, err := database.DB.Query(`
- SELECT d.id, d.username, d.email, IFNULL(d.bio,''), d.status, IFNULL(d.created_at,''),
- (SELECT COUNT(1) FROM plugins p WHERE p.dev_id=d.id),
- (SELECT IFNULL(SUM(p.downloads),0) FROM plugins p WHERE p.dev_id=d.id)
- FROM developers d ORDER BY d.id DESC LIMIT 500`)
- if err != nil {
- http.Error(w, "查询失败", http.StatusInternalServerError)
- return
- }
- defer rows.Close()
- type row struct {
- ID int64
- Username string
- Email string
- Bio string
- Status int64
- CreatedAt string
- Plugins int64
- Downloads int64
- }
- var list []row
- for rows.Next() {
- var it row
- if rows.Scan(&it.ID, &it.Username, &it.Email, &it.Bio, &it.Status, &it.CreatedAt,
- &it.Plugins, &it.Downloads) == nil {
- list = append(list, it)
- }
- }
- Render(w, r, "admin_layout.html", "pg_admin_developers", map[string]any{"devs": list})
-}
-
-// AdminDeveloperStatus 启用 / 禁用开发者
-func AdminDeveloperStatus(w http.ResponseWriter, r *http.Request) {
- a := requireAdmin(w, r)
- if a == nil {
- return
- }
- id := formInt64(r, "id")
- status := int64(1)
- if r.FormValue("status") == "0" {
- status = 0
- }
- _, _ = database.DB.Exec("UPDATE developers SET status=? WHERE id=?", status, id)
- http.Redirect(w, r, "/admin/developers", http.StatusSeeOther)
-}
-
-// ---------- 站点设置 ----------
-
-var settingKeys = []string{
- "site_name", "slogan", "site_desc", "download_url", "footer_text", "icp",
- "smtp_host", "smtp_port", "smtp_user", "smtp_pass", "smtp_from", "allow_register",
-}
-
-// AdminSettings 站点设置页
-func AdminSettings(w http.ResponseWriter, r *http.Request) {
- a := requireAdmin(w, r)
- if a == nil {
- return
- }
- s := map[string]string{}
- for _, k := range settingKeys {
- s[k] = models.GetSetting(k)
- }
- Render(w, r, "admin_layout.html", "pg_admin_settings", map[string]any{"s": s})
-}
-
-// AdminSettingsSave 保存设置
-func AdminSettingsSave(w http.ResponseWriter, r *http.Request) {
- a := requireAdmin(w, r)
- if a == nil {
- return
- }
- for _, k := range settingKeys {
- if k == "allow_register" {
- continue
- }
- if v, ok := r.Form[k]; ok {
- _ = models.SetSetting(k, strings.TrimSpace(v[0]))
- }
- }
- v := "0"
- if r.FormValue("allow_register") == "1" {
- v = "1"
- }
- _ = models.SetSetting("allow_register", v)
- http.Redirect(w, r, "/admin/settings", http.StatusSeeOther)
-}
-
-// AdminPassword 修改管理员密码
-func AdminPassword(w http.ResponseWriter, r *http.Request) {
- a := requireAdmin(w, r)
- if a == nil {
- return
- }
- old := r.FormValue("old_password")
- newPass := r.FormValue("new_password")
- if !util.CheckPassword(a.Password, old) {
- http.Redirect(w, r, "/admin/settings?err="+urlQueryEscape("原密码错误"), http.StatusSeeOther)
- return
- }
- if len(newPass) < 6 {
- http.Redirect(w, r, "/admin/settings?err="+urlQueryEscape("新密码至少 6 位"), http.StatusSeeOther)
- return
- }
- _, _ = database.DB.Exec("UPDATE admins SET password=? WHERE id=?", util.HashPassword(newPass), a.ID)
- util.Log("info", "管理员 %s 修改了密码", a.Username)
- http.Redirect(w, r, "/admin/settings?ok=1", http.StatusSeeOther)
-}
diff --git a/cloud/internal/handlers/dev.go b/cloud/internal/handlers/dev.go
deleted file mode 100644
index 194ba19..0000000
--- a/cloud/internal/handlers/dev.go
+++ /dev/null
@@ -1,594 +0,0 @@
-// 开发者模块:邮箱验证注册、登录、开发者后台(发布/维护自己的插件)。
-package handlers
-
-import (
- "archive/zip"
- "bytes"
- "encoding/json"
- "io"
- "net/http"
- "os"
- "path/filepath"
- "strings"
- "time"
-
- "clearlove-cloud/internal/config"
- "clearlove-cloud/internal/database"
- "clearlove-cloud/internal/mailer"
- "clearlove-cloud/internal/models"
- "clearlove-cloud/internal/util"
-)
-
-// pluginManifest 插件包内 plugin.json 的结构(与表白墙插件格式一致)
-type pluginManifest struct {
- Name string `json:"name"`
- Version string `json:"version"`
- Author string `json:"author"`
- Description string `json:"description"`
- Hooks []any `json:"hooks"`
-}
-
-// parseZipManifest 从 zip 中解析 plugin.json
-func parseZipManifest(data []byte) (*pluginManifest, error) {
- zr, err := zip.NewReader(bytes.NewReader(data), int64(len(data)))
- if err != nil {
- return nil, errInvalidZip
- }
- for _, f := range zr.File {
- if filepath.Base(f.Name) != "plugin.json" {
- continue
- }
- rc, err := f.Open()
- if err != nil {
- return nil, err
- }
- b, _ := io.ReadAll(io.LimitReader(rc, 1<<20))
- rc.Close()
- var m pluginManifest
- if err := json.Unmarshal(b, &m); err != nil {
- return nil, errBadManifest
- }
- if strings.TrimSpace(m.Name) == "" {
- return nil, errBadManifest
- }
- return &m, nil
- }
- return nil, errNoManifest
-}
-
-var (
- errInvalidZip = errStr("无效的 zip 包")
- errNoManifest = errStr("压缩包中未找到 plugin.json")
- errBadManifest = errStr("plugin.json 解析失败(需包含 name 字段)")
-)
-
-type errStr string
-
-func (e errStr) Error() string { return string(e) }
-
-// savePackage 把插件包写入 data/packages/<slug>/<version>.zip
-func savePackage(slug, version string, data []byte) (string, int64, string, error) {
- dir := filepath.Join(config.Cfg.UploadDir, "packages", slug)
- if err := os.MkdirAll(dir, 0o755); err != nil {
- return "", 0, "", err
- }
- p := filepath.Join(dir, version+".zip")
- if err := os.WriteFile(p, data, 0o644); err != nil {
- return "", 0, "", err
- }
- return p, int64(len(data)), util.SHA256Hex(data), nil
-}
-
-// uniqueSlug 生成唯一插件标识
-func uniqueSlug(name string) string {
- base := util.Slug(name)
- slug := base
- for i := 2; ; i++ {
- if models.QueryInt("SELECT COUNT(1) FROM plugins WHERE slug=?", slug) == 0 {
- return slug
- }
- slug = base + "-" + itoa(i)
- }
-}
-
-func itoa(n int) string {
- if n == 0 {
- return "0"
- }
- var b [12]byte
- i := len(b)
- for n > 0 {
- i--
- b[i] = byte('0' + n%10)
- n /= 10
- }
- return string(b[i:])
-}
-
-// codeValid 验证码 10 分钟有效
-func codeValid(expRFC string) bool {
- t, err := time.Parse(time.RFC3339, expRFC)
- return err == nil && time.Now().UTC().Before(t)
-}
-
-// urlQueryEscape 简单查询串转义
-func urlQueryEscape(s string) string {
- return strings.ReplaceAll(strings.ReplaceAll(s, "&", "%26"), "+", "%2B")
-}
-
-// ---------- 注册 / 登录 ----------
-
-// DevRegisterPage 开发者注册页
-func DevRegisterPage(w http.ResponseWriter, r *http.Request) {
- if CurrentDeveloper(r) != nil {
- http.Redirect(w, r, "/dev", http.StatusFound)
- return
- }
- Render(w, r, "layout.html", "pg_dev_register", map[string]any{
- "err": r.URL.Query().Get("err"),
- "smtp": models.GetSetting("smtp_host") != "",
- "allow": models.GetSetting("allow_register") == "1",
- })
-}
-
-// DevSendCode 发送邮箱验证码(POST /api/dev/send-code)
-func DevSendCode(w http.ResponseWriter, r *http.Request) {
- host := models.GetSetting("smtp_host")
- if host == "" {
- fail(w, 400, "站点暂未配置邮件服务,请联系管理员")
- return
- }
- email := strings.TrimSpace(r.FormValue("email"))
- if !util.ValidEmail(email) {
- fail(w, 400, "邮箱格式不正确")
- return
- }
- if models.QueryInt("SELECT COUNT(1) FROM developers WHERE email=?", email) > 0 {
- fail(w, 400, "该邮箱已注册")
- return
- }
- // 60 秒内不重复发送
- var last string
- _ = database.DB.QueryRow("SELECT created_at FROM email_codes WHERE email=? ORDER BY id DESC LIMIT 1", email).Scan(&last)
- if last != "" {
- if t, err := time.Parse(time.RFC3339, last); err == nil && time.Since(t) < 60*time.Second {
- fail(w, 429, "发送过于频繁,请稍后再试")
- return
- }
- }
- code := util.RandomCode(6)
- _, err := database.DB.Exec(
- "INSERT INTO email_codes(email,code,purpose,expires,created_at) VALUES(?,?,?,?,?)",
- email, code, "register", time.Now().UTC().Add(10*time.Minute).Format(time.RFC3339), models.Now())
- if err != nil {
- fail(w, 500, "验证码生成失败")
- return
- }
- if err := mailer.SendCode(host, models.GetSetting("smtp_port"), models.GetSetting("smtp_user"),
- models.GetSetting("smtp_pass"), models.GetSetting("smtp_from"), email, code); err != nil {
- util.Log("error", "验证码邮件发送失败: %v", err)
- fail(w, 500, "验证码发送失败,请稍后重试")
- return
- }
- okJSON(w, map[string]any{"msg": "验证码已发送,请查收邮箱"})
-}
-
-// DevRegisterSubmit 注册提交(校验邮箱验证码)
-func DevRegisterSubmit(w http.ResponseWriter, r *http.Request) {
- if models.GetSetting("allow_register") != "1" {
- http.Error(w, "站点已关闭开发者注册", http.StatusForbidden)
- return
- }
- username := util.StripHTML(r.FormValue("username"))
- email := strings.TrimSpace(r.FormValue("email"))
- password := r.FormValue("password")
- code := strings.TrimSpace(r.FormValue("code"))
- q := func(msg string) {
- http.Redirect(w, r, "/dev/register?err="+urlQueryEscape(msg), http.StatusSeeOther)
- }
- uLen := len([]rune(username))
- if uLen < 3 || uLen > 20 {
- q("用户名需为 3-20 个字符")
- return
- }
- if !util.ValidEmail(email) {
- q("邮箱格式不正确")
- return
- }
- if len(password) < 6 {
- q("密码至少 6 位")
- return
- }
- if models.QueryInt("SELECT COUNT(1) FROM developers WHERE username=?", username) > 0 {
- q("用户名已被占用")
- return
- }
- if models.QueryInt("SELECT COUNT(1) FROM developers WHERE email=?", email) > 0 {
- q("该邮箱已注册")
- return
- }
- var dbCode, exp string
- err := database.DB.QueryRow(
- "SELECT code,expires FROM email_codes WHERE email=? AND purpose='register' AND used=0 ORDER BY id DESC LIMIT 1",
- email).Scan(&dbCode, &exp)
- if err != nil || dbCode != code || !codeValid(exp) {
- q("邮箱验证码错误或已过期")
- return
- }
- res, err := database.DB.Exec(
- "INSERT INTO developers(username,email,password,status,created_at) VALUES(?,?,?,1,?)",
- username, email, util.HashPassword(password), models.Now())
- if err != nil {
- q("注册失败,请稍后重试")
- return
- }
- _, _ = database.DB.Exec("UPDATE email_codes SET used=1 WHERE email=?", email)
- uid, _ := res.LastInsertId()
- http.SetCookie(w, &http.Cookie{
- Name: "clv_dev", Value: util.SignSession(uid, "dev", 30*24*time.Hour), Path: "/",
- MaxAge: 30 * 86400, HttpOnly: true, SameSite: http.SameSiteLaxMode,
- })
- util.Log("info", "新开发者注册: %s (%s)", username, email)
- http.Redirect(w, r, "/dev", http.StatusSeeOther)
-}
-
-// DevLoginPage 开发者登录页
-func DevLoginPage(w http.ResponseWriter, r *http.Request) {
- if CurrentDeveloper(r) != nil {
- http.Redirect(w, r, "/dev", http.StatusFound)
- return
- }
- Render(w, r, "layout.html", "pg_dev_login", map[string]any{"err": r.URL.Query().Get("err")})
-}
-
-// DevLoginSubmit 登录(账号可为用户名或邮箱)
-func DevLoginSubmit(w http.ResponseWriter, r *http.Request) {
- account := strings.TrimSpace(r.FormValue("account"))
- password := r.FormValue("password")
- var id int64
- var hash string
- var status int
- err := database.DB.QueryRow(
- "SELECT id,password,status FROM developers WHERE username=? OR email=? LIMIT 1", account, account).
- Scan(&id, &hash, &status)
- if err != nil || !util.CheckPassword(hash, password) {
- http.Redirect(w, r, "/dev/login?err="+urlQueryEscape("账号或密码错误"), http.StatusSeeOther)
- return
- }
- if status != 1 {
- http.Redirect(w, r, "/dev/login?err="+urlQueryEscape("账号已被禁用,请联系管理员"), http.StatusSeeOther)
- return
- }
- http.SetCookie(w, &http.Cookie{
- Name: "clv_dev", Value: util.SignSession(id, "dev", 30*24*time.Hour), Path: "/",
- MaxAge: 30 * 86400, HttpOnly: true, SameSite: http.SameSiteLaxMode,
- })
- http.Redirect(w, r, "/dev", http.StatusSeeOther)
-}
-
-// DevLogout 退出登录
-func DevLogout(w http.ResponseWriter, r *http.Request) {
- http.SetCookie(w, &http.Cookie{Name: "clv_dev", Value: "", Path: "/", MaxAge: -1})
- http.Redirect(w, r, "/", http.StatusSeeOther)
-}
-
-// ---------- 开发者后台 ----------
-
-// DevDash 开发者仪表盘
-func DevDash(w http.ResponseWriter, r *http.Request) {
- d := requireDev(w, r)
- if d == nil {
- return
- }
- type row struct {
- ID int64
- Slug string
- Name string
- Status int
- Downloads int64
- Installs int64
- UpdatedAt string
- }
- rows, err := database.DB.Query(`SELECT id,slug,IFNULL(name,''),status,downloads,installs,IFNULL(updated_at,'')
- FROM plugins WHERE dev_id=? ORDER BY id DESC LIMIT 5`, d.ID)
- var recent []row
- if err == nil {
- for rows.Next() {
- var it row
- if rows.Scan(&it.ID, &it.Slug, &it.Name, &it.Status, &it.Downloads, &it.Installs, &it.UpdatedAt) == nil {
- recent = append(recent, it)
- }
- }
- rows.Close()
- }
- Render(w, r, "dev_layout.html", "pg_dev_dash", map[string]any{
- "recent": recent,
- "myPlugins": models.QueryInt("SELECT COUNT(1) FROM plugins WHERE dev_id=?", d.ID),
- "downloads": models.QueryInt("SELECT IFNULL(SUM(downloads),0) FROM plugins WHERE dev_id=?", d.ID),
- "installs": models.QueryInt("SELECT IFNULL(SUM(installs),0) FROM plugins WHERE dev_id=?", d.ID),
- "published": models.QueryInt("SELECT COUNT(1) FROM plugins WHERE dev_id=? AND status=1", d.ID),
- })
-}
-
-// DevMyPlugins 我的插件列表
-func DevMyPlugins(w http.ResponseWriter, r *http.Request) {
- d := requireDev(w, r)
- if d == nil {
- return
- }
- rows, err := database.DB.Query(`SELECT p.id,p.slug,p.name,IFNULL(p.description,''),IFNULL(p.category,'other'),
- p.status,p.downloads,p.installs,IFNULL(p.updated_at,''),IFNULL(v.version,'')
- FROM plugins p
- LEFT JOIN plugin_versions v ON v.id=(SELECT id FROM plugin_versions WHERE plugin_id=p.id ORDER BY id DESC LIMIT 1)
- WHERE p.dev_id=? ORDER BY p.id DESC`, d.ID)
- type row struct {
- ID int64
- Slug string
- Name string
- Description string
- Category string
- Status int
- Downloads int64
- Installs int64
- UpdatedAt string
- Version string
- }
- var list []row
- if err == nil {
- for rows.Next() {
- var it row
- if rows.Scan(&it.ID, &it.Slug, &it.Name, &it.Description, &it.Category, &it.Status,
- &it.Downloads, &it.Installs, &it.UpdatedAt, &it.Version) == nil {
- list = append(list, it)
- }
- }
- rows.Close()
- }
- Render(w, r, "dev_layout.html", "pg_dev_plugins", map[string]any{"plugins": list})
-}
-
-// DevNewPage 发布新插件页
-func DevNewPage(w http.ResponseWriter, r *http.Request) {
- d := requireDev(w, r)
- if d == nil {
- return
- }
- Render(w, r, "dev_layout.html", "pg_dev_new", map[string]any{"cats": models.Categories})
-}
-
-// DevNewSubmit 上传插件包并创建插件(初始为待审核)
-func DevNewSubmit(w http.ResponseWriter, r *http.Request) {
- d := requireDev(w, r)
- if d == nil {
- return
- }
- r.Body = http.MaxBytesReader(w, r.Body, 60<<20)
- if err := r.ParseMultipartForm(16 << 20); err != nil {
- back(w, r, "/dev/plugins/new", "上传内容过大或表单无效")
- return
- }
- f, _, err := r.FormFile("file")
- if err != nil {
- back(w, r, "/dev/plugins/new", "请选择插件 zip 包")
- return
- }
- defer f.Close()
- data, err := io.ReadAll(io.LimitReader(f, 50<<20))
- if err != nil || len(data) == 0 {
- back(w, r, "/dev/plugins/new", "读取文件失败")
- return
- }
- m, err := parseZipManifest(data)
- if err != nil {
- back(w, r, "/dev/plugins/new", err.Error())
- return
- }
- slug := uniqueSlug(m.Name)
- version := strings.TrimSpace(m.Version)
- if version == "" {
- version = "1.0.0"
- }
- desc := util.StripHTML(r.FormValue("description"))
- if desc == "" {
- desc = m.Description
- }
- author := util.StripHTML(r.FormValue("author"))
- if author == "" {
- author = m.Author
- }
- if author == "" {
- author = d.Username
- }
- category := r.FormValue("category")
- if models.CategoryLabel(category) == "其他" && category != "other" {
- category = "other"
- }
- path, size, sha, err := savePackage(slug, version, data)
- if err != nil {
- back(w, r, "/dev/plugins/new", "保存插件包失败")
- return
- }
- res, err := database.DB.Exec(`INSERT INTO plugins(dev_id,slug,name,description,readme,category,tags,
- author,homepage,license,status,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?,0,?,?)`,
- d.ID, slug, util.StripHTML(m.Name), desc, util.StripHTML(r.FormValue("readme")), category,
- util.StripHTML(r.FormValue("tags")), author, util.StripHTML(r.FormValue("homepage")),
- util.StripHTML(r.FormValue("license")), models.Now(), models.Now())
- if err != nil {
- back(w, r, "/dev/plugins/new", "创建失败,请稍后重试")
- return
- }
- pid, _ := res.LastInsertId()
- _, _ = database.DB.Exec(`INSERT INTO plugin_versions(plugin_id,version,changelog,file_path,file_size,sha256,created_at)
- VALUES(?,?,?,?,?,?,?)`, pid, version, util.StripHTML(r.FormValue("changelog")), path, size, sha, models.Now())
- util.Log("info", "开发者 %s 发布插件 %s@%s(待审核)", d.Username, slug, version)
- http.Redirect(w, r, "/dev/plugins", http.StatusSeeOther)
-}
-
-// devOwnPlugin 校验插件归属
-func devOwnPlugin(d *models.Developer, slug string) *models.Plugin {
- rows, err := database.DB.Query(pluginSelect+" WHERE p.slug=? AND p.dev_id=?", slug, d.ID)
- if err != nil {
- return nil
- }
- defer rows.Close()
- list := scanPlugins(rows)
- if len(list) == 0 {
- return nil
- }
- return &list[0]
-}
-
-// DevPluginPage 插件管理页(编辑信息 / 上传新版本)
-func DevPluginPage(w http.ResponseWriter, r *http.Request) {
- d := requireDev(w, r)
- if d == nil {
- return
- }
- p := devOwnPlugin(d, r.PathValue("slug"))
- if p == nil {
- http.NotFound(w, r)
- return
- }
- Render(w, r, "dev_layout.html", "pg_dev_plugin", map[string]any{
- "p": p, "versions": pluginVersions(p.ID), "cats": models.Categories,
- })
-}
-
-// DevPluginUpdate 更新插件信息
-func DevPluginUpdate(w http.ResponseWriter, r *http.Request) {
- d := requireDev(w, r)
- if d == nil {
- return
- }
- p := devOwnPlugin(d, r.PathValue("slug"))
- if p == nil {
- http.NotFound(w, r)
- return
- }
- category := r.FormValue("category")
- name := util.StripHTML(r.FormValue("name"))
- if name == "" {
- name = p.Name
- }
- _, _ = database.DB.Exec(`UPDATE plugins SET name=?,description=?,readme=?,category=?,tags=?,homepage=?,license=?,updated_at=? WHERE id=?`,
- name, util.StripHTML(r.FormValue("description")), util.StripHTML(r.FormValue("readme")), category,
- util.StripHTML(r.FormValue("tags")), util.StripHTML(r.FormValue("homepage")),
- util.StripHTML(r.FormValue("license")), models.Now(), p.ID)
- back(w, r, "/dev/plugins/"+p.Slug, "")
-}
-
-// DevPluginVersion 上传新版本
-func DevPluginVersion(w http.ResponseWriter, r *http.Request) {
- d := requireDev(w, r)
- if d == nil {
- return
- }
- p := devOwnPlugin(d, r.PathValue("slug"))
- if p == nil {
- http.NotFound(w, r)
- return
- }
- r.Body = http.MaxBytesReader(w, r.Body, 60<<20)
- if err := r.ParseMultipartForm(16 << 20); err != nil {
- back(w, r, "/dev/plugins/"+p.Slug, "上传内容过大或表单无效")
- return
- }
- f, _, err := r.FormFile("file")
- if err != nil {
- back(w, r, "/dev/plugins/"+p.Slug, "请选择插件 zip 包")
- return
- }
- defer f.Close()
- data, err := io.ReadAll(io.LimitReader(f, 50<<20))
- if err != nil || len(data) == 0 {
- back(w, r, "/dev/plugins/"+p.Slug, "读取文件失败")
- return
- }
- m, err := parseZipManifest(data)
- if err != nil {
- back(w, r, "/dev/plugins/"+p.Slug, err.Error())
- return
- }
- version := strings.TrimSpace(m.Version)
- if version == "" {
- back(w, r, "/dev/plugins/"+p.Slug, "plugin.json 缺少 version 字段")
- return
- }
- if models.QueryInt("SELECT COUNT(1) FROM plugin_versions WHERE plugin_id=? AND version=?", p.ID, version) > 0 {
- back(w, r, "/dev/plugins/"+p.Slug, "该版本号已存在")
- return
- }
- path, size, sha, err := savePackage(p.Slug, version, data)
- if err != nil {
- back(w, r, "/dev/plugins/"+p.Slug, "保存插件包失败")
- return
- }
- _, _ = database.DB.Exec(`INSERT INTO plugin_versions(plugin_id,version,changelog,file_path,file_size,sha256,created_at)
- VALUES(?,?,?,?,?,?,?)`, p.ID, version, util.StripHTML(r.FormValue("changelog")), path, size, sha, models.Now())
- _, _ = database.DB.Exec("UPDATE plugins SET updated_at=? WHERE id=?", models.Now(), p.ID)
- back(w, r, "/dev/plugins/"+p.Slug, "")
-}
-
-// DevPluginSubmit 重新提交审核
-func DevPluginSubmit(w http.ResponseWriter, r *http.Request) {
- d := requireDev(w, r)
- if d == nil {
- return
- }
- p := devOwnPlugin(d, r.PathValue("slug"))
- if p == nil {
- http.NotFound(w, r)
- return
- }
- _, _ = database.DB.Exec("UPDATE plugins SET status=0, updated_at=? WHERE id=?", models.Now(), p.ID)
- back(w, r, "/dev/plugins/"+p.Slug, "")
-}
-
-// DevPluginDelete 删除插件(含全部版本文件)
-func DevPluginDelete(w http.ResponseWriter, r *http.Request) {
- d := requireDev(w, r)
- if d == nil {
- return
- }
- p := devOwnPlugin(d, r.PathValue("slug"))
- if p == nil {
- http.NotFound(w, r)
- return
- }
- _, _ = database.DB.Exec("DELETE FROM plugin_versions WHERE plugin_id=?", p.ID)
- _, _ = database.DB.Exec("DELETE FROM download_logs WHERE plugin_id=?", p.ID)
- _, _ = database.DB.Exec("DELETE FROM plugins WHERE id=?", p.ID)
- _ = os.RemoveAll(filepath.Join(config.Cfg.UploadDir, "packages", p.Slug))
- http.Redirect(w, r, "/dev/plugins", http.StatusSeeOther)
-}
-
-// DevProfile 个人资料页
-func DevProfile(w http.ResponseWriter, r *http.Request) {
- d := requireDev(w, r)
- if d == nil {
- return
- }
- Render(w, r, "dev_layout.html", "pg_dev_profile", map[string]any{"d": d})
-}
-
-// DevProfileSave 保存资料 / 修改密码
-func DevProfileSave(w http.ResponseWriter, r *http.Request) {
- d := requireDev(w, r)
- if d == nil {
- return
- }
- _, _ = database.DB.Exec("UPDATE developers SET bio=?,homepage=? WHERE id=?",
- util.StripHTML(r.FormValue("bio")), util.StripHTML(r.FormValue("homepage")), d.ID)
- if np := r.FormValue("new_password"); len(np) >= 6 {
- _, _ = database.DB.Exec("UPDATE developers SET password=? WHERE id=?", util.HashPassword(np), d.ID)
- }
- back(w, r, "/dev/profile", "")
-}
-
-// back 带错误信息的回跳(err 为空则纯跳转)
-func back(w http.ResponseWriter, r *http.Request, target, msg string) {
- if msg != "" {
- target += "?err=" + urlQueryEscape(msg)
- }
- http.Redirect(w, r, target, http.StatusSeeOther)
-}
diff --git a/cloud/internal/handlers/handlers.go b/cloud/internal/handlers/handlers.go
deleted file mode 100644
index caa2d94..0000000
--- a/cloud/internal/handlers/handlers.go
+++ /dev/null
@@ -1,255 +0,0 @@
-// Package handlers 云端全部 HTTP 处理器。
-// 本文件提供模板渲染、会话判定、JSON 输出等公共设施。
-package handlers
-
-import (
- "bytes"
- "embed"
- "encoding/json"
- "html/template"
- "io/fs"
- "net/http"
- "strconv"
- "strings"
- "time"
-
- "clearlove-cloud/internal/middleware"
- "clearlove-cloud/internal/models"
- "clearlove-cloud/internal/util"
-)
-
-// Tmpl 全局模板集
-var Tmpl *template.Template
-
-// StaticFS 内嵌静态资源(由 main 注入)
-var StaticFS fs.FS
-
-// SetupTemplates 解析内嵌模板并注册函数
-func SetupTemplates(efs embed.FS) {
- funcs := template.FuncMap{
- // nl2br 转义后换行转 <br>(安全展示用户文本)
- "nl2br": func(s string) template.HTML {
- return template.HTML(strings.ReplaceAll(template.HTMLEscapeString(s), "\n", "<br>"))
- },
- // datefmt RFC3339 -> "2006-01-02"
- "datefmt": func(s string) string {
- t, err := time.Parse(time.RFC3339, s)
- if err != nil {
- return s
- }
- return t.Local().Format("2006-01-02")
- },
- // datetime RFC3339 -> "2006-01-02 15:04"
- "datetime": func(s string) string {
- t, err := time.Parse(time.RFC3339, s)
- if err != nil {
- return s
- }
- return t.Local().Format("2006-01-02 15:04")
- },
- "snippet": func(s string, n int) string {
- r := []rune(s)
- if len(r) > n {
- return string(r[:n]) + "..."
- }
- return s
- },
- "substr0": func(s string) string {
- r := []rune(s)
- if len(r) == 0 {
- return "?"
- }
- return string(r[0])
- },
- "catLabel": models.CategoryLabel,
- "sizeText": func(n int64) string {
- if n <= 0 {
- return "-"
- }
- if n >= 1<<20 {
- return strconv.FormatFloat(float64(n)/float64(1<<20), 'f', 1, 64) + " MB"
- }
- return strconv.FormatInt(n/1024+1, 10) + " KB"
- },
- "plus1": func(n int) int { return n + 1 },
- "minus1": func(n int) int { return n - 1 },
- // percent 柱状图高度百分比(最小值 3 保证可见)
- "percent": func(v, max int64) int {
- if max <= 0 || v <= 0 {
- return 3
- }
- p := int(v * 100 / max)
- if p < 3 {
- p = 3
- }
- if p > 100 {
- p = 100
- }
- return p
- },
- }
- // pageContent 按页面名动态渲染子模板
- funcs["pageContent"] = func(name string, data any) template.HTML {
- var buf bytes.Buffer
- if Tmpl == nil {
- return ""
- }
- if err := Tmpl.ExecuteTemplate(&buf, name, data); err != nil {
- util.Log("error", "页面模板 %s 渲染失败: %v", name, err)
- return template.HTML("<p>页面渲染失败</p>")
- }
- return template.HTML(buf.String())
- }
- Tmpl = template.Must(template.New("clv").Funcs(funcs).ParseFS(efs, "web/templates/*.html"))
-}
-
-// pageTitles 页面名 -> 标题
-var pageTitles = map[string]string{
- "pg_home": "",
- "pg_plugins": "插件市场",
- "pg_plugin_detail": "插件详情",
- "pg_docs": "插件开发文档",
- "pg_dev_register": "开发者注册",
- "pg_dev_login": "开发者登录",
- "pg_dev_dash": "开发者后台",
- "pg_dev_plugins": "我的插件",
- "pg_dev_new": "发布插件",
- "pg_dev_plugin": "插件管理",
- "pg_dev_profile": "个人资料",
- "pg_admin_login": "管理员登录",
- "pg_admin_dash": "仪表盘",
- "pg_admin_plugins": "插件审核",
- "pg_admin_developers": "开发者管理",
- "pg_admin_settings": "站点设置",
-}
-
-// Render 渲染页面,自动注入公共数据
-func Render(w http.ResponseWriter, r *http.Request, layout, page string, data map[string]any) {
- if data == nil {
- data = map[string]any{}
- }
- data["Page"] = page
- // 页面标题(未显式指定时按页面名映射)
- if _, ok := data["title"]; !ok {
- data["title"] = pageTitles[page]
- }
- // 表单回跳提示(?err= / ?ok=)
- if _, ok := data["err"]; !ok {
- if e := r.URL.Query().Get("err"); e != "" {
- data["err"] = e
- }
- }
- if _, ok := data["ok"]; !ok {
- if r.URL.Query().Get("ok") == "1" {
- data["ok"] = true
- }
- }
- data["csrf"] = middleware.CSRFToken(r)
- data["site"] = models.GetSetting("site_name")
- data["slogan"] = models.GetSetting("slogan")
- data["footer_text"] = models.GetSetting("footer_text")
- data["icp"] = models.GetSetting("icp")
- data["download_url"] = models.GetSetting("download_url")
- if d := CurrentDeveloper(r); d != nil {
- data["dev"] = d
- }
- if a := CurrentAdmin(r); a != nil {
- data["admin"] = a
- }
- w.Header().Set("Content-Type", "text/html; charset=utf-8")
- if err := Tmpl.ExecuteTemplate(w, layout, data); err != nil {
- util.Log("error", "模板渲染失败 %s: %v", page, err)
- }
-}
-
-// JSON 输出 JSON 响应
-func JSON(w http.ResponseWriter, code int, v any) {
- w.Header().Set("Content-Type", "application/json; charset=utf-8")
- w.WriteHeader(code)
- _ = json.NewEncoder(w).Encode(v)
-}
-
-// fail 错误 JSON
-func fail(w http.ResponseWriter, code int, msg string) {
- JSON(w, code, map[string]any{"ok": false, "msg": msg})
-}
-
-// okJSON 成功 JSON
-func okJSON(w http.ResponseWriter, extra map[string]any) {
- m := map[string]any{"ok": true}
- for k, v := range extra {
- m[k] = v
- }
- JSON(w, http.StatusOK, m)
-}
-
-// ---------- 会话 ----------
-
-// CurrentDeveloper 从签名 Cookie 解析开发者
-func CurrentDeveloper(r *http.Request) *models.Developer {
- c, err := r.Cookie("clv_dev")
- if err != nil {
- return nil
- }
- uid, role, ok := util.VerifySession(c.Value)
- if !ok || role != "dev" {
- return nil
- }
- d := models.GetDeveloperByID(uid)
- if d == nil || d.Status != 1 {
- return nil
- }
- return d
-}
-
-// CurrentAdmin 从签名 Cookie 解析管理员
-func CurrentAdmin(r *http.Request) *models.Admin {
- c, err := r.Cookie("clv_admin")
- if err != nil {
- return nil
- }
- uid, role, ok := util.VerifySession(c.Value)
- if !ok || role != "admin" {
- return nil
- }
- return models.GetAdminByID(uid)
-}
-
-// requireDev 开发者后台门禁
-func requireDev(w http.ResponseWriter, r *http.Request) *models.Developer {
- d := CurrentDeveloper(r)
- if d == nil {
- http.Redirect(w, r, "/dev/login", http.StatusFound)
- return nil
- }
- return d
-}
-
-// requireAdmin 管理后台门禁
-func requireAdmin(w http.ResponseWriter, r *http.Request) *models.Admin {
- a := CurrentAdmin(r)
- if a == nil {
- http.Redirect(w, r, "/admin/login", http.StatusFound)
- return nil
- }
- return a
-}
-
-// ---------- 小工具 ----------
-
-// pathInt64 解析路径参数
-func pathInt64(r *http.Request, name string) int64 {
- n, _ := strconv.ParseInt(r.PathValue(name), 10, 64)
- return n
-}
-
-// formInt64 解析表单整数
-func formInt64(r *http.Request, key string) int64 {
- n, _ := strconv.ParseInt(strings.TrimSpace(r.FormValue(key)), 10, 64)
- return n
-}
-
-// isAJAX 是否 AJAX 请求
-func isAJAX(r *http.Request) bool {
- return r.Header.Get("X-Requested-With") == "XMLHttpRequest"
-}
diff --git a/cloud/internal/handlers/site.go b/cloud/internal/handlers/site.go
deleted file mode 100644
index c1599bb..0000000
--- a/cloud/internal/handlers/site.go
+++ /dev/null
@@ -1,196 +0,0 @@
-// 官网与插件市场:首页、插件列表、插件详情、开发文档、下载跳转。
-package handlers
-
-import (
- "database/sql"
- "net/http"
- "strings"
-
- "clearlove-cloud/internal/database"
- "clearlove-cloud/internal/models"
-)
-
-// ---------- 查询辅助 ----------
-
-const pluginSelect = `
- SELECT p.id,p.dev_id,p.slug,p.name,IFNULL(p.description,''),IFNULL(p.readme,''),IFNULL(p.category,'other'),
- IFNULL(p.tags,''),IFNULL(p.author,''),IFNULL(p.homepage,''),IFNULL(p.license,''),p.status,
- p.downloads,p.installs,IFNULL(p.created_at,''),IFNULL(p.updated_at,''),
- IFNULL(d.username,''),IFNULL(v.version,''),IFNULL(v.file_size,0),IFNULL(v.sha256,'')
- FROM plugins p
- LEFT JOIN developers d ON d.id = p.dev_id
- LEFT JOIN plugin_versions v ON v.id =
- (SELECT id FROM plugin_versions WHERE plugin_id = p.id ORDER BY id DESC LIMIT 1)`
-
-func scanPlugins(rows *sql.Rows) []models.Plugin {
- out := []models.Plugin{}
- for rows.Next() {
- var p models.Plugin
- if rows.Scan(&p.ID, &p.DevID, &p.Slug, &p.Name, &p.Description, &p.Readme, &p.Category,
- &p.Tags, &p.Author, &p.Homepage, &p.License, &p.Status, &p.Downloads, &p.Installs,
- &p.CreatedAt, &p.UpdatedAt, &p.DevName, &p.Version, &p.Size, &p.SHA256) == nil {
- out = append(out, p)
- }
- }
- return out
-}
-
-// listPublished 上架插件查询(支持关键词、分类、排序、分页)
-func listPublished(q, category, sortBy string, limit, offset int) ([]models.Plugin, int64) {
- where := " WHERE p.status=1"
- args := []any{}
- if q != "" {
- where += " AND (p.name LIKE ? OR p.description LIKE ? OR p.tags LIKE ?)"
- kw := "%" + q + "%"
- args = append(args, kw, kw, kw)
- }
- if category != "" && category != "all" {
- where += " AND p.category=?"
- args = append(args, category)
- }
- total := models.QueryInt("SELECT COUNT(1) FROM plugins p"+where, args...)
-
- order := " ORDER BY p.downloads DESC, p.id DESC"
- switch sortBy {
- case "new":
- order = " ORDER BY p.id DESC"
- case "name":
- order = " ORDER BY p.name ASC"
- }
- rows, err := database.DB.Query(pluginSelect+where+order+" LIMIT ? OFFSET ?",
- append(args, limit, offset)...)
- if err != nil {
- return []models.Plugin{}, total
- }
- defer rows.Close()
- return scanPlugins(rows), total
-}
-
-// getPlugin 按 slug 查询已上架插件
-func getPlugin(slug string) *models.Plugin {
- rows, err := database.DB.Query(pluginSelect+" WHERE p.slug=? AND p.status=1", slug)
- if err != nil {
- return nil
- }
- defer rows.Close()
- list := scanPlugins(rows)
- if len(list) == 0 {
- return nil
- }
- return &list[0]
-}
-
-// pluginVersions 插件的全部版本(新到旧)
-func pluginVersions(pid int64) []models.Version {
- out := []models.Version{}
- rows, err := database.DB.Query(`SELECT id,plugin_id,IFNULL(version,''),IFNULL(changelog,''),
- IFNULL(file_path,''),file_size,IFNULL(sha256,''),downloads,IFNULL(created_at,'')
- FROM plugin_versions WHERE plugin_id=? ORDER BY id DESC`, pid)
- if err != nil {
- return out
- }
- defer rows.Close()
- for rows.Next() {
- var v models.Version
- if rows.Scan(&v.ID, &v.PluginID, &v.Version, &v.Changelog, &v.FilePath,
- &v.FileSize, &v.SHA256, &v.Downloads, &v.CreatedAt) == nil {
- out = append(out, v)
- }
- }
- return out
-}
-
-// siteStats 站点统计(首页 / 后台共用)
-func siteStats() map[string]any {
- return map[string]any{
- "plugins": models.QueryInt("SELECT COUNT(1) FROM plugins WHERE status=1"),
- "developers": models.QueryInt("SELECT COUNT(1) FROM developers WHERE status=1"),
- "downloads": models.QueryInt("SELECT IFNULL(SUM(downloads),0) FROM plugins"),
- "installs": models.QueryInt("SELECT IFNULL(SUM(installs),0) FROM plugins"),
- }
-}
-
-// ---------- 页面 ----------
-
-// Home 官网首页
-func Home(w http.ResponseWriter, r *http.Request) {
- latest, _ := listPublished("", "all", "new", 6, 0)
- data := map[string]any{
- "latest": latest,
- "stats": siteStats(),
- "isHome": true,
- }
- Render(w, r, "layout.html", "pg_home", data)
-}
-
-// Plugins 插件市场列表
-func Plugins(w http.ResponseWriter, r *http.Request) {
- q := strings.TrimSpace(r.URL.Query().Get("q"))
- category := strings.TrimSpace(r.URL.Query().Get("category"))
- sortBy := r.URL.Query().Get("sort")
- page := int(formInt64(r, "page"))
- if page < 1 {
- page = 1
- }
- const size = 12
- list, total := listPublished(q, category, sortBy, size, (page-1)*size)
- pages := int(total)/size + 1
- if int64(page) > int64(pages) {
- page = pages
- }
- Render(w, r, "layout.html", "pg_plugins", map[string]any{
- "plugins": list,
- "total": total,
- "page": page,
- "pages": pages,
- "q": q,
- "category": category,
- "sort": sortBy,
- "cats": models.Categories,
- })
-}
-
-// PluginDetail 插件详情
-func PluginDetail(w http.ResponseWriter, r *http.Request) {
- slug := r.PathValue("slug")
- p := getPlugin(slug)
- if p == nil {
- http.NotFound(w, r)
- return
- }
- Render(w, r, "layout.html", "pg_plugin_detail", map[string]any{
- "p": p,
- "versions": pluginVersions(p.ID),
- })
-}
-
-// Docs 插件开发文档
-func Docs(w http.ResponseWriter, r *http.Request) {
- Render(w, r, "layout.html", "pg_docs", nil)
-}
-
-// DownloadLatest 下载最新版本(/dl/{slug})
-func DownloadLatest(w http.ResponseWriter, r *http.Request) {
- p := getPlugin(r.PathValue("slug"))
- if p == nil || p.Version == "" {
- http.NotFound(w, r)
- return
- }
- http.Redirect(w, r, "/api/v1/store/download/"+p.Slug+"/"+p.Version, http.StatusFound)
-}
-
-// DownloadSite 站点下载按钮跳转(目标由后台设置)
-func DownloadSite(w http.ResponseWriter, r *http.Request) {
- url := models.GetSetting("download_url")
- if url == "" {
- http.Redirect(w, r, "/", http.StatusFound)
- return
- }
- http.Redirect(w, r, url, http.StatusFound)
-}
-
-// Favicon 站点图标(内联 SVG,绿色主题)
-func Favicon(w http.ResponseWriter, r *http.Request) {
- w.Header().Set("Content-Type", "image/svg+xml")
- _, _ = w.Write([]byte(`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><rect width="32" height="32" rx="9" fill="#10b981"/><path d="M16 24s-8-5.2-8-10.4C8 10 10.2 8 12.8 8c1.6 0 2.7.8 3.2 1.7C16.5 8.8 17.6 8 19.2 8 21.8 8 24 10 24 13.6 24 18.8 16 24 16 24z" fill="#fff"/></svg>`))
-}
diff --git a/cloud/internal/handlers/store.go b/cloud/internal/handlers/store.go
deleted file mode 100644
index 367da9c..0000000
--- a/cloud/internal/handlers/store.go
+++ /dev/null
@@ -1,175 +0,0 @@
-// 插件市场开放 API(供表白墙后台插件商店等第三方客户端调用)。
-// 全部为只读/统计接口,无需登录:
-// GET /api/v1/store/plugins?q=&category=&sort=&page=&limit=
-// GET /api/v1/store/plugins/{slug}
-// GET /api/v1/store/download/{slug}/{version}
-// POST /api/v1/store/report (安装上报,用于统计真实安装量)
-package handlers
-
-import (
- "encoding/json"
- "io"
- "net/http"
- "os"
- "strings"
-
- "clearlove-cloud/internal/config"
- "clearlove-cloud/internal/database"
- "clearlove-cloud/internal/models"
- "clearlove-cloud/internal/util"
-)
-
-// versionItem API 返回的版本结构
-type versionItem struct {
- Version string `json:"version"`
- Changelog string `json:"changelog"`
- Size int64 `json:"size"`
- SHA256 string `json:"sha256"`
- Downloads int64 `json:"downloads"`
- URL string `json:"download_url"`
- CreatedAt string `json:"created_at"`
-}
-
-// pluginItem API 返回的插件结构
-type pluginItem struct {
- Slug string `json:"slug"`
- Name string `json:"name"`
- Version string `json:"version"`
- Description string `json:"description"`
- Author string `json:"author"`
- Category string `json:"category"`
- CategoryTxt string `json:"category_label"`
- Tags string `json:"tags"`
- Homepage string `json:"homepage"`
- License string `json:"license"`
- Downloads int64 `json:"downloads"`
- Installs int64 `json:"installs"`
- Size int64 `json:"size"`
- SHA256 string `json:"sha256"`
- UpdatedAt string `json:"updated_at"`
- PageURL string `json:"page_url"`
- DownloadURL string `json:"download_url"`
-}
-
-func toItem(p models.Plugin) pluginItem {
- base := strings.TrimSuffix(config.Cfg.BaseURL, "/")
- dl := ""
- if p.Version != "" {
- dl = base + "/api/v1/store/download/" + p.Slug + "/" + p.Version
- }
- return pluginItem{
- Slug: p.Slug, Name: p.Name, Version: p.Version, Description: p.Description,
- Author: p.Author, Category: p.Category, CategoryTxt: models.CategoryLabel(p.Category),
- Tags: p.Tags, Homepage: p.Homepage, License: p.License,
- Downloads: p.Downloads, Installs: p.Installs, Size: p.Size, SHA256: p.SHA256,
- UpdatedAt: p.UpdatedAt, PageURL: base + "/plugins/" + p.Slug, DownloadURL: dl,
- }
-}
-
-// APIListPlugins 插件市场列表
-func APIListPlugins(w http.ResponseWriter, r *http.Request) {
- q := strings.TrimSpace(r.URL.Query().Get("q"))
- category := r.URL.Query().Get("category")
- sortBy := r.URL.Query().Get("sort")
- page := int(formInt64(r, "page"))
- limit := int(formInt64(r, "limit"))
- if page < 1 {
- page = 1
- }
- if limit <= 0 || limit > 50 {
- limit = 20
- }
- list, total := listPublished(q, category, sortBy, limit, (page-1)*limit)
- items := make([]pluginItem, 0, len(list))
- for _, p := range list {
- items = append(items, toItem(p))
- }
- okJSON(w, map[string]any{
- "total": total, "page": page, "limit": limit, "plugins": items,
- })
-}
-
-// APIPluginDetail 插件详情(含 readme 与全部版本)
-func APIPluginDetail(w http.ResponseWriter, r *http.Request) {
- p := getPlugin(r.PathValue("slug"))
- if p == nil {
- fail(w, http.StatusNotFound, "插件不存在或未上架")
- return
- }
- vers := pluginVersions(p.ID)
- items := make([]versionItem, 0, len(vers))
- base := strings.TrimSuffix(config.Cfg.BaseURL, "/")
- for _, v := range vers {
- items = append(items, versionItem{
- Version: v.Version, Changelog: v.Changelog, Size: v.FileSize, SHA256: v.SHA256,
- Downloads: v.Downloads, CreatedAt: v.CreatedAt,
- URL: base + "/api/v1/store/download/" + p.Slug + "/" + v.Version,
- })
- }
- okJSON(w, map[string]any{"plugin": toItem(*p), "readme": p.Readme, "versions": items})
-}
-
-// APIDownload 下载指定版本插件包(zip),并累计下载量
-func APIDownload(w http.ResponseWriter, r *http.Request) {
- slug := r.PathValue("slug")
- version := r.PathValue("version")
- p := getPlugin(slug)
- if p == nil {
- fail(w, http.StatusNotFound, "插件不存在或未上架")
- return
- }
- var v models.Version
- err := database.DB.QueryRow(`SELECT id,IFNULL(file_path,''),file_size,IFNULL(sha256,'')
- FROM plugin_versions WHERE plugin_id=? AND version=?`, p.ID, version).
- Scan(&v.ID, &v.FilePath, &v.FileSize, &v.SHA256)
- if err != nil || v.FilePath == "" {
- fail(w, http.StatusNotFound, "版本不存在")
- return
- }
- f, err := os.Open(v.FilePath)
- if err != nil {
- fail(w, http.StatusNotFound, "插件包文件缺失")
- return
- }
- defer f.Close()
- info, err := f.Stat()
- if err != nil {
- fail(w, http.StatusInternalServerError, "读取失败")
- return
- }
- // 统计
- _, _ = database.DB.Exec("UPDATE plugins SET downloads=downloads+1 WHERE id=?", p.ID)
- _, _ = database.DB.Exec("UPDATE plugin_versions SET downloads=downloads+1 WHERE id=?", v.ID)
- _, _ = database.DB.Exec("INSERT INTO download_logs(plugin_id,version,ip,created_at) VALUES(?,?,?,?)",
- p.ID, version, util.ClientIP(r), models.Now())
-
- w.Header().Set("Content-Type", "application/zip")
- w.Header().Set("Content-Disposition", `attachment; filename="`+slug+"-"+version+`.zip"`)
- w.Header().Set("X-Plugin-SHA256", v.SHA256)
- http.ServeContent(w, r, slug+"-"+version+".zip", info.ModTime(), f)
-}
-
-// APIReportInstall 安装上报(表白墙安装成功后回调,用于统计真实安装量)
-func APIReportInstall(w http.ResponseWriter, r *http.Request) {
- var body struct {
- Slug string `json:"slug"`
- Version string `json:"version"`
- Site string `json:"site"`
- }
- if err := json.NewDecoder(io.LimitReader(r.Body, 1<<16)).Decode(&body); err != nil {
- fail(w, 400, "JSON 格式错误")
- return
- }
- if body.Slug == "" {
- fail(w, 400, "缺少 slug")
- return
- }
- var pid int64
- if err := database.DB.QueryRow("SELECT id FROM plugins WHERE slug=? AND status=1", body.Slug).Scan(&pid); err != nil {
- fail(w, http.StatusNotFound, "插件不存在")
- return
- }
- _, _ = database.DB.Exec("UPDATE plugins SET installs=installs+1 WHERE id=?", pid)
- util.Log("info", "插件安装上报: %s@%s 来自 %s", body.Slug, body.Version, body.Site)
- okJSON(w, nil)
-}
diff --git a/cloud/internal/mailer/mailer.go b/cloud/internal/mailer/mailer.go
deleted file mode 100644
index dd7e4b9..0000000
--- a/cloud/internal/mailer/mailer.go
+++ /dev/null
@@ -1,66 +0,0 @@
-// Package mailer SMTP 邮件发送(465 SSL 直连 / 587 与 25 STARTTLS 或明文)。
-// SMTP 参数由管理员在后台「站点设置」中配置。
-package mailer
-
-import (
- "crypto/tls"
- "encoding/base64"
- "fmt"
- "net"
- "net/smtp"
- "strings"
-)
-
-// Send 发送纯文本邮件
-func Send(host, port, user, pass, from, to, subject, body string) error {
- addr := net.JoinHostPort(host, port)
- auth := smtp.PlainAuth("", user, pass, host)
- msg := buildMessage(from, to, subject, body)
-
- if port == "465" {
- conn, err := tls.Dial("tcp", addr, &tls.Config{ServerName: host})
- if err != nil {
- return fmt.Errorf("SSL 连接失败: %w", err)
- }
- c, err := smtp.NewClient(conn, host)
- if err != nil {
- return err
- }
- defer c.Close()
- if err = c.Auth(auth); err != nil {
- return fmt.Errorf("SMTP 认证失败: %w", err)
- }
- if err = c.Mail(from); err != nil {
- return err
- }
- if err = c.Rcpt(to); err != nil {
- return err
- }
- w, err := c.Data()
- if err != nil {
- return err
- }
- if _, err = w.Write(msg); err != nil {
- return err
- }
- return w.Close()
- }
- return smtp.SendMail(addr, auth, from, []string{to}, msg)
-}
-
-func buildMessage(from, to, subject, body string) []byte {
- var b strings.Builder
- b.WriteString("From: " + from + "\r\n")
- b.WriteString("To: " + to + "\r\n")
- b.WriteString("Subject: =?UTF-8?B?" + base64.StdEncoding.EncodeToString([]byte(subject)) + "?=\r\n")
- b.WriteString("MIME-Version: 1.0\r\n")
- b.WriteString("Content-Type: text/plain; charset=UTF-8\r\n\r\n")
- b.WriteString(body)
- return []byte(b.String())
-}
-
-// SendCode 发送开发者注册验证码
-func SendCode(host, port, user, pass, from, to, code string) error {
- body := fmt.Sprintf("您正在注册 ClearLove 插件社区开发者账号,验证码为:%s\n\n验证码 10 分钟内有效,若非本人操作请忽略本邮件。", code)
- return Send(host, port, user, pass, from, to, "ClearLove 插件社区 · 邮箱验证码", body)
-}
diff --git a/cloud/internal/middleware/middleware.go b/cloud/internal/middleware/middleware.go
deleted file mode 100644
index 5d30559..0000000
--- a/cloud/internal/middleware/middleware.go
+++ /dev/null
@@ -1,86 +0,0 @@
-// Package middleware 全局中间件:安全响应头、CSRF 双提交校验、访问统计埋点。
-package middleware
-
-import (
- "context"
- "net/http"
- "strings"
- "time"
-
- "clearlove-cloud/internal/database"
- "clearlove-cloud/internal/models"
- "clearlove-cloud/internal/util"
-)
-
-type ctxKey string
-
-const csrfKey ctxKey = "csrf"
-
-// CSRFToken 取出当前请求的 CSRF 令牌
-func CSRFToken(r *http.Request) string {
- if v, ok := r.Context().Value(csrfKey).(string); ok {
- return v
- }
- return ""
-}
-
-// Use 全局中间件入口
-func Use(next http.Handler) http.Handler {
- return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- start := time.Now()
-
- w.Header().Set("X-Content-Type-Options", "nosniff")
- w.Header().Set("X-Frame-Options", "SAMEORIGIN")
- w.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin")
-
- // CSRF 令牌:缺失则生成并写入 Cookie
- token := ""
- if c, err := r.Cookie("clv_csrf"); err == nil {
- token = c.Value
- }
- fresh := false
- if token == "" || len(token) != 32 {
- token = util.RandomHex(16)
- fresh = true
- }
- if fresh {
- http.SetCookie(w, &http.Cookie{
- Name: "clv_csrf", Value: token, Path: "/", MaxAge: 86400 * 30,
- HttpOnly: true, SameSite: http.SameSiteLaxMode,
- })
- }
- r = r.WithContext(context.WithValue(r.Context(), csrfKey, token))
-
- // 写操作 CSRF 校验(开放 API /api/v1/ 供表白墙等第三方调用,不做 CSRF)
- isOpenAPI := strings.HasPrefix(r.URL.Path, "/api/v1/")
- if !isOpenAPI && (r.Method == http.MethodPost || r.Method == http.MethodPut || r.Method == http.MethodDelete) {
- v := r.Header.Get("X-CSRF-Token")
- if v == "" {
- ct := r.Header.Get("Content-Type")
- if strings.HasPrefix(ct, "multipart/form-data") {
- _ = r.ParseMultipartForm(32 << 20)
- } else {
- _ = r.ParseForm()
- }
- v = r.PostFormValue("_csrf")
- }
- if v != token {
- http.Error(w, "CSRF 校验失败,请刷新页面重试", http.StatusForbidden)
- return
- }
- }
-
- next.ServeHTTP(w, r)
-
- // 访问统计:仅记录 HTML 页面浏览(排除静态资源与 API 下载)
- p := r.URL.Path
- if r.Method == http.MethodGet && database.DB != nil &&
- !strings.HasPrefix(p, "/static/") && !strings.HasPrefix(p, "/api/") &&
- p != "/favicon.svg" {
- day := time.Now().UTC().Format("2006-01-02")
- _, _ = database.DB.Exec("INSERT INTO visits(day,path,ip,created_at) VALUES(?,?,?,?)",
- day, p, util.ClientIP(r), models.Now())
- }
- util.Log("info", "%s %s %s %s", r.Method, r.URL.Path, r.RemoteAddr, time.Since(start).Round(time.Millisecond))
- })
-}
diff --git a/cloud/internal/models/models.go b/cloud/internal/models/models.go
deleted file mode 100644
index d5decb6..0000000
--- a/cloud/internal/models/models.go
+++ /dev/null
@@ -1,212 +0,0 @@
-// Package models 云端核心模型与通用存取辅助。
-package models
-
-import (
- "sync"
- "time"
-
- "clearlove-cloud/internal/database"
-)
-
-// Now 统一 UTC RFC3339 时间字符串
-func Now() string { return time.Now().UTC().Format(time.RFC3339) }
-
-// ---------- 设置 ----------
-
-var (
- setCache sync.Map
- setCacheMu sync.Mutex
-)
-
-// GetSetting 读取设置(带内存缓存)
-func GetSetting(k string) string {
- if database.DB == nil {
- return ""
- }
- if v, ok := setCache.Load(k); ok {
- s, _ := v.(string)
- return s
- }
- setCacheMu.Lock()
- defer setCacheMu.Unlock()
- var v string
- if err := database.DB.QueryRow("SELECT v FROM settings WHERE k=?", k).Scan(&v); err != nil {
- return ""
- }
- setCache.Store(k, v)
- return v
-}
-
-// SetSetting 写入设置并刷新缓存
-func SetSetting(k, v string) error {
- if database.DB == nil {
- return nil
- }
- setCacheMu.Lock()
- defer setCacheMu.Unlock()
- if _, err := database.DB.Exec(
- "INSERT INTO settings(k,v) VALUES(?,?) ON CONFLICT(k) DO UPDATE SET v=excluded.v", k, v); err != nil {
- return err
- }
- setCache.Store(k, v)
- return nil
-}
-
-// QueryInt 快捷查询单个整数值
-func QueryInt(q string, args ...any) int64 {
- if database.DB == nil {
- return 0
- }
- var n int64
- _ = database.DB.QueryRow(q, args...).Scan(&n)
- return n
-}
-
-// QueryStr 快捷查询单个字符串
-func QueryStr(q string, args ...any) string {
- if database.DB == nil {
- return ""
- }
- var s string
- _ = database.DB.QueryRow(q, args...).Scan(&s)
- return s
-}
-
-// ---------- 管理员 ----------
-
-// Admin 云端管理员
-type Admin struct {
- ID int64
- Username string
- Password string
- CreatedAt string
-}
-
-// GetAdminByID 按 ID 查询管理员
-func GetAdminByID(id int64) *Admin {
- if database.DB == nil {
- return nil
- }
- a := &Admin{}
- err := database.DB.QueryRow(
- "SELECT id,username,password,IFNULL(created_at,'') FROM admins WHERE id=?", id).
- Scan(&a.ID, &a.Username, &a.Password, &a.CreatedAt)
- if err != nil {
- return nil
- }
- return a
-}
-
-// ---------- 开发者 ----------
-
-// Developer 插件开发者账号。Status: 1 正常 / 2 禁用
-type Developer struct {
- ID int64
- Username string
- Email string
- Password string
- Bio string
- Homepage string
- Status int
- CreatedAt string
-}
-
-// GetDeveloperByID 按 ID 查询开发者
-func GetDeveloperByID(id int64) *Developer {
- if database.DB == nil {
- return nil
- }
- d := &Developer{}
- err := database.DB.QueryRow(`SELECT id,username,email,password,IFNULL(bio,''),IFNULL(homepage,''),
- status,IFNULL(created_at,'') FROM developers WHERE id=?`, id).
- Scan(&d.ID, &d.Username, &d.Email, &d.Password, &d.Bio, &d.Homepage, &d.Status, &d.CreatedAt)
- if err != nil {
- return nil
- }
- return d
-}
-
-// ---------- 插件 ----------
-
-// Plugin 插件主记录。Status: 0 待审核 / 1 已上架 / 2 已下架 / 3 已驳回
-type Plugin struct {
- ID int64
- DevID int64
- Slug string
- Name string
- Description string
- Readme string
- Category string
- Tags string
- Author string
- Homepage string
- License string
- Status int
- Downloads int64
- Installs int64
- CreatedAt string
- UpdatedAt string
- // 关联字段(列表查询时填充)
- DevName string
- Version string
- Size int64
- SHA256 string
-}
-
-// Version 插件版本
-type Version struct {
- ID int64
- PluginID int64
- Version string
- Changelog string
- FilePath string
- FileSize int64
- SHA256 string
- Downloads int64
- CreatedAt string
-}
-
-// StatusText 插件状态中文描述
-func (p *Plugin) StatusText() string {
- switch p.Status {
- case 1:
- return "已上架"
- case 2:
- return "已下架"
- case 3:
- return "已驳回"
- default:
- return "待审核"
- }
-}
-
-// CategoryLabel 分类中文名
-func CategoryLabel(c string) string {
- switch c {
- case "style":
- return "主题美化"
- case "social":
- return "社交互动"
- case "content":
- return "内容增强"
- case "security":
- return "安全防护"
- case "tool":
- return "实用工具"
- default:
- return "其他"
- }
-}
-
-// Categories 全部分类(slug -> label)
-var Categories = []struct {
- Slug string
- Label string
-}{
- {"style", "主题美化"},
- {"social", "社交互动"},
- {"content", "内容增强"},
- {"security", "安全防护"},
- {"tool", "实用工具"},
- {"other", "其他"},
-}
diff --git a/cloud/internal/util/util.go b/cloud/internal/util/util.go
deleted file mode 100644
index 5fb8133..0000000
--- a/cloud/internal/util/util.go
+++ /dev/null
@@ -1,170 +0,0 @@
-// Package util 云端通用工具:日志、随机串、bcrypt、HMAC 会话、请求辅助。
-package util
-
-import (
- "crypto/hmac"
- "crypto/rand"
- "crypto/sha256"
- "encoding/base64"
- "encoding/hex"
- "encoding/json"
- "fmt"
- "log"
- "math/big"
- "net/http"
- "regexp"
- "strings"
- "time"
-
- "golang.org/x/crypto/bcrypt"
-
- "clearlove-cloud/internal/config"
-)
-
-// Log 统一日志
-func Log(level, format string, args ...any) {
- log.SetFlags(log.Ldate | log.Ltime)
- log.Printf("[%s] %s", strings.ToUpper(level), fmt.Sprintf(format, args...))
-}
-
-// RandomHex 生成 n 字节十六进制随机串
-func RandomHex(n int) string {
- b := make([]byte, n)
- _, _ = rand.Read(b)
- return hex.EncodeToString(b)
-}
-
-// RandomCode 生成 n 位数字验证码
-func RandomCode(n int) string {
- var b strings.Builder
- for i := 0; i < n; i++ {
- v, err := rand.Int(rand.Reader, big.NewInt(10))
- if err != nil {
- b.WriteByte('0')
- continue
- }
- b.WriteByte(byte('0' + v.Int64()))
- }
- return b.String()
-}
-
-// SHA256Hex 计算字节流 sha256(插件包完整性校验)
-func SHA256Hex(b []byte) string {
- sum := sha256.Sum256(b)
- return hex.EncodeToString(sum[:])
-}
-
-// HashPassword bcrypt 加密
-func HashPassword(p string) string {
- b, _ := bcrypt.GenerateFromPassword([]byte(p), bcrypt.DefaultCost)
- return string(b)
-}
-
-// CheckPassword 校验密码
-func CheckPassword(hash, p string) bool {
- return bcrypt.CompareHashAndPassword([]byte(hash), []byte(p)) == nil
-}
-
-// ---------- HMAC 签名会话(无状态) ----------
-
-type sessionClaims struct {
- UID int64 `json:"uid"`
- Role string `json:"role"` // dev | admin
- Exp int64 `json:"exp"`
-}
-
-// SignSession 生成签名会话串 base64(payload).hex(hmac)
-func SignSession(uid int64, role string, dur time.Duration) string {
- c := sessionClaims{UID: uid, Role: role, Exp: time.Now().Add(dur).Unix()}
- payload, _ := json.Marshal(c)
- mac := hmac.New(sha256.New, []byte(config.Cfg.Secret))
- mac.Write(payload)
- return base64.RawURLEncoding.EncodeToString(payload) + "." + hex.EncodeToString(mac.Sum(nil))
-}
-
-// VerifySession 校验会话,返回 (uid, role, ok)
-func VerifySession(val string) (int64, string, bool) {
- parts := strings.SplitN(val, ".", 2)
- if len(parts) != 2 {
- return 0, "", false
- }
- payload, err := base64.RawURLEncoding.DecodeString(parts[0])
- if err != nil {
- return 0, "", false
- }
- mac := hmac.New(sha256.New, []byte(config.Cfg.Secret))
- mac.Write(payload)
- if !hmac.Equal([]byte(hex.EncodeToString(mac.Sum(nil))), []byte(parts[1])) {
- return 0, "", false
- }
- var c sessionClaims
- if err := json.Unmarshal(payload, &c); err != nil || c.Exp < time.Now().Unix() {
- return 0, "", false
- }
- return c.UID, c.Role, true
-}
-
-// ---------- 文本与请求 ----------
-
-var tagRe = regexp.MustCompile(`<[^>]*>`)
-var ctrlRe = regexp.MustCompile(`[\x00-\x08\x0b\x0c\x0e-\x1f]`)
-
-// StripHTML 移除标签与控制字符(保留换行)
-func StripHTML(s string) string {
- s = ctrlRe.ReplaceAllString(s, "")
- s = strings.ReplaceAll(s, "\r\n", "\n")
- return strings.TrimSpace(tagRe.ReplaceAllString(s, ""))
-}
-
-// ClientIP 提取真实客户端 IP(支持反代)
-func ClientIP(r *http.Request) string {
- if v := r.Header.Get("X-Forwarded-For"); v != "" {
- return strings.TrimSpace(strings.Split(v, ",")[0])
- }
- if v := r.Header.Get("X-Real-IP"); v != "" {
- return v
- }
- host := r.RemoteAddr
- if i := strings.LastIndex(host, ":"); i > 0 {
- host = host[:i]
- }
- return host
-}
-
-// Slug 生成 URL 友好的短标识
-func Slug(s string) string {
- s = strings.ToLower(strings.TrimSpace(s))
- var b strings.Builder
- lastDash := false
- for _, r := range s {
- switch {
- case r >= 'a' && r <= 'z', r >= '0' && r <= '9':
- b.WriteRune(r)
- lastDash = false
- case r > 127: // 中文等非 ASCII 统一丢弃
- lastDash = true
- default:
- if !lastDash && b.Len() > 0 {
- b.WriteByte('-')
- lastDash = true
- }
- }
- }
- out := strings.Trim(b.String(), "-")
- if out == "" {
- out = "plugin-" + RandomHex(3)
- }
- if len(out) > 48 {
- out = out[:48]
- }
- return out
-}
-
-// ValidEmail 简单邮箱格式校验
-func ValidEmail(s string) bool {
- i := strings.LastIndex(s, "@")
- if i <= 0 || i == len(s)-1 {
- return false
- }
- return strings.Contains(s[i+1:], ".") && !strings.ContainsAny(s, " \t\r\n")
-}
diff --git a/cloud/main.go b/cloud/main.go
deleted file mode 100644
index 9eea14b..0000000
--- a/cloud/main.go
+++ /dev/null
@@ -1,128 +0,0 @@
-// ClearLove 插件社区 · 云端服务 —— 单二进制部署
-// 入口:加载配置 -> 连接 SQLite -> 建表 -> 解析模板 -> 注册路由 -> 启动 HTTP 服务
-// 域名:https://clearlove.kazx.top(通过 Nginx 反代到本服务)
-package main
-
-import (
- "embed"
- "fmt"
- "io/fs"
- "log"
- "net/http"
- "os"
- "path/filepath"
-
- "clearlove-cloud/internal/config"
- "clearlove-cloud/internal/database"
- "clearlove-cloud/internal/handlers"
- "clearlove-cloud/internal/middleware"
-)
-
-//go:embed all:web/templates all:web/static
-var webFS embed.FS
-
-func main() {
- config.Load()
- if err := ensureDirs(); err != nil {
- log.Fatalf("目录初始化失败: %v\n提示:可通过 CLOUD_DATA_DIR / CLOUD_UPLOAD_DIR 指定可写目录", err)
- }
- if err := database.Connect(); err != nil {
- log.Fatalf("数据库连接失败: %v", err)
- }
- if err := database.Migrate(); err != nil {
- log.Fatalf("数据库迁移失败: %v", err)
- }
-
- handlers.SetupTemplates(webFS)
- staticFS, _ := fs.Sub(webFS, "web/static")
- handlers.StaticFS = staticFS
-
- addr := ":16869"
- if p := os.Getenv("PORT"); p != "" {
- addr = ":" + p
- } else if p := os.Getenv("CLOUD_PORT"); p != "" {
- addr = ":" + p
- }
- log.Printf("ClearLove 插件社区 %s | 数据目录: %s | 对外地址: %s", config.Version, config.Cfg.DataDir, config.Cfg.BaseURL)
- log.Printf("服务已启动: http://localhost%s (管理后台: /admin,开发者: /dev/login)", addr)
- if err := http.ListenAndServe(addr, middleware.Use(buildMux())); err != nil {
- log.Fatalf("HTTP 服务启动失败: %v", err)
- }
-}
-
-// ensureDirs 创建并校验运行时目录可写
-func ensureDirs() error {
- dirs := []string{
- config.Cfg.DataDir,
- config.Cfg.UploadDir,
- filepath.Join(config.Cfg.UploadDir, "packages"),
- }
- for _, d := range dirs {
- if err := os.MkdirAll(d, 0o755); err != nil {
- return err
- }
- probe := filepath.Join(d, ".write_test")
- if err := os.WriteFile(probe, []byte("ok"), 0o644); err != nil {
- return fmt.Errorf("%s 不可写: %w", d, err)
- }
- _ = os.Remove(probe)
- }
- return nil
-}
-
-// buildMux 注册全部路由
-func buildMux() *http.ServeMux {
- mux := http.NewServeMux()
-
- // 静态资源
- mux.Handle("GET /static/", http.StripPrefix("/static/", http.FileServer(http.FS(handlers.StaticFS))))
- mux.HandleFunc("GET /favicon.svg", handlers.Favicon)
-
- // 官网
- mux.HandleFunc("GET /{$}", handlers.Home)
- mux.HandleFunc("GET /plugins", handlers.Plugins)
- mux.HandleFunc("GET /plugins/{slug}", handlers.PluginDetail)
- mux.HandleFunc("GET /docs", handlers.Docs)
- mux.HandleFunc("GET /download", handlers.DownloadSite)
- mux.HandleFunc("GET /dl/{slug}", handlers.DownloadLatest)
-
- // 开发者
- mux.HandleFunc("GET /dev/register", handlers.DevRegisterPage)
- mux.HandleFunc("POST /dev/register", handlers.DevRegisterSubmit)
- mux.HandleFunc("GET /dev/login", handlers.DevLoginPage)
- mux.HandleFunc("POST /dev/login", handlers.DevLoginSubmit)
- mux.HandleFunc("GET /dev/logout", handlers.DevLogout)
- mux.HandleFunc("POST /api/dev/send-code", handlers.DevSendCode)
- mux.HandleFunc("GET /dev", handlers.DevDash)
- mux.HandleFunc("GET /dev/plugins", handlers.DevMyPlugins)
- mux.HandleFunc("GET /dev/plugins/new", handlers.DevNewPage)
- mux.HandleFunc("POST /dev/plugins/new", handlers.DevNewSubmit)
- mux.HandleFunc("GET /dev/plugins/{slug}", handlers.DevPluginPage)
- mux.HandleFunc("POST /dev/plugins/{slug}/update", handlers.DevPluginUpdate)
- mux.HandleFunc("POST /dev/plugins/{slug}/version", handlers.DevPluginVersion)
- mux.HandleFunc("POST /dev/plugins/{slug}/submit", handlers.DevPluginSubmit)
- mux.HandleFunc("POST /dev/plugins/{slug}/delete", handlers.DevPluginDelete)
- mux.HandleFunc("GET /dev/profile", handlers.DevProfile)
- mux.HandleFunc("POST /dev/profile", handlers.DevProfileSave)
-
- // 插件市场开放 API(供表白墙后台插件商店调用)
- mux.HandleFunc("GET /api/v1/store/plugins", handlers.APIListPlugins)
- mux.HandleFunc("GET /api/v1/store/plugins/{slug}", handlers.APIPluginDetail)
- mux.HandleFunc("GET /api/v1/store/download/{slug}/{version}", handlers.APIDownload)
- mux.HandleFunc("POST /api/v1/store/report", handlers.APIReportInstall)
-
- // 管理后台
- mux.HandleFunc("GET /admin/login", handlers.AdminLoginPage)
- mux.HandleFunc("POST /admin/login", handlers.AdminLoginSubmit)
- mux.HandleFunc("GET /admin/logout", handlers.AdminLogout)
- mux.HandleFunc("GET /admin", handlers.AdminDash)
- mux.HandleFunc("GET /admin/plugins", handlers.AdminPlugins)
- mux.HandleFunc("POST /admin/plugins/status", handlers.AdminPluginStatus)
- mux.HandleFunc("GET /admin/developers", handlers.AdminDevelopers)
- mux.HandleFunc("POST /admin/developers/status", handlers.AdminDeveloperStatus)
- mux.HandleFunc("GET /admin/settings", handlers.AdminSettings)
- mux.HandleFunc("POST /admin/settings", handlers.AdminSettingsSave)
- mux.HandleFunc("POST /admin/password", handlers.AdminPassword)
-
- return mux
-}
diff --git a/cloud/web/static/site.css b/cloud/web/static/site.css
deleted file mode 100644
index c6535af..0000000
--- a/cloud/web/static/site.css
+++ /dev/null
@@ -1,497 +0,0 @@
-/* ===================================================================
- ClearLove 插件社区 · 云端官网样式
- 清新现代绿色主题;动画仅使用 淡入 / 缩放 / 旋转 / 渐变流动,
- 不使用任何「元素上浮」效果。
- =================================================================== */
-:root {
- --g-1: #10b981;
- --g-2: #34d399;
- --g-3: #059669;
- --cyan: #22d3ee;
- --bg: #f2faf6;
- --card: #ffffff;
- --text: #0f1f1a;
- --muted: #6b8079;
- --border: #e2efe9;
- --soft: rgba(16, 185, 129, .1);
- --radius: 16px;
- --radius-sm: 10px;
- --shadow: 0 2px 18px rgba(15, 60, 45, .06);
- --font: -apple-system, BlinkMacSystemFont, "PingFang SC", "Microsoft YaHei", "Segoe UI", "Noto Sans CJK SC", sans-serif;
-}
-
-* { box-sizing: border-box; }
-html, body { margin: 0; padding: 0; }
-[hidden] { display: none !important; }
-body {
- font-family: var(--font);
- color: var(--text);
- background: var(--bg);
- line-height: 1.65;
- -webkit-font-smoothing: antialiased;
-}
-a { color: inherit; text-decoration: none; }
-img, svg { display: block; max-width: 100%; }
-button { font-family: inherit; }
-code { font-family: ui-monospace, Consolas, Menlo, monospace; font-size: 12.5px; background: var(--soft); padding: 1px 6px; border-radius: 6px; }
-.muted { color: var(--muted); }
-.small { font-size: 12.5px; }
-.mono { font-family: ui-monospace, Consolas, Menlo, monospace; font-size: 11.5px; word-break: break-all; }
-.empty { color: var(--muted); text-align: center; padding: 18px 0; }
-.pre-wrap { white-space: pre-wrap; word-break: break-word; }
-.hr { border: 0; border-top: 1px solid var(--border); margin: 16px 0; }
-
-/* ---------------- 背景装饰(纯缩放/流动,无位移) ---------------- */
-.bg-grid {
- position: fixed; inset: 0; z-index: -3; pointer-events: none;
- background-image:
- linear-gradient(to right, rgba(16, 185, 129, .06) 1px, transparent 1px),
- linear-gradient(to bottom, rgba(16, 185, 129, .06) 1px, transparent 1px);
- background-size: 48px 48px;
- mask-image: radial-gradient(circle at 50% 0%, #000 0%, transparent 72%);
- -webkit-mask-image: radial-gradient(circle at 50% 0%, #000 0%, transparent 72%);
-}
-.glow {
- position: fixed; z-index: -2; pointer-events: none;
- width: 46vw; height: 46vw; border-radius: 50%;
- filter: blur(90px); opacity: .5;
- animation: glowPulse 12s ease-in-out infinite;
-}
-.glow-a { top: -14vw; left: -10vw; background: radial-gradient(circle, rgba(52, 211, 153, .55), transparent 68%); }
-.glow-b { bottom: -18vw; right: -12vw; background: radial-gradient(circle, rgba(34, 211, 238, .38), transparent 68%); animation-delay: 4s; }
-@keyframes glowPulse {
- 0%, 100% { transform: scale(1); opacity: .42; }
- 50% { transform: scale(1.18); opacity: .62; }
-}
-
-/* ---------------- 顶部导航 ---------------- */
-.topbar {
- position: sticky; top: 0; z-index: 30;
- display: flex; align-items: center; justify-content: space-between;
- gap: 12px; padding: 12px 22px;
- background: rgba(255, 255, 255, .82);
- backdrop-filter: blur(12px);
- border-bottom: 1px solid var(--border);
-}
-.brand { display: inline-flex; align-items: center; gap: 9px; font-weight: 700; }
-.brand-mark {
- width: 32px; height: 32px; border-radius: 10px; display: inline-flex;
- align-items: center; justify-content: center; color: #fff;
- background: linear-gradient(135deg, var(--g-1), var(--cyan));
- box-shadow: 0 4px 14px rgba(16, 185, 129, .35);
-}
-.brand-mark.sm { width: 26px; height: 26px; border-radius: 8px; }
-.brand-text { font-size: 16px; letter-spacing: .3px; }
-.topnav { display: flex; align-items: center; gap: 16px; font-size: 14px; }
-.topnav a { color: #3d5850; transition: color .18s; }
-.topnav a:hover { color: var(--g-1); }
-.topnav a.active { color: var(--g-1); font-weight: 600; }
-.topnav .nav-plain { padding: 0 2px; }
-
-/* ---------------- 按钮 ---------------- */
-.btn {
- display: inline-flex; align-items: center; justify-content: center; gap: 7px;
- padding: 9px 18px; border-radius: var(--radius-sm); border: 1px solid transparent;
- font-size: 14px; cursor: pointer; transition: box-shadow .2s, background .2s, color .2s, border-color .2s, filter .2s;
- background: var(--border); color: var(--text);
-}
-.btn:hover { filter: brightness(.98); }
-.btn-primary {
- background: linear-gradient(135deg, var(--g-1), var(--g-3));
- color: #fff; box-shadow: 0 6px 20px rgba(16, 185, 129, .28);
-}
-.btn-primary:hover { box-shadow: 0 8px 26px rgba(16, 185, 129, .4); }
-.btn-ghost { background: #fff; border-color: var(--border); color: var(--text); }
-.btn-ghost:hover { border-color: var(--g-2); color: var(--g-3); }
-.btn-danger-ghost { background: #fff; border-color: #f3d4d9; color: #d84a5f; }
-.btn-block { width: 100%; }
-.btn-lg { padding: 12px 24px; font-size: 15px; border-radius: 12px; }
-.btn[disabled] { opacity: .55; cursor: not-allowed; }
-.mini-btn {
- display: inline-flex; align-items: center; padding: 4px 10px; font-size: 12px;
- border-radius: 8px; border: 1px solid var(--border); background: #fff;
- color: var(--text); cursor: pointer; transition: border-color .18s, color .18s;
-}
-.mini-btn:hover { border-color: var(--g-2); color: var(--g-3); }
-.mini-btn.danger { color: #d84a5f; border-color: #f3d4d9; }
-.mini-link { color: var(--g-3); font-size: 13px; }
-.mini-link:hover { color: var(--g-1); }
-.icon-btn { background: none; border: 0; font-size: 20px; cursor: pointer; color: inherit; padding: 4px; }
-
-/* ---------------- 首页 Hero ---------------- */
-.page { max-width: 1140px; margin: 0 auto; padding: 0 20px 60px; }
-.hero {
- display: grid; grid-template-columns: 1.15fr .85fr; gap: 30px;
- align-items: center; padding: 64px 0 52px;
-}
-.hero-inner { animation: fadeIn .7s ease both; }
-.pill {
- display: inline-flex; align-items: center; gap: 8px;
- padding: 6px 14px; border-radius: 999px; font-size: 13px;
- background: var(--soft); color: var(--g-3); border: 1px solid rgba(16, 185, 129, .22);
-}
-.pill .dot {
- width: 7px; height: 7px; border-radius: 50%; background: var(--g-1);
- animation: dotPulse 1.8s ease-in-out infinite;
-}
-@keyframes dotPulse { 0%, 100% { opacity: 1; box-shadow: 0 0 0 0 rgba(16,185,129,.5); } 50% { opacity: .7; box-shadow: 0 0 0 6px rgba(16,185,129,0); } }
-.hero-title { font-size: 42px; line-height: 1.28; margin: 18px 0 14px; letter-spacing: -.5px; }
-.hero-title .grad {
- background: linear-gradient(100deg, var(--g-1), var(--cyan), var(--g-2), var(--g-1));
- background-size: 220% 100%;
- -webkit-background-clip: text; background-clip: text; color: transparent;
- animation: shimmer 7s linear infinite;
-}
-@keyframes shimmer { from { background-position: 0 0; } to { background-position: 220% 0; } }
-.hero-sub { font-size: 16px; color: var(--muted); max-width: 560px; margin: 0 0 26px; }
-.hero-actions { display: flex; flex-wrap: wrap; gap: 12px; }
-.hero-actions.center { justify-content: center; }
-.hero-stats { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 12px; margin-top: 34px; max-width: 560px; }
-.hstat {
- background: #fff; border: 1px solid var(--border); border-radius: var(--radius-sm);
- padding: 12px 10px; text-align: center; box-shadow: var(--shadow);
- animation: fadeIn .8s ease both;
-}
-.hstat b { display: block; font-size: 22px; color: var(--g-3); }
-.hstat span { font-size: 12px; color: var(--muted); }
-
-.hero-visual { display: flex; align-items: center; justify-content: center; }
-
-/* ---------------- 区块 ---------------- */
-.section { padding: 38px 0; }
-.section-head { margin-bottom: 22px; }
-.section-head h2 { font-size: 26px; margin: 0 0 6px; letter-spacing: -.3px; }
-.section-head.row { display: flex; align-items: flex-end; justify-content: space-between; gap: 14px; }
-.section-head p { margin: 0; }
-.features { display: grid; grid-template-columns: repeat(auto-fit, minmax(268px, 1fr)); gap: 16px; }
-.feature {
- background: #fff; border: 1px solid var(--border); border-radius: var(--radius);
- padding: 20px; box-shadow: var(--shadow);
- transition: border-color .22s, box-shadow .22s;
-}
-.feature:hover { border-color: var(--g-2); box-shadow: 0 6px 26px rgba(16, 185, 129, .14); }
-.feature-icon {
- width: 42px; height: 42px; border-radius: 12px; display: inline-flex;
- align-items: center; justify-content: center; color: var(--g-3);
- background: var(--soft); transition: transform .22s;
-}
-.feature:hover .feature-icon { transform: scale(1.1); }
-.feature h3 { margin: 14px 0 6px; font-size: 16px; }
-.feature p { margin: 0; color: var(--muted); font-size: 13.5px; }
-
-.cta-box {
- background: linear-gradient(135deg, rgba(16, 185, 129, .1), rgba(34, 211, 238, .12));
- border: 1px solid rgba(16, 185, 129, .2); border-radius: 22px;
- padding: 40px 26px; text-align: center;
-}
-.cta-box h2 { margin: 0 0 8px; font-size: 24px; }
-.cta-box p { margin: 0 0 22px; color: var(--muted); }
-
-/* ---------------- 插件卡片 ---------------- */
-.grid-cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(288px, 1fr)); gap: 16px; }
-.pcard {
- display: flex; flex-direction: column; background: #fff;
- border: 1px solid var(--border); border-radius: var(--radius);
- padding: 18px; box-shadow: var(--shadow);
- transition: border-color .22s, box-shadow .22s;
-}
-.pcard:hover { border-color: var(--g-2); box-shadow: 0 6px 26px rgba(16, 185, 129, .15); }
-.pcard-head { display: flex; align-items: center; gap: 12px; }
-.pcard-icon {
- width: 44px; height: 44px; flex: none; border-radius: 13px;
- display: inline-flex; align-items: center; justify-content: center;
- font-size: 19px; font-weight: 700; color: #fff;
- background: linear-gradient(135deg, var(--g-1), var(--cyan));
-}
-.pcard-icon.lg { width: 58px; height: 58px; font-size: 24px; border-radius: 16px; }
-.pcard-title h3 { margin: 0; font-size: 16px; }
-.pcard-title h3 a:hover { color: var(--g-3); }
-.pcard-meta { margin: 3px 0 0; font-size: 12.5px; color: var(--muted); display: flex; align-items: center; gap: 7px; }
-.pcard-desc { flex: 1; margin: 12px 0; color: #465b53; font-size: 13.5px; min-height: 40px; }
-.pcard-foot {
- display: flex; align-items: center; gap: 14px;
- padding-top: 12px; border-top: 1px solid var(--border); font-size: 12.5px; color: var(--muted);
-}
-.stat-inline { display: inline-flex; align-items: center; gap: 4px; }
-.pcard-foot .mini-link { margin-left: auto; }
-.tag-soft {
- display: inline-block; padding: 1px 9px; border-radius: 999px;
- background: var(--soft); color: var(--g-3); font-size: 12px;
-}
-.tag { display: inline-block; padding: 2px 9px; border-radius: 999px; font-size: 12px; background: var(--border); color: #47605a; }
-.tag.ok { background: #e5f8ef; color: #10805a; }
-.tag.warn { background: #fdf3e3; color: #c47a1d; }
-.tag.danger { background: #fdeef1; color: #d84a5f; }
-.badge {
- display: inline-flex; align-items: center; justify-content: center;
- min-width: 18px; height: 18px; padding: 0 5px; margin-left: 6px;
- border-radius: 999px; background: #ef5b74; color: #fff; font-size: 11px;
-}
-
-/* ---------------- 列表 / 筛选 ---------------- */
-.page-head { padding: 30px 0 18px; }
-.page-head.row { display: flex; align-items: center; justify-content: space-between; gap: 14px; flex-wrap: wrap; }
-.page-title { font-size: 26px; margin: 0 0 4px; letter-spacing: -.3px; }
-.filter-bar {
- display: flex; flex-wrap: wrap; gap: 10px; align-items: center;
- background: #fff; border: 1px solid var(--border); border-radius: var(--radius);
- padding: 12px 14px; margin-bottom: 20px; box-shadow: var(--shadow);
-}
-.filter-bar input[type=search] { flex: 1; min-width: 180px; }
-.filter-bar input, .filter-bar select { margin: 0; }
-.pager { display: flex; align-items: center; justify-content: center; gap: 14px; padding: 24px 0 0; }
-.chip-row { display: flex; gap: 8px; flex-wrap: wrap; }
-.chip {
- padding: 5px 14px; border-radius: 999px; font-size: 13px;
- background: #fff; border: 1px solid var(--border); color: var(--muted);
- transition: background .18s, color .18s, border-color .18s;
-}
-.chip.active { background: linear-gradient(135deg, var(--g-1), var(--g-3)); color: #fff; border-color: transparent; }
-
-/* ---------------- 详情 ---------------- */
-.card {
- background: var(--card); border: 1px solid var(--border); border-radius: var(--radius);
- box-shadow: var(--shadow); padding: 20px; margin-bottom: 16px;
-}
-.card h3 { margin: 0 0 12px; font-size: 16px; }
-.card h3:not(:first-child) { margin-top: 20px; }
-.detail-head { display: flex; align-items: center; justify-content: space-between; gap: 18px; flex-wrap: wrap; padding: 24px; }
-.detail-title { display: flex; align-items: center; gap: 16px; }
-.detail-title h1 { margin: 0 0 4px; font-size: 25px; }
-.detail-title p { margin: 0; font-size: 13.5px; }
-.detail-grid { display: grid; grid-template-columns: 1fr 340px; gap: 16px; align-items: start; }
-.side-card { position: sticky; top: 76px; }
-.version-list { list-style: none; margin: 0; padding: 0; }
-.version-list li { padding: 11px 0; border-bottom: 1px dashed var(--border); }
-.version-list li:last-child { border-bottom: 0; }
-.v-row { display: flex; align-items: center; justify-content: space-between; gap: 10px; }
-.steps-list { margin: 0; padding-left: 20px; color: #465b53; font-size: 14px; }
-.steps-list li { margin: 7px 0; }
-.code {
- background: #0f2a22; color: #b9f2d8; border-radius: var(--radius-sm);
- padding: 16px; font-size: 12.5px; line-height: 1.7; overflow-x: auto;
- font-family: ui-monospace, Consolas, Menlo, monospace; white-space: pre;
-}
-.doc-h { font-size: 17px; margin: 0 0 10px; }
-
-/* ---------------- 表格 ---------------- */
-.table-card { padding: 0; overflow: hidden; }
-.table-scroll { overflow-x: auto; }
-.table { width: 100%; border-collapse: collapse; font-size: 13.5px; }
-.table th, .table td { padding: 11px 14px; text-align: left; border-bottom: 1px solid var(--border); vertical-align: top; }
-.table th { background: #f6fbf9; font-weight: 600; white-space: nowrap; }
-.table tbody tr { transition: background .18s; }
-.table tbody tr:hover { background: #f7fdfa; }
-.cell-content { max-width: 240px; word-break: break-word; }
-.ops { display: flex; flex-wrap: wrap; gap: 6px; }
-.inline-form { display: inline-flex; align-items: center; gap: 6px; margin: 0; }
-.kv-table { width: 100%; border-collapse: collapse; font-size: 14px; }
-.kv-table th { text-align: left; width: 150px; color: var(--muted); font-weight: 500; padding: 9px 0; }
-.kv-table td { padding: 9px 0; }
-
-/* ---------------- 表单 ---------------- */
-input[type=text], input[type=password], input[type=email], input[type=search],
-input[type=file], select, textarea {
- width: 100%; margin-top: 5px; padding: 10px 12px; font-size: 14px;
- border: 1px solid var(--border); border-radius: var(--radius-sm);
- background: #fff; color: var(--text); outline: none; font-family: inherit;
- transition: border-color .18s, box-shadow .18s;
-}
-input:focus, select:focus, textarea:focus { border-color: var(--g-2); box-shadow: 0 0 0 3px var(--soft); }
-input[disabled] { background: #f6f8f7; color: var(--muted); }
-textarea { resize: vertical; }
-label { display: block; margin: 12px 0; font-size: 14px; color: #465b53; }
-.check-row { display: flex; align-items: center; gap: 8px; }
-.check-row input { width: auto; margin: 0; }
-.form-card { padding: 22px; }
-.grid2 { display: grid; grid-template-columns: 1fr 1fr; gap: 0 14px; }
-.grid-2col { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; align-items: start; }
-.btn-row { display: flex; gap: 10px; margin-top: 16px; }
-.form-err { color: #c0392b; font-size: 13.5px; background: #fdeef1; padding: 9px 14px; border-radius: var(--radius-sm); }
-.form-ok { color: #10805a; font-size: 13.5px; background: #e5f8ef; padding: 9px 14px; border-radius: var(--radius-sm); }
-
-/* ---------------- 登录 / 注册 ---------------- */
-.auth-wrap { min-height: 100vh; display: flex; align-items: center; justify-content: center; padding: 30px 16px; }
-.auth { max-width: 420px; width: 100%; padding: 28px; margin: 40px auto; animation: scaleIn .5s ease both; }
-.auth-title { margin: 0 0 4px; font-size: 21px; text-align: center; }
-.auth-sub { text-align: center; margin: 0 0 8px; }
-.auth-alt { text-align: center; font-size: 13.5px; color: var(--muted); margin: 14px 0 0; }
-.auth-alt a { color: var(--g-3); }
-.code-row { display: flex; gap: 8px; }
-.code-row input { flex: 1; }
-
-/* ---------------- 控制台(开发者 / 管理员) ---------------- */
-.console-body { background: var(--bg); }
-.console-wrap { max-width: 1140px; margin: 0 auto; padding: 22px 20px 60px; display: grid; grid-template-columns: 190px 1fr; gap: 20px; align-items: start; }
-.side { position: sticky; top: 76px; }
-.side-nav { display: flex; flex-direction: column; gap: 3px; }
-.side-nav a {
- padding: 9px 14px; border-radius: var(--radius-sm); font-size: 14px;
- color: #465b53; transition: background .18s, color .18s;
-}
-.side-nav a:hover { background: #fff; color: var(--g-3); }
-.side-nav a.active { background: linear-gradient(135deg, var(--g-1), var(--g-3)); color: #fff; }
-.console-main { min-width: 0; }
-.stat-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(170px, 1fr)); gap: 14px; margin-bottom: 16px; }
-.stat { display: flex; flex-direction: column; align-items: center; padding: 20px 12px; margin: 0; }
-.stat-num { font-size: 25px; font-weight: 700; color: var(--g-3); }
-.stat-num.warn { color: #e08a2e; }
-.stat span { font-size: 13px; color: var(--muted); }
-
-/* 管理员 Shell */
-.sidebar {
- position: fixed; top: 0; left: 0; bottom: 0; width: 216px; z-index: 50;
- background: #0f2a22; color: #cfe6dc; display: flex; flex-direction: column;
- transition: transform .22s ease;
-}
-.side-brand { display: flex; align-items: center; gap: 9px; padding: 16px 18px; font-weight: 700; color: #fff; border-bottom: 1px solid rgba(255,255,255,.08); }
-.side-nav.dark a { color: #cfe6dc; }
-.side-nav.dark a:hover { background: rgba(255,255,255,.08); color: #fff; }
-.side-nav.dark a.active { background: linear-gradient(135deg, var(--g-1), var(--cyan)); color: #05201a; }
-.side-foot { padding: 12px; display: flex; justify-content: space-between; font-size: 13px; border-top: 1px solid rgba(255,255,255,.08); }
-.side-foot a { color: #cfe6dc; }
-.admin-top {
- position: sticky; top: 0; z-index: 40; margin-left: 216px;
- display: flex; align-items: center; gap: 12px; padding: 12px 20px;
- background: rgba(255,255,255,.86); backdrop-filter: blur(10px);
- border-bottom: 1px solid var(--border);
-}
-.admin-title { font-weight: 600; font-size: 14px; }
-.admin-main { margin-left: 216px; padding: 22px; max-width: 1180px; }
-
-/* ---------------- 柱状图 ---------------- */
-.chart { display: flex; align-items: flex-end; gap: 12px; height: 190px; padding-top: 10px; }
-.chart-col { flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: flex-end; height: 100%; gap: 6px; }
-.chart-bar-wrap { flex: 1; width: 100%; display: flex; align-items: flex-end; justify-content: center; }
-.chart-bar {
- width: 60%; min-height: 4px; border-radius: 8px 8px 4px 4px;
- background: linear-gradient(180deg, var(--g-2), var(--g-1));
- transition: height .6s cubic-bezier(.2, .8, .3, 1), filter .2s;
-}
-.chart-col:hover .chart-bar { filter: brightness(1.06); }
-.chart-label { font-size: 11.5px; color: var(--muted); }
-.chart-sub { font-size: 10.5px; color: var(--muted); }
-.chart-val { font-size: 12px; font-weight: 600; color: var(--g-3); }
-
-/* ---------------- 页脚 ---------------- */
-.footer { border-top: 1px solid var(--border); background: rgba(255,255,255,.7); }
-.footer-inner { max-width: 1140px; margin: 0 auto; padding: 24px 20px 34px; text-align: center; }
-.footer-brand { margin: 0 0 4px; font-size: 13.5px; color: #465b53; }
-
-/* ---------------- 官网首页:产品预览 / 快速开始 / 架构 / FAQ ---------------- */
-.link-more { color: var(--g-3); font-size: 14px; align-self: center; transition: color .18s; }
-.link-more:hover { color: var(--g-1); }
-
-.hero-meta { list-style: none; display: flex; flex-wrap: wrap; gap: 8px 18px; margin: 22px 0 0; padding: 0; font-size: 13px; color: var(--muted); }
-.hero-meta li { display: inline-flex; align-items: center; gap: 7px; }
-.hero-meta li::before { content: ''; width: 6px; height: 6px; border-radius: 50%; background: var(--g-2); }
-
-/* 产品预览(纯 CSS 模拟界面,不依赖图片) */
-.mock {
- width: 100%; max-width: 372px; background: #fff; border: 1px solid var(--border);
- border-radius: 18px; overflow: hidden; box-shadow: 0 18px 50px rgba(15, 60, 45, .13);
- animation: scaleIn .8s ease both;
-}
-.mock-bar { display: flex; align-items: center; gap: 10px; padding: 10px 14px; background: #f7fcfa; border-bottom: 1px solid var(--border); }
-.mock-dots { display: inline-flex; gap: 5px; }
-.mock-dots i { width: 9px; height: 9px; border-radius: 50%; background: #dfeae5; }
-.mock-dots i:nth-child(1) { background: #ffb3c1; }
-.mock-dots i:nth-child(2) { background: #ffd9a0; }
-.mock-dots i:nth-child(3) { background: #a8e6c8; }
-.mock-url { font-size: 11.5px; color: var(--muted); }
-.mock-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; padding: 14px; }
-.mock-card { border: 1px solid var(--border); border-radius: 12px; padding: 10px; background: #fff; box-shadow: var(--shadow); }
-.mock-card.alt { background: #f9fdfb; }
-.mock-row { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; }
-.mock-avatar { width: 22px; height: 22px; border-radius: 50%; background: linear-gradient(135deg, var(--g-1), var(--cyan)); }
-.mock-avatar.g { background: linear-gradient(135deg, var(--cyan), var(--g-2)); }
-.mock-line { height: 7px; border-radius: 4px; background: #e8f2ee; }
-.mock-line.w50 { width: 50%; }
-.mock-line.w40 { width: 40%; }
-.mock-text { margin: 0 0 8px; font-size: 11.5px; line-height: 1.55; color: #4a615a; }
-.mock-media { height: 60px; margin-bottom: 8px; border-radius: 9px; background: linear-gradient(135deg, rgba(16, 185, 129, .18), rgba(34, 211, 238, .24)); }
-.mock-foot { font-size: 11px; color: var(--muted); }
-.mock-heart { color: var(--g-1); }
-
-/* 快速开始 */
-.section-head.center { text-align: center; }
-.quickstart { display: grid; grid-template-columns: 1.05fr .95fr; gap: 18px; align-items: start; }
-.cmd-box { background: #0e2a22; border-radius: var(--radius); overflow: hidden; box-shadow: 0 12px 34px rgba(9, 40, 31, .22); }
-.cmd-head { display: flex; align-items: center; justify-content: space-between; padding: 9px 14px; border-bottom: 1px solid rgba(255, 255, 255, .08); color: #8fd9bd; font-size: 12.5px; }
-.cmd-body { margin: 0; padding: 16px; color: #b9f2d8; font-size: 12.5px; line-height: 1.85; font-family: ui-monospace, Consolas, Menlo, monospace; overflow-x: auto; }
-.copy-btn {
- border: 1px solid rgba(255, 255, 255, .18); background: rgba(255, 255, 255, .06); color: #cdeee0;
- font-size: 12px; padding: 3px 10px; border-radius: 7px; cursor: pointer;
- transition: background .18s, border-color .18s;
-}
-.copy-btn:hover { background: rgba(255, 255, 255, .14); border-color: rgba(255, 255, 255, .32); }
-.step-cards { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 12px; }
-.step-card { display: flex; gap: 12px; align-items: flex-start; background: #fff; border: 1px solid var(--border); border-radius: var(--radius); padding: 14px 16px; box-shadow: var(--shadow); transition: border-color .2s; }
-.step-card:hover { border-color: var(--g-2); }
-.step-num {
- flex: none; width: 26px; height: 26px; border-radius: 9px; display: inline-flex;
- align-items: center; justify-content: center; font-size: 13px; font-weight: 700; color: #fff;
- background: linear-gradient(135deg, var(--g-1), var(--cyan));
-}
-.step-card h3 { margin: 2px 0 4px; font-size: 15px; }
-.step-card p { margin: 0; font-size: 13px; color: var(--muted); }
-
-/* 架构 */
-.tech-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); gap: 16px; }
-.tech-item { background: #fff; border: 1px solid var(--border); border-radius: var(--radius); padding: 18px; box-shadow: var(--shadow); transition: border-color .2s, box-shadow .2s; }
-.tech-item:hover { border-color: var(--g-2); box-shadow: 0 6px 26px rgba(16, 185, 129, .14); }
-.tech-item h3 { margin: 0 0 8px; font-size: 15px; color: var(--g-3); }
-.tech-item p { margin: 0; font-size: 13.5px; color: var(--muted); }
-.tech-badges { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 18px; justify-content: center; }
-.tbadge { padding: 5px 13px; border-radius: 999px; font-size: 12.5px; background: #fff; border: 1px solid var(--border); color: #46605a; }
-
-/* 生态入口 */
-.eco-links { display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 14px; margin-top: 18px; }
-.eco-link { display: flex; flex-direction: column; gap: 4px; background: #fff; border: 1px solid var(--border); border-radius: var(--radius); padding: 16px 18px; box-shadow: var(--shadow); transition: border-color .2s; }
-.eco-link:hover { border-color: var(--g-2); }
-.eco-link b { font-size: 15px; }
-.eco-link span { font-size: 13px; color: var(--muted); }
-
-/* FAQ */
-.faq { display: flex; flex-direction: column; gap: 10px; max-width: 860px; margin: 0 auto; }
-.faq-item { background: #fff; border: 1px solid var(--border); border-radius: var(--radius); padding: 2px 18px; box-shadow: var(--shadow); }
-.faq-item summary { cursor: pointer; padding: 14px 0; font-size: 15px; font-weight: 600; list-style: none; display: flex; align-items: center; justify-content: space-between; gap: 12px; }
-.faq-item summary::-webkit-details-marker { display: none; }
-.faq-item summary::after { content: '+'; color: var(--g-1); font-size: 19px; line-height: 1; }
-.faq-item[open] summary::after { content: '−'; }
-.faq-item p { margin: 0 0 16px; font-size: 13.5px; color: var(--muted); }
-
-/* ---------------- 动画 ---------------- */
-@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
-@keyframes scaleIn { from { opacity: 0; transform: scale(.96); } to { opacity: 1; transform: scale(1); } }
-@media (prefers-reduced-motion: reduce) {
- .glow, .pill .dot, .hero-title .grad, .auth, .mock { animation: none !important; }
-}
-
-/* ---------------- 响应式 ---------------- */
-@media (max-width: 980px) {
- .hero { grid-template-columns: 1fr; padding: 40px 0 30px; }
- .hero-visual { display: none; }
- .quickstart { grid-template-columns: 1fr; }
- .detail-grid { grid-template-columns: 1fr; }
- .side-card { position: static; }
- .console-wrap { grid-template-columns: 1fr; }
- .side { position: static; }
- .side-nav { flex-direction: row; flex-wrap: wrap; }
- .grid-2col { grid-template-columns: 1fr; }
- .grid2 { grid-template-columns: 1fr; }
- .sidebar { transform: translateX(-100%); }
- .sidebar.open { transform: translateX(0); box-shadow: 0 0 40px rgba(0,0,0,.3); }
- .admin-top, .admin-main { margin-left: 0; }
-}
-@media (max-width: 620px) {
- .hero-title { font-size: 30px; }
- .hero-stats { grid-template-columns: repeat(2, 1fr); }
- .topbar { padding: 10px 14px; }
- .topnav { gap: 10px; font-size: 13px; }
- .topnav a:nth-child(3) { display: none; }
- .page { padding: 0 14px 44px; }
- .chart { height: 150px; gap: 6px; }
- .chart-sub { display: none; }
- .stat-grid { grid-template-columns: repeat(2, 1fr); }
-}
diff --git a/cloud/web/static/site.js b/cloud/web/static/site.js
deleted file mode 100644
index d6f96cc..0000000
--- a/cloud/web/static/site.js
+++ /dev/null
@@ -1,139 +0,0 @@
-/* ===================================================================
- ClearLove 插件社区 · 前端脚本(原生 JS,无第三方依赖)
- 功能:数字滚动动画、移动端侧栏、邮箱验证码、危险操作确认。
- 注:动画仅使用数值/透明度变化,不使用元素位移。
- =================================================================== */
-(function () {
- 'use strict';
-
- var CSRF = (document.querySelector('meta[name=csrf]') || {}).content || '';
- var reduceMotion = window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
-
- function toast(msg) { alert(msg); }
-
- /* ---------------- 数字滚动 ---------------- */
- function countUp(el) {
- var target = parseInt(el.dataset.count || '0', 10) || 0;
- if (reduceMotion || target <= 0) {
- el.textContent = target;
- return;
- }
- var dur = 900, start = null;
- function step(ts) {
- if (start === null) start = ts;
- var p = Math.min((ts - start) / dur, 1);
- var eased = 1 - Math.pow(1 - p, 3); // easeOutCubic
- el.textContent = Math.round(target * eased);
- if (p < 1) requestAnimationFrame(step);
- }
- requestAnimationFrame(step);
- }
-
- function initCounters() {
- document.querySelectorAll('.count[data-count]').forEach(countUp);
- }
-
- /* ---------------- 移动端侧栏 ---------------- */
- function initSidebar() {
- var toggle = document.getElementById('side-toggle');
- var side = document.getElementById('sidebar');
- if (toggle && side) {
- toggle.addEventListener('click', function () { side.classList.toggle('open'); });
- }
- }
-
- /* ---------------- 危险操作确认 ---------------- */
- function initConfirm() {
- document.querySelectorAll('form[data-confirm]').forEach(function (f) {
- f.addEventListener('submit', function (ev) {
- if (!confirm(f.dataset.confirm)) ev.preventDefault();
- });
- });
- }
-
- /* ---------------- 开发者注册:发送邮箱验证码 ---------------- */
- function initSendCode() {
- var btn = document.getElementById('send-code');
- if (!btn) return;
- btn.addEventListener('click', function () {
- var input = document.getElementById(btn.dataset.email);
- if (!input || !input.value.trim()) { toast('请先填写邮箱'); return; }
- var fd = new FormData();
- fd.append('email', input.value.trim());
- btn.disabled = true;
- fetch('/api/dev/send-code', {
- method: 'POST',
- headers: {
- 'X-CSRF-Token': CSRF,
- 'X-Requested-With': 'XMLHttpRequest'
- },
- body: fd
- }).then(function (r) {
- return r.json().catch(function () { return { ok: r.ok }; });
- }).then(function (r) {
- if (!r || !r.ok) {
- toast((r && r.msg) || '发送失败');
- btn.disabled = false;
- return;
- }
- var left = 60;
- btn.textContent = left + ' 秒后重发';
- var timer = setInterval(function () {
- left--;
- btn.textContent = left + ' 秒后重发';
- if (left <= 0) {
- clearInterval(timer);
- btn.disabled = false;
- btn.textContent = '发送验证码';
- }
- }, 1000);
- }).catch(function () {
- toast('网络异常,请稍后重试');
- btn.disabled = false;
- });
- });
- }
-
- /* ---------------- 复制命令 ---------------- */
- function copyText(text, cb) {
- if (navigator.clipboard && navigator.clipboard.writeText) {
- navigator.clipboard.writeText(text).then(cb).catch(function () { fallbackCopy(text, cb); });
- return;
- }
- fallbackCopy(text, cb);
- }
-
- function fallbackCopy(text, cb) {
- var ta = document.createElement('textarea');
- ta.value = text;
- ta.style.position = 'fixed';
- ta.style.opacity = '0';
- document.body.appendChild(ta);
- ta.select();
- try { document.execCommand('copy'); } catch (e) { /* 忽略 */ }
- document.body.removeChild(ta);
- cb();
- }
-
- function initCopy() {
- document.querySelectorAll('.copy-btn').forEach(function (btn) {
- btn.addEventListener('click', function () {
- var text = btn.dataset.copy || '';
- if (!text) return;
- copyText(text, function () {
- var old = btn.textContent;
- btn.textContent = '已复制';
- setTimeout(function () { btn.textContent = old; }, 1600);
- });
- });
- });
- }
-
- document.addEventListener('DOMContentLoaded', function () {
- initCounters();
- initSidebar();
- initConfirm();
- initSendCode();
- initCopy();
- });
-})();
diff --git a/cloud/web/templates/admin.html b/cloud/web/templates/admin.html
deleted file mode 100644
index d4e5299..0000000
--- a/cloud/web/templates/admin.html
+++ /dev/null
@@ -1,218 +0,0 @@
-{{/* ============ 管理员登录 ============ */}}
-{{define "pg_admin_login"}}
-<div class="auth-wrap">
- <form class="auth card" method="post" action="/admin/login">
- <h1 class="auth-title">云端管理后台</h1>
- <p class="muted small auth-sub">{{.site}}</p>
- <input type="hidden" name="_csrf" value="{{.csrf}}">
- {{with .err}}<p class="form-err">{{.}}</p>{{end}}
- <label>管理员账号<input type="text" name="username" required autocomplete="username"></label>
- <label>密码<input type="password" name="password" required autocomplete="current-password"></label>
- <button class="btn btn-primary btn-block btn-lg" type="submit">登录</button>
- </form>
-</div>
-{{end}}
-
-{{/* ============ 仪表盘 ============ */}}
-{{define "pg_admin_dash"}}
-<h1 class="page-title">仪表盘</h1>
-
-<div class="stat-grid">
- <div class="stat card"><b class="stat-num">{{.uvToday}}</b><span>今日活跃访客 (UV)</span></div>
- <div class="stat card"><b class="stat-num">{{.pvToday}}</b><span>今日浏览量 (PV)</span></div>
- <div class="stat card"><b class="stat-num">{{.uvWeek}}</b><span>近 7 日活跃访客</span></div>
- <div class="stat card"><b class="stat-num">{{.pvTotal}}</b><span>累计浏览量</span></div>
-</div>
-
-<div class="stat-grid">
- <div class="stat card"><b class="stat-num">{{index .stats "developers"}}</b><span>入驻开发者</span></div>
- <div class="stat card"><b class="stat-num">{{index .stats "plugins"}}</b><span>已上架插件</span></div>
- <div class="stat card"><b class="stat-num warn">{{.pending}}</b><span>待审核插件</span></div>
- <div class="stat card"><b class="stat-num">{{.devActive}}</b><span>近 7 日活跃开发者</span></div>
-</div>
-
-<div class="card">
- <h3>近 7 日活跃趋势</h3>
- <div class="chart">
- {{$max := .maxPV}}
- {{range .trend}}
- <div class="chart-col">
- <span class="chart-val">{{.PV}}</span>
- <div class="chart-bar-wrap">
- <div class="chart-bar" style="height:{{percent .PV $max}}%"></div>
- </div>
- <span class="chart-label">{{.Day}}</span>
- <span class="chart-sub">{{.UV}} UV</span>
- </div>
- {{end}}
- </div>
-</div>
-
-<div class="card">
- <h3>平台概览</h3>
- <table class="kv-table">
- <tr><th>插件下载总量</th><td>{{index .stats "downloads"}}</td></tr>
- <tr><th>插件安装总量</th><td>{{index .stats "installs"}}</td></tr>
- <tr><th>累计开发者</th><td>{{.devTotal}}</td></tr>
- <tr><th>待审核插件</th><td>{{.pending}} <a class="mini-link" href="/admin/plugins?status=0">去审核 →</a></td></tr>
- </table>
-</div>
-{{end}}
-
-{{/* ============ 插件审核 ============ */}}
-{{define "pg_admin_plugins"}}
-<div class="page-head row">
- <h1 class="page-title">插件审核</h1>
- <div class="chip-row">
- <a class="chip {{if not .status}}active{{end}}" href="/admin/plugins">全部</a>
- <a class="chip {{if eq .status "0"}}active{{end}}" href="/admin/plugins?status=0">待审核</a>
- <a class="chip {{if eq .status "1"}}active{{end}}" href="/admin/plugins?status=1">已上架</a>
- <a class="chip {{if eq .status "2"}}active{{end}}" href="/admin/plugins?status=2">已下架</a>
- <a class="chip {{if eq .status "3"}}active{{end}}" href="/admin/plugins?status=3">已驳回</a>
- </div>
-</div>
-
-<div class="card table-card">
- <div class="table-scroll">
- <table class="table">
- <thead><tr><th>插件</th><th>开发者</th><th>版本</th><th>大小</th><th>下载</th><th>安装</th><th>状态</th><th>操作</th></tr></thead>
- <tbody>
- {{range .plugins}}
- <tr>
- <td>
- <b>{{.Name}}</b><br>
- <small class="muted">/{{.Slug}} · {{catLabel .Category}}</small><br>
- <small class="muted">{{snippet .Description 46}}</small>
- </td>
- <td>{{if .DevName}}{{.DevName}}{{else}}{{.Author}}{{end}}</td>
- <td>{{if .Version}}v{{.Version}}{{else}}-{{end}}</td>
- <td class="muted small">{{sizeText .Size}}</td>
- <td>{{.Downloads}}</td>
- <td>{{.Installs}}</td>
- <td>{{if eq .Status 1}}<span class="tag ok">已上架</span>{{else if eq .Status 0}}<span class="tag warn">待审核</span>{{else if eq .Status 2}}<span class="tag">已下架</span>{{else}}<span class="tag danger">已驳回</span>{{end}}</td>
- <td class="ops">
- <a class="mini-btn" href="/plugins/{{.Slug}}" target="_blank">预览</a>
- {{if ne .Status 1}}
- <form method="post" action="/admin/plugins/status" class="inline-form">
- <input type="hidden" name="_csrf" value="{{$.csrf}}">
- <input type="hidden" name="id" value="{{.ID}}">
- <input type="hidden" name="op" value="approve">
- <button class="mini-btn" type="submit">上架</button>
- </form>
- {{end}}
- {{if eq .Status 1}}
- <form method="post" action="/admin/plugins/status" class="inline-form">
- <input type="hidden" name="_csrf" value="{{$.csrf}}">
- <input type="hidden" name="id" value="{{.ID}}">
- <input type="hidden" name="op" value="offline">
- <button class="mini-btn" type="submit">下架</button>
- </form>
- {{end}}
- {{if eq .Status 0}}
- <form method="post" action="/admin/plugins/status" class="inline-form">
- <input type="hidden" name="_csrf" value="{{$.csrf}}">
- <input type="hidden" name="id" value="{{.ID}}">
- <input type="hidden" name="op" value="reject">
- <button class="mini-btn danger" type="submit">驳回</button>
- </form>
- {{end}}
- <form method="post" action="/admin/plugins/status" class="inline-form" data-confirm="确定永久删除该插件吗?">
- <input type="hidden" name="_csrf" value="{{$.csrf}}">
- <input type="hidden" name="id" value="{{.ID}}">
- <input type="hidden" name="op" value="delete">
- <button class="mini-btn danger" type="submit">删除</button>
- </form>
- </td>
- </tr>
- {{else}}<tr><td colspan="8" class="empty">暂无插件</td></tr>{{end}}
- </tbody>
- </table>
- </div>
-</div>
-{{end}}
-
-{{/* ============ 开发者管理 ============ */}}
-{{define "pg_admin_developers"}}
-<h1 class="page-title">开发者管理</h1>
-<div class="card table-card">
- <div class="table-scroll">
- <table class="table">
- <thead><tr><th>ID</th><th>用户名</th><th>邮箱</th><th>简介</th><th>插件数</th><th>下载量</th><th>注册时间</th><th>状态</th><th>操作</th></tr></thead>
- <tbody>
- {{range .devs}}
- <tr>
- <td>{{.ID}}</td>
- <td><b>{{.Username}}</b></td>
- <td class="cell-content">{{.Email}}</td>
- <td class="cell-content muted small">{{snippet .Bio 30}}</td>
- <td>{{.Plugins}}</td>
- <td>{{.Downloads}}</td>
- <td class="muted small">{{datefmt .CreatedAt}}</td>
- <td>{{if eq .Status 1}}<span class="tag ok">正常</span>{{else}}<span class="tag danger">已禁用</span>{{end}}</td>
- <td class="ops">
- <form method="post" action="/admin/developers/status" class="inline-form">
- <input type="hidden" name="_csrf" value="{{$.csrf}}">
- <input type="hidden" name="id" value="{{.ID}}">
- {{if eq .Status 1}}
- <input type="hidden" name="status" value="0">
- <button class="mini-btn danger" type="submit">禁用</button>
- {{else}}
- <input type="hidden" name="status" value="1">
- <button class="mini-btn" type="submit">启用</button>
- {{end}}
- </form>
- </td>
- </tr>
- {{else}}<tr><td colspan="9" class="empty">暂无开发者</td></tr>{{end}}
- </tbody>
- </table>
- </div>
-</div>
-{{end}}
-
-{{/* ============ 站点设置 ============ */}}
-{{define "pg_admin_settings"}}
-<h1 class="page-title">站点设置</h1>
-
-<form class="card form-card settings-form" method="post" action="/admin/settings">
- <input type="hidden" name="_csrf" value="{{.csrf}}">
- <h3>站点信息</h3>
- <label>站点名称<input type="text" name="site_name" value="{{index .s "site_name"}}" maxlength="40"></label>
- <label>首页标语
- <input type="text" name="slogan" value="{{index .s "slogan"}}" maxlength="120">
- </label>
- <label>站点简介<textarea name="site_desc" rows="3" maxlength="300">{{index .s "site_desc"}}</textarea></label>
- <label>「立即下载」按钮指向的地址
- <input type="text" name="download_url" value="{{index .s "download_url"}}" maxlength="300" placeholder="https://...">
- </label>
- <p class="muted small">官网首屏「立即下载」按钮会跳转到该地址,可填写网盘、GitHub Release 或安装包直链。</p>
- <label>页脚文案<input type="text" name="footer_text" value="{{index .s "footer_text"}}" maxlength="120"></label>
- <label>备案号(可选)<input type="text" name="icp" value="{{index .s "icp"}}" maxlength="60" placeholder="例如:京ICP备00000000号"></label>
-
- <h3>邮件服务(开发者注册验证码)</h3>
- <div class="grid2">
- <label>SMTP 主机<input type="text" name="smtp_host" value="{{index .s "smtp_host"}}" placeholder="smtp.example.com"></label>
- <label>端口<input type="text" name="smtp_port" value="{{index .s "smtp_port"}}" placeholder="465 / 587"></label>
- </div>
- <div class="grid2">
- <label>账号<input type="text" name="smtp_user" value="{{index .s "smtp_user"}}"></label>
- <label>密码 / 授权码<input type="password" name="smtp_pass" value="{{index .s "smtp_pass"}}"></label>
- </div>
- <label>发件人<input type="text" name="smtp_from" value="{{index .s "smtp_from"}}" placeholder="noreply@example.com"></label>
-
- <h3>开发者注册</h3>
- <label class="check-row"><input type="checkbox" name="allow_register" value="1" {{if eq (index .s "allow_register") "1"}}checked{{end}}> 允许新开发者注册(需先配置邮件服务)</label>
-
- <div class="btn-row"><button class="btn btn-primary" type="submit">保存设置</button></div>
-</form>
-
-<form class="card form-card" method="post" action="/admin/password">
- <h3>修改管理员密码</h3>
- <input type="hidden" name="_csrf" value="{{.csrf}}">
- <div class="grid2">
- <label>原密码<input type="password" name="old_password" required></label>
- <label>新密码<input type="password" name="new_password" required minlength="6"></label>
- </div>
- <div class="btn-row"><button class="btn btn-ghost" type="submit">修改密码</button></div>
-</form>
-{{end}}
diff --git a/cloud/web/templates/admin_layout.html b/cloud/web/templates/admin_layout.html
deleted file mode 100644
index 7fed72a..0000000
--- a/cloud/web/templates/admin_layout.html
+++ /dev/null
@@ -1,50 +0,0 @@
-{{define "admin_layout.html"}}<!DOCTYPE html>
-<html lang="zh-CN">
-<head>
-<meta charset="utf-8">
-<meta name="viewport" content="width=device-width, initial-scale=1">
-<meta name="csrf" content="{{.csrf}}">
-<title>{{if .title}}{{.title}} · {{end}}{{.site}}</title>
-<link rel="icon" href="/favicon.svg" type="image/svg+xml">
-<link rel="stylesheet" href="/static/site.css">
-</head>
-{{if .admin}}
-<body class="console-body admin-shell">
-<aside class="sidebar" id="sidebar">
- <div class="side-brand">
- <span class="brand-mark sm">
- <svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 21s-8-5.2-8-10.4C4 7 6.2 5 8.8 5c1.6 0 2.7.8 3.2 1.7C12.5 5.8 13.6 5 15.2 5 17.8 5 20 7 20 10.6 20 15.8 12 21 12 21z"/></svg>
- </span>
- <span>云端管理后台</span>
- </div>
- <nav class="side-nav dark">
- <a href="/admin" class="{{if eq .Page "pg_admin_dash"}}active{{end}}">仪表盘</a>
- <a href="/admin/plugins" class="{{if eq .Page "pg_admin_plugins"}}active{{end}}">插件审核{{if .pending}}<span class="badge">{{.pending}}</span>{{end}}</a>
- <a href="/admin/developers" class="{{if eq .Page "pg_admin_developers"}}active{{end}}">开发者管理</a>
- <a href="/admin/settings" class="{{if eq .Page "pg_admin_settings"}}active{{end}}">站点设置</a>
- </nav>
- <div class="side-foot">
- <a href="/" target="_blank">查看官网</a>
- <a href="/admin/logout">退出登录</a>
- </div>
-</aside>
-<header class="admin-top">
- <button type="button" class="icon-btn" id="side-toggle" aria-label="菜单">
- <svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 6h16M4 12h16M4 18h16"/></svg>
- </button>
- <span class="admin-title">{{.site}} · {{.admin.Username}}</span>
-</header>
-<main class="admin-main">
- {{if .err}}<p class="form-err">{{.err}}</p>{{end}}
- {{if .ok}}<p class="form-ok">保存成功</p>{{end}}
- {{pageContent .Page .}}
-</main>
-<script src="/static/site.js" defer></script>
-</body>
-{{else}}
-<body class="console-body">
- {{pageContent .Page .}}
- <script src="/static/site.js" defer></script>
-</body>
-{{end}}
-</html>{{end}}
diff --git a/cloud/web/templates/dev.html b/cloud/web/templates/dev.html
deleted file mode 100644
index 1386f45..0000000
--- a/cloud/web/templates/dev.html
+++ /dev/null
@@ -1,208 +0,0 @@
-{{/* ============ 开发者仪表盘 ============ */}}
-{{define "pg_dev_dash"}}
-<div class="page-head">
- <h1 class="page-title">开发者仪表盘</h1>
- <p class="muted">欢迎回来,{{.dev.Username}}</p>
-</div>
-
-<div class="stat-grid">
- <div class="stat card"><b class="stat-num">{{.myPlugins}}</b><span>我的插件</span></div>
- <div class="stat card"><b class="stat-num">{{.published}}</b><span>已上架</span></div>
- <div class="stat card"><b class="stat-num">{{.downloads}}</b><span>累计下载</span></div>
- <div class="stat card"><b class="stat-num">{{.installs}}</b><span>站点安装</span></div>
-</div>
-
-<div class="card">
- <div class="section-head row">
- <h3>最近插件</h3>
- <a class="mini-link" href="/dev/plugins">全部插件 →</a>
- </div>
- <div class="table-scroll">
- <table class="table">
- <thead><tr><th>名称</th><th>状态</th><th>下载</th><th>安装</th><th>更新时间</th><th>操作</th></tr></thead>
- <tbody>
- {{range .recent}}
- <tr>
- <td><b>{{.Name}}</b><br><small class="muted">/{{.Slug}}</small></td>
- <td>{{if eq .Status 1}}<span class="tag ok">已上架</span>{{else if eq .Status 0}}<span class="tag warn">待审核</span>{{else if eq .Status 2}}<span class="tag">已下架</span>{{else}}<span class="tag danger">已驳回</span>{{end}}</td>
- <td>{{.Downloads}}</td>
- <td>{{.Installs}}</td>
- <td class="muted small">{{datetime .UpdatedAt}}</td>
- <td><a class="mini-link" href="/dev/plugins/{{.Slug}}">管理</a></td>
- </tr>
- {{else}}<tr><td colspan="6" class="empty">还没有发布插件,<a class="mini-link" href="/dev/plugins/new">立即发布 →</a></td></tr>{{end}}
- </tbody>
- </table>
- </div>
-</div>
-{{end}}
-
-{{/* ============ 我的插件 ============ */}}
-{{define "pg_dev_plugins"}}
-<div class="page-head row">
- <div>
- <h1 class="page-title">我的插件</h1>
- <p class="muted">共 {{len .plugins}} 个插件</p>
- </div>
- <a class="btn btn-primary" href="/dev/plugins/new">+ 发布新插件</a>
-</div>
-
-<div class="card table-card">
- <div class="table-scroll">
- <table class="table">
- <thead><tr><th>名称</th><th>分类</th><th>版本</th><th>状态</th><th>下载</th><th>安装</th><th>操作</th></tr></thead>
- <tbody>
- {{range .plugins}}
- <tr>
- <td>
- <b>{{.Name}}</b><br>
- <small class="muted">{{snippet .Description 40}}</small>
- </td>
- <td><span class="tag-soft">{{catLabel .Category}}</span></td>
- <td>{{if .Version}}v{{.Version}}{{else}}-{{end}}</td>
- <td>{{if eq .Status 1}}<span class="tag ok">已上架</span>{{else if eq .Status 0}}<span class="tag warn">待审核</span>{{else if eq .Status 2}}<span class="tag">已下架</span>{{else}}<span class="tag danger">已驳回</span>{{end}}</td>
- <td>{{.Downloads}}</td>
- <td>{{.Installs}}</td>
- <td class="ops"><a class="mini-btn" href="/dev/plugins/{{.Slug}}">管理</a></td>
- </tr>
- {{else}}<tr><td colspan="7" class="empty">还没有插件</td></tr>{{end}}
- </tbody>
- </table>
- </div>
-</div>
-{{end}}
-
-{{/* ============ 发布新插件 ============ */}}
-{{define "pg_dev_new"}}
-<div class="page-head">
- <h1 class="page-title">发布新插件</h1>
- <p class="muted">上传 zip 包(内含 plugin.json),提交后由管理员审核上架</p>
-</div>
-
-<form class="card form-card" method="post" action="/dev/plugins/new" enctype="multipart/form-data">
- <input type="hidden" name="_csrf" value="{{.csrf}}">
- <label>插件包(zip)
- <input type="file" name="file" accept=".zip" required>
- </label>
- <p class="muted small">包内 plugin.json 的 name / version / author / description 会被自动读取,可在下方覆盖填写。</p>
-
- <div class="grid2">
- <label>插件名称(可选)<input type="text" name="name" maxlength="40" placeholder="留空则使用 plugin.json 中的 name"></label>
- <label>作者名(可选)<input type="text" name="author" maxlength="40" placeholder="留空则使用 plugin.json 中的 author"></label>
- </div>
- <div class="grid2">
- <label>分类
- <select name="category">
- {{range .cats}}<option value="{{.Slug}}">{{.Label}}</option>{{end}}
- </select>
- </label>
- <label>标签(逗号分隔)<input type="text" name="tags" maxlength="80" placeholder="美化,评论,通知"></label>
- </div>
- <label>简介(一句话)<input type="text" name="description" maxlength="120" placeholder="留空则使用 plugin.json 中的 description"></label>
- <label>详细说明 / 使用文档
- <textarea name="readme" rows="8" placeholder="支持多行文本,展示在插件详情页"></textarea>
- </label>
- <div class="grid2">
- <label>项目主页(可选)<input type="text" name="homepage" maxlength="200" placeholder="https://"></label>
- <label>开源协议(可选)<input type="text" name="license" maxlength="40" placeholder="MIT"></label>
- </div>
- <label>版本更新说明(可选)<input type="text" name="changelog" maxlength="200" placeholder="首个版本"></label>
- <div class="btn-row">
- <a class="btn btn-ghost" href="/dev/plugins">取消</a>
- <button class="btn btn-primary" type="submit">提交审核</button>
- </div>
-</form>
-{{end}}
-
-{{/* ============ 插件管理 ============ */}}
-{{define "pg_dev_plugin"}}
-{{with .p}}
-<div class="page-head row">
- <div>
- <h1 class="page-title">{{.Name}}</h1>
- <p class="muted">
- /{{.Slug}} ·
- {{if eq .Status 1}}<span class="tag ok">已上架</span>{{else if eq .Status 0}}<span class="tag warn">待审核</span>{{else if eq .Status 2}}<span class="tag">已下架</span>{{else}}<span class="tag danger">已驳回</span>{{end}}
- · ↓{{.Downloads}} · 安装 {{.Installs}}
- </p>
- </div>
- <div class="ops">
- {{if or (eq .Status 2) (eq .Status 3)}}
- <form method="post" action="/dev/plugins/{{.Slug}}/submit" class="inline-form">
- <input type="hidden" name="_csrf" value="{{$.csrf}}">
- <button class="btn btn-primary" type="submit">重新提交审核</button>
- </form>
- {{end}}
- <form method="post" action="/dev/plugins/{{.Slug}}/delete" class="inline-form" data-confirm="确定删除该插件及其全部版本吗?此操作不可恢复。">
- <input type="hidden" name="_csrf" value="{{$.csrf}}">
- <button class="btn btn-danger-ghost" type="submit">删除插件</button>
- </form>
- </div>
-</div>
-
-<div class="grid-2col">
- <form class="card form-card" method="post" action="/dev/plugins/{{.Slug}}/update">
- <h3>基本信息</h3>
- <input type="hidden" name="_csrf" value="{{$.csrf}}">
- <label>插件名称<input type="text" name="name" value="{{.Name}}" maxlength="40"></label>
- <label>分类
- <select name="category">
- {{range $.cats}}<option value="{{.Slug}}" {{if eq $.p.Category .Slug}}selected{{end}}>{{.Label}}</option>{{end}}
- </select>
- </label>
- <label>标签<input type="text" name="tags" value="{{.Tags}}" maxlength="80"></label>
- <label>简介<input type="text" name="description" value="{{.Description}}" maxlength="120"></label>
- <label>详细说明<textarea name="readme" rows="7">{{.Readme}}</textarea></label>
- <div class="grid2">
- <label>项目主页<input type="text" name="homepage" value="{{.Homepage}}" maxlength="200"></label>
- <label>开源协议<input type="text" name="license" value="{{.License}}" maxlength="40"></label>
- </div>
- <div class="btn-row"><button class="btn btn-primary" type="submit">保存修改</button></div>
- </form>
-
- <div>
- <form class="card form-card" method="post" action="/dev/plugins/{{.Slug}}/version" enctype="multipart/form-data">
- <h3>上传新版本</h3>
- <input type="hidden" name="_csrf" value="{{$.csrf}}">
- <label>插件包(zip,版本号取自 plugin.json 的 version)
- <input type="file" name="file" accept=".zip" required>
- </label>
- <label>更新说明<input type="text" name="changelog" maxlength="200" placeholder="本次更新内容"></label>
- <div class="btn-row"><button class="btn btn-primary" type="submit">上传新版本</button></div>
- </form>
-
- <div class="card">
- <h3>版本记录</h3>
- <ul class="version-list">
- {{range $.versions}}
- <li>
- <div class="v-row"><b>v{{.Version}}</b><span class="muted small">{{datefmt .CreatedAt}} · {{sizeText .FileSize}}</span></div>
- {{if .Changelog}}<p class="small pre-wrap">{{.Changelog}}</p>{{end}}
- <small class="mono muted">sha256: {{.SHA256}}</small>
- </li>
- {{else}}<li class="muted">暂无版本</li>{{end}}
- </ul>
- </div>
- </div>
-</div>
-{{end}}
-{{end}}
-
-{{/* ============ 个人资料 ============ */}}
-{{define "pg_dev_profile"}}
-<div class="page-head">
- <h1 class="page-title">个人资料</h1>
- <p class="muted">展示在插件详情页的作者信息</p>
-</div>
-
-<form class="card form-card" method="post" action="/dev/profile">
- <input type="hidden" name="_csrf" value="{{.csrf}}">
- <label>用户名<input type="text" value="{{.d.Username}}" disabled></label>
- <label>邮箱<input type="text" value="{{.d.Email}}" disabled></label>
- <label>个人简介<textarea name="bio" rows="3" maxlength="200">{{.d.Bio}}</textarea></label>
- <label>个人主页<input type="text" name="homepage" value="{{.d.Homepage}}" maxlength="200" placeholder="https://"></label>
- <hr class="hr">
- <label>修改密码(留空则不修改)<input type="password" name="new_password" minlength="6" placeholder="至少 6 位"></label>
- <div class="btn-row"><button class="btn btn-primary" type="submit">保存</button></div>
-</form>
-{{end}}
diff --git a/cloud/web/templates/dev_layout.html b/cloud/web/templates/dev_layout.html
deleted file mode 100644
index 1c07926..0000000
--- a/cloud/web/templates/dev_layout.html
+++ /dev/null
@@ -1,44 +0,0 @@
-{{define "dev_layout.html"}}<!DOCTYPE html>
-<html lang="zh-CN">
-<head>
-<meta charset="utf-8">
-<meta name="viewport" content="width=device-width, initial-scale=1">
-<meta name="csrf" content="{{.csrf}}">
-<title>{{if .title}}{{.title}} · {{end}}{{.site}}</title>
-<link rel="icon" href="/favicon.svg" type="image/svg+xml">
-<link rel="stylesheet" href="/static/site.css">
-</head>
-<body class="console-body">
-<header class="topbar">
- <a class="brand" href="/">
- <span class="brand-mark">
- <svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 21s-8-5.2-8-10.4C4 7 6.2 5 8.8 5c1.6 0 2.7.8 3.2 1.7C12.5 5.8 13.6 5 15.2 5 17.8 5 20 7 20 10.6 20 15.8 12 21 12 21z"/></svg>
- </span>
- <span class="brand-text">开发者后台</span>
- </a>
- <nav class="topnav">
- <a href="/">返回官网</a>
- {{if .dev}}<span class="muted small">{{.dev.Username}}</span><a class="btn btn-ghost" href="/dev/logout">退出</a>{{end}}
- </nav>
-</header>
-
-<div class="console-wrap">
- {{if .dev}}
- <aside class="side">
- <nav class="side-nav">
- <a href="/dev" class="{{if eq .Page "pg_dev_dash"}}active{{end}}">仪表盘</a>
- <a href="/dev/plugins" class="{{if or (eq .Page "pg_dev_plugins") (eq .Page "pg_dev_plugin")}}active{{end}}">我的插件</a>
- <a href="/dev/plugins/new" class="{{if eq .Page "pg_dev_new"}}active{{end}}">发布插件</a>
- <a href="/dev/profile" class="{{if eq .Page "pg_dev_profile"}}active{{end}}">个人资料</a>
- <a href="/docs">开发文档</a>
- </nav>
- </aside>
- {{end}}
- <main class="console-main">
- {{if .err}}<p class="form-err">{{.err}}</p>{{end}}
- {{pageContent .Page .}}
- </main>
-</div>
-<script src="/static/site.js" defer></script>
-</body>
-</html>{{end}}
diff --git a/cloud/web/templates/layout.html b/cloud/web/templates/layout.html
deleted file mode 100644
index 9787040..0000000
--- a/cloud/web/templates/layout.html
+++ /dev/null
@@ -1,50 +0,0 @@
-{{define "layout.html"}}<!DOCTYPE html>
-<html lang="zh-CN">
-<head>
-<meta charset="utf-8">
-<meta name="viewport" content="width=device-width, initial-scale=1">
-<meta name="csrf" content="{{.csrf}}">
-<meta name="description" content="{{.site_desc}}">
-<title>{{if .title}}{{.title}} · {{end}}{{.site}}</title>
-<link rel="icon" href="/favicon.svg" type="image/svg+xml">
-<link rel="stylesheet" href="/static/site.css">
-</head>
-<body>
-<div class="bg-grid" aria-hidden="true"></div>
-<div class="glow glow-a" aria-hidden="true"></div>
-<div class="glow glow-b" aria-hidden="true"></div>
-
-<header class="topbar">
- <a class="brand" href="/">
- <span class="brand-mark">
- <svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 21s-8-5.2-8-10.4C4 7 6.2 5 8.8 5c1.6 0 2.7.8 3.2 1.7C12.5 5.8 13.6 5 15.2 5 17.8 5 20 7 20 10.6 20 15.8 12 21 12 21z"/></svg>
- </span>
- <span class="brand-text">{{.site}}</span>
- </a>
- <nav class="topnav">
- <a href="/" class="{{if eq .Page "pg_home"}}active{{end}}">首页</a>
- <a href="/plugins" class="{{if or (eq .Page "pg_plugins") (eq .Page "pg_plugin_detail")}}active{{end}}">插件市场</a>
- <a href="/docs" class="{{if eq .Page "pg_docs"}}active{{end}}">开发文档</a>
- {{if .dev}}
- <a class="btn btn-ghost" href="/dev">开发者后台</a>
- <a class="btn btn-primary" href="/dev/plugins/new">发布插件</a>
- {{else}}
- <a href="/dev/login" class="nav-plain">登录</a>
- <a class="btn btn-primary" href="/dev/register">开发者注册</a>
- {{end}}
- </nav>
-</header>
-
-<main class="page">
-{{pageContent .Page .}}
-</main>
-
-<footer class="footer">
- <div class="footer-inner">
- <p class="footer-brand">{{.footer_text}}</p>
- <p class="muted small">{{if .icp}}{{.icp}} · {{end}}ClearLove 插件社区 · Powered by Go + SQLite</p>
- </div>
-</footer>
-<script src="/static/site.js" defer></script>
-</body>
-</html>{{end}}
diff --git a/cloud/web/templates/pages.html b/cloud/web/templates/pages.html
deleted file mode 100644
index 0d13e20..0000000
--- a/cloud/web/templates/pages.html
+++ /dev/null
@@ -1,468 +0,0 @@
-{{/* ============ 插件卡片(列表/首页复用) ============ */}}
-{{define "pluginCard"}}
-<article class="pcard">
- <div class="pcard-head">
- <span class="pcard-icon">{{substr0 .Name}}</span>
- <div class="pcard-title">
- <h3><a href="/plugins/{{.Slug}}">{{.Name}}</a></h3>
- <p class="pcard-meta"><span class="tag-soft">{{catLabel .Category}}</span>{{if .Version}}v{{.Version}}{{end}}</p>
- </div>
- </div>
- <p class="pcard-desc">{{if .Description}}{{snippet .Description 78}}{{else}}暂无描述{{end}}</p>
- <div class="pcard-foot">
- <span class="stat-inline" title="下载量">
- <svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 4v11M7.5 10.5 12 15l4.5-4.5M5 19h14"/></svg>{{.Downloads}}
- </span>
- <span class="stat-inline" title="开发者">
- <svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="8" r="3.4"/><path d="M5 20c0-3.6 3.1-6.5 7-6.5s7 2.9 7 6.5"/></svg>{{if .DevName}}{{.DevName}}{{else}}{{.Author}}{{end}}
- </span>
- <a class="mini-link" href="/plugins/{{.Slug}}">查看详情 →</a>
- </div>
-</article>
-{{end}}
-
-{{/* ============ 官网首页 ============ */}}
-{{define "pg_home"}}
-<section class="hero">
- <div class="hero-inner">
- <span class="pill"><i class="dot"></i>Clearlove2.0 全新上线</span>
- <h1 class="hero-title">Clearlove2.0 全新上线<br><span class="grad">用 Go 构建你的专属表白墙</span></h1>
- <p class="hero-sub">{{.site_desc}}</p>
- <div class="hero-actions">
- <a class="btn btn-primary btn-lg" href="/download" target="_blank" rel="noopener">
- 立即下载
- <svg viewBox="0 0 24 24" width="17" height="17" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 4v11M7.5 10.5 12 15l4.5-4.5M5 19h14"/></svg>
- </a>
- <a class="btn btn-ghost btn-lg" href="/plugins">浏览插件市场</a>
- <a class="link-more" href="/docs">开发文档 →</a>
- </div>
- <ul class="hero-meta">
- <li>单文件部署</li>
- <li>SQLite / MySQL 双数据库</li>
- <li>插件生态</li>
- <li>AI 内容审核</li>
- </ul>
- <div class="hero-stats">
- <div class="hstat"><b class="count" data-count="{{index .stats "plugins"}}">0</b><span>上架插件</span></div>
- <div class="hstat"><b class="count" data-count="{{index .stats "developers"}}">0</b><span>入驻开发者</span></div>
- <div class="hstat"><b class="count" data-count="{{index .stats "downloads"}}">0</b><span>累计下载</span></div>
- <div class="hstat"><b class="count" data-count="{{index .stats "installs"}}">0</b><span>站点安装</span></div>
- </div>
- </div>
- <div class="hero-visual" aria-hidden="true">
- <div class="mock">
- <div class="mock-bar">
- <span class="mock-dots"><i></i><i></i><i></i></span>
- <span class="mock-url">clearlove · 表白墙</span>
- </div>
- <div class="mock-grid">
- <article class="mock-card">
- <div class="mock-row"><span class="mock-avatar"></span><span class="mock-line w50"></span></div>
- <p class="mock-text">今天的晚霞特别好看,想和你一起看。</p>
- <div class="mock-media"></div>
- <div class="mock-foot"><span class="mock-heart">♥</span> 128 · 评论 23</div>
- </article>
- <article class="mock-card alt">
- <div class="mock-row"><span class="mock-avatar g"></span><span class="mock-line w40"></span></div>
- <p class="mock-text">匿名也能收到温柔的回复,这里很好。</p>
- <div class="mock-foot"><span class="mock-heart">♥</span> 86 · 评论 12</div>
- </article>
- </div>
- </div>
- </div>
-</section>
-
-<section class="section">
- <div class="section-head center">
- <h2>三步上线属于你的表白墙</h2>
- <p class="muted">不需要 Docker,也不需要单独部署数据库,一个可执行文件即可运行</p>
- </div>
- <div class="quickstart">
- <div class="cmd-box">
- <div class="cmd-head">
- <span>Terminal</span>
- <button class="copy-btn" type="button" data-copy="chmod +x clearlove ./clearlove # 然后访问 http://localhost:16868/install 完成安装">复制</button>
- </div>
- <pre class="cmd-body"># 1. 下载对应架构的二进制(见「立即下载」)
-$ chmod +x clearlove
-$ ./clearlove
-ClearLove 表白墙 2.0 | 数据目录: ./data | 上传目录: ./uploads
-服务已启动: http://localhost:16868 (后台: /admin)
-
-# 可选:自定义端口与数据目录
-$ PORT=8080 CLEARLOVE_DATA_DIR=/opt/clv/data ./clearlove</pre>
- </div>
- <ol class="step-cards">
- <li class="step-card">
- <b class="step-num">1</b>
- <div><h3>下载运行</h3><p>Linux amd64 / arm64 单文件二进制,无运行库依赖,不依赖 CGO。</p></div>
- </li>
- <li class="step-card">
- <b class="step-num">2</b>
- <div><h3>完成安装向导</h3><p>访问 <code>/install</code>,选择 SQLite 或 MySQL,自动建表并创建管理员。</p></div>
- </li>
- <li class="step-card">
- <b class="step-num">3</b>
- <div><h3>按需安装插件</h3><p>后台「插件商店」一键安装云端插件,也可上传自己的插件包。</p></div>
- </li>
- </ol>
- </div>
-</section>
-
-<section class="section">
- <div class="section-head center">
- <h2>核心能力</h2>
- <p class="muted">前台体验、后台管理与扩展能力,一次到位</p>
- </div>
- <div class="features">
- <div class="feature">
- <span class="feature-icon">
- <svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M10 3h4v4a2 2 0 1 0 4 0h3v4h-4a2 2 0 1 0 0 4h4v4h-4a2 2 0 1 0-4 0H9v-4a2 2 0 1 0-4 0H3v-4h4a2 2 0 1 0 0-4H3V7h4a2 2 0 1 0 4 0z"/></svg>
- </span>
- <h3>钩子式插件</h3>
- <p>HTML 注入 / 正则内容过滤 / 事件 Webhook 三类钩子,覆盖页面美化与业务扩展。</p>
- </div>
- <div class="feature">
- <span class="feature-icon">
- <svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M4 6h16v12H4z"/><path d="M8 10h8M8 14h5"/></svg>
- </span>
- <h3>后台一键安装</h3>
- <p>表白墙后台内置插件商店,选择插件点击安装即可自动下载并部署,无需手工上传。</p>
- </div>
- <div class="feature">
- <span class="feature-icon">
- <svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M12 3l8 3v6c0 5-3.4 8.2-8 9-4.6-.8-8-4-8-9V6z"/><path d="M9 12l2 2 4-4"/></svg>
- </span>
- <h3>人工审核上架</h3>
- <p>所有插件经管理员审核后上架,附带 SHA256 校验值,安装时可校验完整性。</p>
- </div>
- <div class="feature">
- <span class="feature-icon">
- <svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M12 3v18M5 8l7-5 7 5M5 16l7 5 7-5"/></svg>
- </span>
- <h3>极简部署</h3>
- <p>单二进制 + SQLite,无外部依赖。下载即运行,适合个人服务器与小型社区。</p>
- </div>
- <div class="feature">
- <span class="feature-icon">
- <svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="1.8"><ellipse cx="12" cy="6" rx="8" ry="3"/><path d="M4 6v12c0 1.7 3.6 3 8 3s8-1.3 8-3V6"/><path d="M4 12c0 1.7 3.6 3 8 3s8-1.3 8-3"/></svg>
- </span>
- <h3>双数据库兼容</h3>
- <p>表白墙本体同时支持 SQLite 与 MySQL,插件在两种环境下行为一致。</p>
- </div>
- <div class="feature">
- <span class="feature-icon">
- <svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="1.8"><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3.5 2"/></svg>
- </span>
- <h3>持续更新</h3>
- <p>云端统一分发版本,插件作者发布新版本后,使用者可即时获取。</p>
- </div>
- </div>
-</section>
-
-<section class="section">
- <div class="section-head center">
- <h2>为长期运行而设计的架构</h2>
- <p class="muted">极简依赖、双数据库兼容、钩子式扩展</p>
- </div>
- <div class="tech-grid">
- <div class="tech-item">
- <h3>运行时</h3>
- <p>Go 1.27 编译为单个静态二进制,模板与静态资源全部内嵌,无 CGO、无外部依赖。</p>
- </div>
- <div class="tech-item">
- <h3>存储</h3>
- <p>默认 SQLite 零配置开箱即用,也可切换 MySQL;建表迁移幂等,两种驱动行为一致。</p>
- </div>
- <div class="tech-item">
- <h3>扩展</h3>
- <p>钩子式插件(HTML 注入 / 正则过滤 / 事件 Webhook),可从云端插件市场一键安装。</p>
- </div>
- <div class="tech-item">
- <h3>安全</h3>
- <p>bcrypt 密码存储、HMAC 签名会话、CSRF 双提交校验、XSS 转义与 IP/指纹封禁。</p>
- </div>
- </div>
- <div class="tech-badges">
- <span class="tbadge">Go 1.27</span>
- <span class="tbadge">SQLite</span>
- <span class="tbadge">MySQL</span>
- <span class="tbadge">CGO=0</span>
- <span class="tbadge">embed 内嵌</span>
- <span class="tbadge">WebP 自动转换</span>
- <span class="tbadge">AI 内容审核</span>
- <span class="tbadge">SMTP 邮件</span>
- </div>
-</section>
-
-<section class="section">
- <div class="section-head row">
- <div>
- <h2>插件生态</h2>
- <p class="muted">{{if .latest}}社区开发者发布的作品,表白墙后台一键安装{{else}}插件市场已就绪,等待第一位开发者发布作品{{end}}</p>
- </div>
- <a class="mini-link" href="/plugins">全部插件 →</a>
- </div>
- {{if .latest}}
- <div class="grid-cards">
- {{range .latest}}{{template "pluginCard" .}}{{end}}
- </div>
- {{end}}
- <div class="eco-links">
- <a class="eco-link" href="/plugins"><b>插件市场</b><span>浏览全部已上架插件与版本记录</span></a>
- <a class="eco-link" href="/docs"><b>插件开发文档</b><span>钩子类型、plugin.json 规范与发布流程</span></a>
- <a class="eco-link" href="/dev/register"><b>成为开发者</b><span>发布插件,查看下载与安装数据</span></a>
- </div>
-</section>
-
-<section class="section">
- <div class="section-head center">
- <h2>常见问题</h2>
- </div>
- <div class="faq">
- <details class="faq-item">
- <summary>部署 ClearLove 需要什么环境?</summary>
- <p>只需要一台能运行 Linux 的服务器,上传对应架构的单个可执行文件即可。程序内置全部模板与静态资源,不需要额外安装 Nginx、PHP、Node.js 等运行时;数据库默认使用内置的 SQLite,也可以连接已有 MySQL。</p>
- </details>
- <details class="faq-item">
- <summary>数据保存在哪里?升级会丢数据吗?</summary>
- <p>数据都在程序同级的 <code>data/</code>(SQLite 文件、配置、主题、插件)与 <code>uploads/</code>(图片与视频)目录中。升级时只替换二进制文件并重启,保持数据目录不动即可;也可以用 <code>CLEARLOVE_DATA_DIR</code> 把数据放到其他磁盘。</p>
- </details>
- <details class="faq-item">
- <summary>如何自定义端口与访问地址?</summary>
- <p>用环境变量即可:<code>PORT=8080 ./clearlove</code>。生产环境建议配合 Nginx 反向代理到 443 并配置 HTTPS,程序会自动识别 <code>X-Forwarded-For</code> 与 <code>X-Real-IP</code>。</p>
- </details>
- <details class="faq-item">
- <summary>插件是怎么安装和生效的?</summary>
- <p>进入后台「插件管理 → 插件商店」,从云端市场选择插件点击安装,系统会自动下载并校验 SHA256 完整性;安装完成后回到插件管理页点击「启用」即可生效,无需重启服务。</p>
- </details>
- <details class="faq-item">
- <summary>可以自己开发插件吗?</summary>
- <p>可以。插件就是一个包含 <code>plugin.json</code> 的 zip 包,支持 HTML 注入、正则内容过滤与事件 Webhook 三类钩子。注册开发者账号后即可在开发者后台发布,审核上架后所有站点都能一键安装,详见<a class="mini-link" href="/docs">插件开发文档</a>。</p>
- </details>
- <details class="faq-item">
- <summary>内容审核怎么处理?</summary>
- <p>内置举报、IP / 浏览器指纹封禁与管理员审核,还可以接入任意 OpenAI 兼容模型:开启后发帖先经 AI 预审,并支持每 10 分钟自动巡查,对违规内容执行保留 / 隐藏 / 删除并全程留痕。</p>
- </details>
- </div>
-</section>
-
-<section class="section cta">
- <div class="cta-box">
- <h2>现在就把表白墙跑起来</h2>
- <p>下载即用;也欢迎把插件发布到社区,让更多人用上你的作品。</p>
- <div class="hero-actions center">
- <a class="btn btn-primary btn-lg" href="/download" target="_blank" rel="noopener">立即下载</a>
- <a class="btn btn-ghost btn-lg" href="/plugins">浏览插件市场</a>
- <a class="btn btn-ghost btn-lg" href="/dev/register">注册开发者</a>
- </div>
- </div>
-</section>
-{{end}}
-
-{{/* ============ 插件市场 ============ */}}
-{{define "pg_plugins"}}
-<div class="page-head">
- <h1 class="page-title">插件市场</h1>
- <p class="muted">共 {{.total}} 个已上架插件,可直接在表白墙后台「插件商店」中一键安装</p>
-</div>
-
-<form class="filter-bar" method="get" action="/plugins">
- <input type="search" name="q" value="{{.q}}" placeholder="搜索插件名称、描述或标签">
- <select name="category">
- <option value="">全部分类</option>
- {{range .cats}}<option value="{{.Slug}}" {{if eq $.category .Slug}}selected{{end}}>{{.Label}}</option>{{end}}
- </select>
- <select name="sort">
- <option value="">按下载量</option>
- <option value="new" {{if eq .sort "new"}}selected{{end}}>最新发布</option>
- <option value="name" {{if eq .sort "name"}}selected{{end}}>按名称</option>
- </select>
- <button class="btn btn-primary" type="submit">筛选</button>
-</form>
-
-{{if .plugins}}
-<div class="grid-cards">
- {{range .plugins}}{{template "pluginCard" .}}{{end}}
-</div>
-{{if gt .pages 1}}
-<nav class="pager">
- {{if gt .page 1}}<a class="btn btn-ghost" href="/plugins?page={{minus1 .page}}&q={{.q}}&category={{.category}}&sort={{.sort}}">上一页</a>{{end}}
- <span class="muted">第 {{.page}} / {{.pages}} 页</span>
- {{if lt .page .pages}}<a class="btn btn-ghost" href="/plugins?page={{plus1 .page}}&q={{.q}}&category={{.category}}&sort={{.sort}}">下一页</a>{{end}}
-</nav>
-{{end}}
-{{else}}
-<p class="empty">没有找到匹配的插件</p>
-{{end}}
-{{end}}
-
-{{/* ============ 插件详情 ============ */}}
-{{define "pg_plugin_detail"}}
-{{with .p}}
-<div class="detail-head card">
- <div class="detail-title">
- <span class="pcard-icon lg">{{substr0 .Name}}</span>
- <div>
- <h1>{{.Name}}</h1>
- <p class="muted">
- <span class="tag-soft">{{catLabel .Category}}</span>
- 由 <b>{{if .DevName}}{{.DevName}}{{else}}{{.Author}}{{end}}</b> 发布
- {{if .License}}· {{.License}}{{end}}
- </p>
- </div>
- </div>
- <div class="detail-actions">
- {{if .Version}}
- <a class="btn btn-primary btn-lg" href="/dl/{{.Slug}}">
- 下载 v{{.Version}}
- <svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 4v11M7.5 10.5 12 15l4.5-4.5M5 19h14"/></svg>
- </a>
- {{end}}
- </div>
-</div>
-
-<div class="detail-grid">
- <div class="card">
- <h3>插件说明</h3>
- <p class="pre-wrap">{{if .Description}}{{.Description}}{{else}}暂无说明{{end}}</p>
- {{if .Readme}}
- <h3>详细文档</h3>
- <div class="pre-wrap readme">{{.Readme}}</div>
- {{end}}
- {{if .Tags}}<p class="muted small">标签:{{.Tags}}</p>{{end}}
- {{if .Homepage}}<p class="small"><a class="mini-link" href="{{.Homepage}}" target="_blank" rel="noopener">项目主页 →</a></p>{{end}}
- </div>
-
- <aside class="card side-card">
- <h3>版本记录</h3>
- <ul class="version-list">
- {{range $.versions}}
- <li>
- <div class="v-row">
- <b>v{{.Version}}</b>
- <a class="mini-link" href="/api/v1/store/download/{{$.p.Slug}}/{{.Version}}">下载</a>
- </div>
- <small class="muted">{{datefmt .CreatedAt}} · {{sizeText .FileSize}} · ↓{{.Downloads}}</small>
- {{if .Changelog}}<p class="small pre-wrap">{{.Changelog}}</p>{{end}}
- <small class="mono muted">sha256: {{.SHA256}}</small>
- </li>
- {{else}}<li class="muted">暂无版本</li>{{end}}
- </ul>
- </aside>
-</div>
-
-<div class="card">
- <h3>安装方式</h3>
- <ol class="steps-list">
- <li>进入你的表白墙后台 → <b>插件管理 → 插件商店</b>(云端地址默认已配置为本站)。</li>
- <li>在商店中找到「{{.Name}}」,点击<b>安装</b>,系统会自动下载并校验 SHA256。</li>
- <li>返回插件管理页点击<b>启用</b>即可生效。</li>
- </ol>
- <p class="muted small">也可以手动下载 zip 包,在「插件管理 → 上传并安装」中上传。</p>
-</div>
-{{end}}
-{{end}}
-
-{{/* ============ 开发文档 ============ */}}
-{{define "pg_docs"}}
-<div class="page-head">
- <h1 class="page-title">插件开发文档</h1>
- <p class="muted">只需一个 plugin.json,就能为全网点表白墙提供扩展能力</p>
-</div>
-
-<div class="card">
- <h2 class="doc-h">1. 插件包结构</h2>
- <p>插件是一个 zip 压缩包,根目录(或任意子目录)需包含 <code>plugin.json</code>:</p>
- <pre class="code">your-plugin.zip
-├── plugin.json
-└── (可选)其他静态资源
-
-plugin.json 示例:
-{
- "name": "每日一言",
- "version": "1.0.0",
- "author": "yourname",
- "description": "在页面底部展示一句随机语录",
- "hooks": [
- { "hook": "footer_html", "type": "html",
- "html": "<div class=\"daily-quote\">今天也要加油鸭</div>" },
- { "hook": "content_filter", "type": "filter",
- "match": "广告|加群", "replace": "***" },
- { "hook": "post_created", "type": "http",
- "url": "https://example.com/webhook" }
- ]
-}</pre>
-</div>
-
-<div class="card">
- <h2 class="doc-h">2. 三类钩子</h2>
- <table class="table">
- <thead><tr><th>类型</th><th>钩子名</th><th>说明</th></tr></thead>
- <tbody>
- <tr><td><span class="tag-soft">html</span></td><td><code>header_html</code></td><td>向页面头部注入 HTML 片段</td></tr>
- <tr><td><span class="tag-soft">html</span></td><td><code>footer_html</code></td><td>向页面底部注入 HTML 片段</td></tr>
- <tr><td><span class="tag-soft">filter</span></td><td><code>content_filter</code></td><td>正则改写帖子 / 评论内容(match + replace)</td></tr>
- <tr><td><span class="tag-soft">http</span></td><td><code>post_created</code></td><td>发帖后回调 Webhook</td></tr>
- <tr><td><span class="tag-soft">http</span></td><td><code>comment_created</code></td><td>评论后回调 Webhook</td></tr>
- <tr><td><span class="tag-soft">http</span></td><td><code>report_created</code></td><td>举报后回调 Webhook</td></tr>
- </tbody>
- </table>
- <p class="muted small">Webhook 以 POST JSON 发送,请求头带 <code>X-ClearLove-Event</code>。</p>
-</div>
-
-<div class="card">
- <h2 class="doc-h">3. 发布流程</h2>
- <ol class="steps-list">
- <li><a class="mini-link" href="/dev/register">注册开发者账号</a>(需邮箱验证,验证码由本站邮件服务发送)。</li>
- <li>进入「开发者后台 → 发布插件」,上传 zip 包并填写说明。</li>
- <li>等待管理员审核上架(通常 1 个工作日内)。</li>
- <li>上架后,所有表白墙使用者都能在后台插件商店中一键安装。</li>
- </ol>
-</div>
-
-<div class="card">
- <h2 class="doc-h">4. 安装到表白墙</h2>
- <p>表白墙后台 → <b>插件管理 → 插件商店</b>,云端地址填写 <code>https://clearlove.kazx.top</code>,即可浏览并一键安装。</p>
-</div>
-{{end}}
-
-{{/* ============ 开发者注册 ============ */}}
-{{define "pg_dev_register"}}
-<form class="auth card" method="post" action="/dev/register" id="dev-register-form">
- <h1 class="auth-title">注册开发者账号</h1>
- <p class="muted small auth-sub">发布你的插件,让全网表白墙都能用上</p>
- <input type="hidden" name="_csrf" value="{{.csrf}}">
- {{with .err}}<p class="form-err">{{.}}</p>{{end}}
- {{if not .allow}}
- <p class="empty">站点已关闭开发者注册</p>
- {{else if not .smtp}}
- <p class="form-err">站点尚未配置邮件服务,暂时无法注册,请联系管理员。</p>
- {{end}}
- <label>用户名<input type="text" name="username" required maxlength="20" placeholder="3-20 个字符"></label>
- <label>邮箱<input type="email" name="email" id="dev-email" required placeholder="用于接收验证码"></label>
- <label>邮箱验证码
- <div class="code-row">
- <input type="text" name="code" required maxlength="6" inputmode="numeric" placeholder="6 位验证码">
- <button class="btn btn-ghost" type="button" id="send-code" data-email="dev-email">发送验证码</button>
- </div>
- </label>
- <label>密码<input type="password" name="password" required minlength="6" placeholder="至少 6 位"></label>
- <button class="btn btn-primary btn-block btn-lg" type="submit" {{if or (not .allow) (not .smtp)}}disabled{{end}}>注册</button>
- <p class="auth-alt">已有账号?<a href="/dev/login">去登录</a></p>
-</form>
-{{end}}
-
-{{/* ============ 开发者登录 ============ */}}
-{{define "pg_dev_login"}}
-<form class="auth card" method="post" action="/dev/login">
- <h1 class="auth-title">开发者登录</h1>
- <p class="muted small auth-sub">登录后可发布与维护你的插件</p>
- <input type="hidden" name="_csrf" value="{{.csrf}}">
- {{with .err}}<p class="form-err">{{.}}</p>{{end}}
- <label>用户名或邮箱<input type="text" name="account" required autocomplete="username"></label>
- <label>密码<input type="password" name="password" required autocomplete="current-password"></label>
- <button class="btn btn-primary btn-block btn-lg" type="submit">登录</button>
- <p class="auth-alt">还没有账号?<a href="/dev/register">注册开发者</a></p>
-</form>
-{{end}}