feat: HomeDash - unified self-hosted navigation portal

Single-container navigation service combining:
- Dashy dashboard + SearXNG metasearch + history sync API + admin WebUI
- Go services for history/admin APIs, nginx as unified entry
- Browser extensions (Chrome MV3 / Firefox MV2) with 30s batched sync
- Production guide: portal-nginx unified auth reverse proxy + webui4frpc/frp tunnel
- Fully sanitized (no internal IPs/domains/credentials)
This commit is contained in:
JianFeeeee
2026-09-08 22:25:00 +08:00
commit 7e49d2b51c
26 changed files with 1970 additions and 0 deletions

5
.codegraph/.gitignore vendored Normal file
View File

@ -0,0 +1,5 @@
# CodeGraph data files — local to each machine, not for committing.
# Ignore everything in .codegraph/ except this file itself, so transient
# files (the database, daemon.pid, sockets, logs) never show up in git.
*
!.gitignore

8
.env.example Normal file
View File

@ -0,0 +1,8 @@
# JianF 统一导航服务环境变量
# 复制此文件为 .env 并修改
# 浏览器插件同步令牌(留空则不鉴权)
NAVI_SYNC_TOKEN=
# 管理 API 令牌(留空则不鉴权)
NAVI_ADMIN_TOKEN=

1
.gitattributes vendored Normal file
View File

@ -0,0 +1 @@
*.tar.gz filter=lfs diff=lfs merge=lfs -text

17
.gitignore vendored Normal file
View File

@ -0,0 +1,17 @@
# Data and private deployment config
/data/
conf.yml
# Environment
.env
# IDE
.vscode/
.idea/
# Go
gateway/navi-gateway
# OS
.DS_Store
Thumbs.db

78
Dockerfile Normal file
View File

@ -0,0 +1,78 @@
# syntax=docker/dockerfile:1
# ── Stage 1: Go binaries ──
FROM golang:1.21-bookworm AS gobuild
WORKDIR /src/history
COPY history-server/ .
RUN GOPROXY=https://goproxy.cn,direct CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/history-server .
WORKDIR /src/admin
COPY admin-server/ .
RUN GOPROXY=https://goproxy.cn,direct go mod tidy && CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/admin-server .
# ── Stage 2: SearXNG from source ──
FROM debian:bookworm-slim AS searxng-build
ARG HTTP_PROXY=http://172.17.0.1:7890
ARG HTTPS_PROXY=http://172.17.0.1:7890
ENV DEBIAN_FRONTEND=noninteractive
ENV http_proxy=${HTTP_PROXY}
ENV https_proxy=${HTTPS_PROXY}
ENV HTTP_PROXY=${HTTP_PROXY}
ENV HTTPS_PROXY=${HTTPS_PROXY}
RUN apt-get update && apt-get install -y --no-install-recommends \
python3 python3-pip python3-venv git ca-certificates \
&& rm -rf /var/lib/apt/lists/*
RUN git clone --depth 1 https://github.com/searxng/searxng.git /src/searxng \
&& python3 -m venv /opt/searxng-venv \
&& /opt/searxng-venv/bin/pip install --upgrade pip setuptools wheel msgspec \
&& cd /src/searxng \
&& /opt/searxng-venv/bin/pip install --no-cache-dir -r requirements.txt \
&& /opt/searxng-venv/bin/pip install --no-cache-dir --no-build-isolation . \
&& rm -rf /src/searxng
# ── Stage 3: Runtime ──
FROM debian:bookworm-slim
ENV DEBIAN_FRONTEND=noninteractive
RUN apt-get update && apt-get install -y --no-install-recommends \
nginx python3 supervisor curl ca-certificates git \
&& rm -rf /var/lib/apt/lists/*
# Copy SearXNG venv + source
COPY --from=searxng-build /opt/searxng-venv /app/searxng-venv
COPY --from=searxng-build /usr/lib/python3.11 /usr/lib/python3.11
COPY --from=searxng-build /lib/x86_64-linux-gnu /lib/x86_64-linux-gnu
COPY --from=searxng-build /usr/lib/x86_64-linux-gnu /usr/lib/x86_64-linux-gnu
# Dashy static files
COPY --from=lissy93/dashy:latest /app/dist /app/dashy
# Go binaries
COPY --from=gobuild /out/history-server /usr/local/bin/history-server
COPY --from=gobuild /out/admin-server /usr/local/bin/admin-server
# Config files
COPY nginx.conf /etc/nginx/nginx.conf
COPY supervisord.conf /etc/supervisor/conf.d/supervisord.conf
COPY searxng-settings.yml /etc/searxng/settings.yml
COPY conf.example.yml /app/defaults/conf.yml
COPY admin/index.html /app/admin/index.html
# Inject search script into Dashy
COPY web/navi.js /app/dashy/navi.js
RUN sed -i 's#</body>#<script src="/navi.js"></script></body>#' /app/dashy/index.html
# Data directory
RUN mkdir -p /app/data/item-icons /var/log/supervisor /var/cache/searxng /run/nginx
VOLUME ["/app/data"]
EXPOSE 8080
HEALTHCHECK --interval=30s --timeout=5s --start-period=30s --retries=3 \
CMD curl -fs http://localhost:8080/conf.yml || exit 1
CMD ["/usr/bin/supervisord", "-c", "/etc/supervisor/conf.d/supervisord.conf"]

413
README.md Normal file
View File

@ -0,0 +1,413 @@
# HomeDash — 自托管内网导航门户
将 **Dashy + SearXNG + 浏览器历史同步 + 管理后台**整合在单个 Docker 容器中,并附带**完整的内网服务发布方案**(带统一认证的反向代理 + webui4frpc 内网穿透),让外网通过域名安全访问内网服务。
## 架构总览(生产全链路)
```
公网用户
│
▼
阿里云 VPS (frps 服务端, <vps-public-ip>:7000/8080/443)
│ frps: vhost 端口 + tcp 隧道
▼
frpc (由 webui4frpc 在 .60 上管理, WebUI :7500)
│ configs/*.json → 定义各隧道
▼
portal-nginx (统一认证反向代理, 容器)
│ auth_request → portal-auth(:4001) 统一登录
│ map $host → 按子域名分流
▼
navi 单容器 (Dashy+SearXNG+历史+管理, :3003)
│ 内层 nginx :8080 按路径分流
▼
Dashy / SearXNG(→Clash 代理出网) / 历史 API / 管理 UI
```
### navi 容器内部
```
┌─────────────────────────────────────────────────────┐
│ nginx (:8080) — 统一入口,同域名同端口 │
│ ├─ / → Dashy 静态仪表盘 │
│ ├─ /search → SearXNG 搜索 (:8888) │
│ ├─ /static/ → SearXNG 静态资源 (:8888) │
│ ├─ /api/history/ → 历史同步 API (:3004) │
│ ├─ /admin/ → 管理后台 WebUI (:3005) │
│ ├─ /conf.yml → 运行时导航配置(数据卷) │
│ └─ /item-icons/ → 自定义图标 (数据卷) │
└─────────────────────────────────────────────────────┘
```
**技术栈**:Go(历史 API + 管理 API)· SearXNG(Python) · Dashy/Vue · Nginx · Supervisord · frp/webui4frpc
## 快速开始
### 前置条件
- Docker + Docker Compose v2
- 可选:反向代理(Nginx / Caddy / Traefik)用于 HTTPS
### 1. 克隆并启动
```bash
git clone ssh://git@<your-gitea>:220/<owner>/HomeDash.git
cd HomeDash
cp .env.example .env # 编辑令牌
docker compose up -d
```
访问 `http://<your-ip>:3003`
### 2. 首次配置
编辑 `data/conf.yml` 自定义导航项,重启生效:
```bash
docker compose restart
```
### 3. 导入预构建镜像(可选)
如果无法构建,使用预导出的镜像:
```bash
docker load < navi-image.tar.gz
docker compose up -d
```
## 配置说明
### conf.yml 核心字段
```yaml
appConfig:
theme: default
webSearch:
searchEngine: custom
customSearchEngine: /search?q= # 同域名反代到 SearXNG
openingMethod: newtab
sections:
- name: 常用服务 # 按访问频次自动排序
icon: fas fa-star
collapsed: false
items:
- title: Gitea
icon: fas fa-code-branch
url: http://192.168.1.100:3000
statusCheck: true
```
### 图标
放入 `data/item-icons/`,conf.yml 引用文件名:
```yaml
icon: my-icon.png # 本地图标
icon: fas fa-star # Font Awesome
```
### 环境变量
| 变量 | 说明 | 默认 |
|------|------|------|
| `NAVI_SYNC_TOKEN` | 浏览器插件同步令牌 | 空(不鉴权) |
| `NAVI_ADMIN_TOKEN` | 管理后台写操作令牌 | 空(不鉴权) |
## 浏览器扩展
### 安装
**Chrome / Edge**:`chrome://extensions/` → 开发者模式 → 加载 `extension/chrome/`
**Firefox**:`about:debugwriting#/runtime/this-firefox` → 加载 `extension/firefox/manifest.json`
### 配置
| 字段 | 示例 |
|------|------|
| 服务地址 | `https://nav.example.com` |
| 同步令牌 | `.env` 中的 `NAVI_SYNC_TOKEN` |
| 设备名称 | `desktop-chrome` |
### 行为
- 每 30 秒检查本地队列,**仅在有新数据时 POST**
- 搜索引擎 URL 在本地过滤,不上传
- 「常用服务」和「历史访问」按总访问频次自动排序
## 管理后台
访问 `http://<your-ip>:3003/admin/` 可视化管理导航项。
API(需 `NAVI_ADMIN_TOKEN`):
```bash
# 查看
curl -H "Authorization: Bearer <token>" http://<ip>:3003/admin/api/sections
# 添加
curl -H "Authorization: Bearer <token>" -X POST -H 'Content-Type: application/json' \
-d '{"sectionIndex":0,"item":{"title":"New","url":"https://example.com"}}' \
http://<ip>:3003/admin/api/item
# 删除
curl -H "Authorization: Bearer <token>" -X DELETE -H 'Content-Type: application/json' \
-d '{"sectionIndex":0,"itemIndex":2}' \
http://<ip>:3003/admin/api/item/delete
```
## 历史同步 API
| 端点 | 方法 | 说明 |
|------|------|------|
| `/api/history/sync` | POST | 批量同步浏览记录 |
| `/api/history/` | GET | 获取历史(按频次排序) |
| `/api/history/` | DELETE | 清空全部历史 |
| `/api/history/stats` | GET | 统计信息 |
```json
// POST /api/history/sync
{
"items": [{
"url": "https://example.com/page",
"title": "Page Title",
"device": "desktop-chrome",
"timestamp": 1725800000,
"visit_count": 3
}]
}
```
## 生产部署:外层统一认证反向代理
> 内网门户通常不直接暴露 3003 端口,而是放在一个**带统一登录认证的反向代理**后面,
> 所有子域名共用一个登录入口。下面是本项目参考的 portal-nginx 方案。
### portal-nginx(统一入口容器)
结构:一个 nginx 容器负责所有 `*.example.com` 的 TLS 终止 + 按域名分流 + 统一认证。
```nginx
# 按域名映射到内网后端
map $host $backend {
hostnames;
default "";
navi.example.com http://127.0.0.1:3003; # 本导航容器
gitea.example.com http://127.0.0.1:3000;
blog.example.com http://127.0.0.1:8080;
}
# 需登录的域名统一走 auth_request
server {
listen 443 ssl;
http2 on;
server_name navi.example.com gitea.example.com blog.example.com;
auth_request /_auth; # 先问认证服务
error_page 401 = @login; # 未登录 → 跳登录页
location /_auth {
internal;
proxy_pass http://127.0.0.1:4001; # portal-auth 认证服务
proxy_pass_request_body off;
proxy_set_header Content-Length "";
proxy_set_header Cookie $http_cookie;
}
location @login {
return 302 https://login.example.com/login?redirect=$scheme://$host$request_uri;
}
location / {
if ($backend = "") { return 404; }
proxy_pass $backend;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
```
### portal-auth(统一认证服务)
一个极简 HTTP 认证服务(:4001),只做两件事:
1. `GET/POST /login` — 校验用户名密码,签发 `portal_session` cookie
2. `/_auth` 被 nginx `auth_request` 调用 — 校验 cookie 是否有效(200=通过,401=拒绝)
```python
# app.py(示意)
USERNAME = os.environ['PORTAL_USERNAME'] # admin
PASSWORD = os.environ['PORTAL_PASSWORD'] # 强密码
sessions = {} # token -> 过期时间
# /login 成功 → uuid token 写入 sessions + Set-Cookie
# /_auth 读 Cookie → 校验存在且未过期 → 200/401
```
部署:
```bash
docker run -d --name portal-auth \
-p 4001:4001 \
-e PORTAL_USERNAME=admin \
-e PORTAL_PASSWORD=change-me \
-v /path/auth/app.py:/app/app.py:ro \
python:3.11-slim
```
### 让搜索/管理子路径免登录(可选)
Dashy 域名整体在认证后,登录用户能正常搜索。若希望搜索页**免登录公开访问**,在对应 server 块加:
```nginx
location /search { auth_request off; proxy_pass $backend; }
```
> ⚠️ 管理后台 `/admin/` 与历史 API `/api/` **不应**免认证——
> 它们能修改导航与数据,建议保留登录墙,或改用 **Bearer Token**(见下文鉴权章节)。
## 内网穿透:webui4frpc + frp
> [webui4frpc](https://gitcode.com/JianFeeeee/webui4frpc) — 基于 Go 与 Web 的可视化 frpc 配置管理工具,
> 提供 WebUI 创建/启停 frpc 隧道,无需手写 frpc.toml。
### 拓扑
```
公网用户 → 阿里云 VPS frps(7000/8080/443) → frpc ← webui4frpc(.60:7500) 管理
→ portal-nginx(.106) 认证分流 → navi:3003
```
### 1. 公网 VPS 部署 frps
```bash
# frps.toml
bindPort = 7000
vhostHTTPPort = 8080
# ...按需配置 auth.token 与 TLS
```
### 2. 内网主机部署 webui4frpc
```bash
/opt/webui4frpc/webui4frpc \
-addr 0.0.0.0:7500 \
-user <admin> -password <强密码> \
-workdir /opt/webui4frpc/data
```
### 3. 在 WebUI 中创建隧道
webui4frpc 把配置存为 `configs/*.json`。一个隧道文件示例:
```json
{
"serverAddr": "<vps-public-ip>",
"serverPort": 7000,
"auth": { "method": "token", "token": "frp-token" },
"proxies": [
{
"name": "portal_https",
"type": "https",
"localIP": "192.168.1.100",
"localPort": 3080,
"customDomains": ["*.navi.example.com", "navi.example.com"]
},
{
"name": "portal_http",
"type": "tcp",
"localIP": "192.168.1.100",
"localPort": 3081,
"remotePort": 8080
}
]
}
```
- `type: https` + `customDomains` → frps 把 443 的域名流量转发给内网 portal-nginx 的 3080(TLS 终止在内网)
- `type: tcp` 用于无域名的端口映射(如 Gitea SSH、HTTP 跳转)
### 4. frpc 客户端生成
webui4frpc 自动下载并启动 `frpc` 二进制(`data/bin/`),配置变更后热加载。
多份配置 = 多个 frpc 进程,各自连接 frps。
## 鉴权(历史同步 + 管理 API)
导航门户的 `/api/` 和 `/admin/api/` 能读写数据,**不应裸奔**。推荐两层:
1. **外层**:portal-nginx 保留登录墙(`/admin/` 页面需登录)
2. **内层**:Go 服务校验 `Authorization: Bearer <token>`
部署时通过环境变量注入令牌:
```bash
docker run -d ... \
-e NAVI_SYNC_TOKEN=<长随机串> \ # 浏览器扩展同步用
-e NAVI_ADMIN_TOKEN=<长随机串> \ # 管理 API 写操作用
jianf-navi:latest
```
浏览器扩展在设置里填同一个 `NAVI_SYNC_TOKEN`,每次同步请求带 `Authorization: Bearer <token>`。
```bash
# 管理 API 示例(写操作必须带 admin token)
curl -H "Authorization: Bearer <NAVI_ADMIN_TOKEN>" \
-X POST -H 'Content-Type: application/json' \
-d '{"sectionIndex":0,"item":{"title":"New","url":"https://example.com"}}' \
http://<ip>:3003/admin/api/item
```
> 实现参考:`history-server` 的 `/sync` 校验 `NAVI_SYNC_TOKEN`,
> `admin-server` 的写操作校验 `NAVI_ADMIN_TOKEN`。
## 数据备份
```
data/
├── conf.yml # 导航配置
├── history.json # 浏览历史
└── item-icons/ # 自定义图标
```
```bash
# 自动备份
0 2 * * * docker exec navi tar czf /tmp/bak.tar.gz -C /app/data . && \
docker cp navi:/tmp/bak.tar.gz /backup/navi-$(date +\%Y\%m\%d).tar.gz
```
## 项目结构
```
├── history-server/ # Go 历史同步 API (:3004)
├── admin-server/ # Go 管理后台 API (:3005)
├── admin/ # 管理后台前端
├── web/ # Dashy 注入脚本
├── extension/ # 浏览器扩展 (Chrome/Firefox)
├── nginx.conf # 容器内统一反代
├── supervisord.conf # 进程管理
├── conf.example.yml # 脱敏示例配置
├── searxng-settings.yml
├── docker-compose.yml
├── Dockerfile
└── .env.example
```
## 常见问题
**搜索无结果**:确认容器能访问搜索引擎。国内服务器建议只启用百度/Bing中国/搜狗。
**管理后台 401**:设置了 `NAVI_ADMIN_TOKEN` 时需携带 Bearer token。
**历史不显示**:检查扩展配置中的服务地址和同步令牌。
**内存占用**:正常约 100MB。
## 许可
MIT

5
admin-server/go.mod Normal file
View File

@ -0,0 +1,5 @@
module admin-server
go 1.21
require gopkg.in/yaml.v3 v3.0.1

4
admin-server/go.sum Normal file
View File

@ -0,0 +1,4 @@
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

215
admin-server/main.go Normal file
View File

@ -0,0 +1,215 @@
package main
import (
"encoding/json"
"log"
"net/http"
"os"
"path/filepath"
"strings"
"sync"
"gopkg.in/yaml.v3"
)
var (
confFile = envOr("DASHY_CONF", "/app/data/conf.yml")
mu sync.Mutex
)
type Config struct {
AppConfig map[string]any `yaml:"appConfig"`
PageInfo map[string]any `yaml:"pageInfo"`
Sections []Section `yaml:"sections"`
Pages []any `yaml:"pages,omitempty"`
}
type Section struct {
Name string `yaml:"name"`
Icon string `yaml:"icon,omitempty"`
Collapsed *bool `yaml:"collapsed,omitempty"`
Items []NavItem `yaml:"items"`
}
type NavItem struct {
Title string `yaml:"title"`
Description string `yaml:"description,omitempty"`
Icon string `yaml:"icon,omitempty"`
URL string `yaml:"url"`
StatusCheck *bool `yaml:"statusCheck,omitempty"`
}
func envOr(k, def string) string {
if v := os.Getenv(k); v != "" {
return v
}
return def
}
func loadConfig() (*Config, error) {
data, err := os.ReadFile(confFile)
if err != nil {
return nil, err
}
var cfg Config
if err := yaml.Unmarshal(data, &cfg); err != nil {
return nil, err
}
return &cfg, nil
}
func saveConfig(cfg *Config) error {
data, err := yaml.Marshal(cfg)
if err != nil {
return err
}
tmp := confFile + ".tmp"
if err := os.WriteFile(tmp, data, 0644); err != nil {
return err
}
return os.Rename(tmp, confFile)
}
func cors(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
if r.Method == http.MethodOptions {
w.WriteHeader(204)
return
}
next(w, r)
}
}
func jsonResp(w http.ResponseWriter, v any) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(v)
}
func jsonErr(w http.ResponseWriter, status int, msg string) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(map[string]string{"error": msg})
}
func handleSections(w http.ResponseWriter, r *http.Request) {
mu.Lock()
defer mu.Unlock()
cfg, err := loadConfig()
if err != nil {
jsonErr(w, 500, err.Error())
return
}
jsonResp(w, cfg.Sections)
}
func handleAddItem(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
jsonErr(w, 405, "method not allowed")
return
}
mu.Lock()
defer mu.Unlock()
var req struct {
SectionIndex int `json:"sectionIndex"`
Item NavItem `json:"item"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
jsonErr(w, 400, err.Error())
return
}
if req.Item.Title == "" || req.Item.URL == "" {
jsonErr(w, 400, "title and url required")
return
}
// Block javascript: URLs
if strings.HasPrefix(strings.ToLower(req.Item.URL), "javascript:") {
jsonErr(w, 400, "javascript: URLs not allowed")
return
}
cfg, err := loadConfig()
if err != nil {
jsonErr(w, 500, err.Error())
return
}
if req.SectionIndex < 0 || req.SectionIndex >= len(cfg.Sections) {
jsonErr(w, 400, "invalid section index")
return
}
cfg.Sections[req.SectionIndex].Items = append(cfg.Sections[req.SectionIndex].Items, req.Item)
if err := saveConfig(cfg); err != nil {
jsonErr(w, 500, err.Error())
return
}
jsonResp(w, map[string]string{"status": "ok"})
}
func handleDeleteItem(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodDelete {
jsonErr(w, 405, "method not allowed")
return
}
mu.Lock()
defer mu.Unlock()
var req struct {
SectionIndex int `json:"sectionIndex"`
ItemIndex int `json:"itemIndex"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
jsonErr(w, 400, err.Error())
return
}
cfg, err := loadConfig()
if err != nil {
jsonErr(w, 500, err.Error())
return
}
if req.SectionIndex < 0 || req.SectionIndex >= len(cfg.Sections) {
jsonErr(w, 400, "invalid section index")
return
}
sec := &cfg.Sections[req.SectionIndex]
if req.ItemIndex < 0 || req.ItemIndex >= len(sec.Items) {
jsonErr(w, 400, "invalid item index")
return
}
sec.Items = append(sec.Items[:req.ItemIndex], sec.Items[req.ItemIndex+1:]...)
if err := saveConfig(cfg); err != nil {
jsonErr(w, 500, err.Error())
return
}
jsonResp(w, map[string]string{"status": "ok"})
}
func main() {
// Ensure conf file exists
if _, err := os.Stat(confFile); os.IsNotExist(err) {
defaultConf := envOr("NAVI_DEFAULT_CONFIG", "/app/defaults/conf.yml")
if data, err := os.ReadFile(defaultConf); err == nil {
os.MkdirAll(filepath.Dir(confFile), 0755)
os.WriteFile(confFile, data, 0644)
}
}
// Serve admin UI static files (nginx strips /admin/ prefix)
http.Handle("/", http.FileServer(http.Dir("/app/admin")))
http.HandleFunc("/api/sections", cors(handleSections))
http.HandleFunc("/api/item", cors(handleAddItem))
http.HandleFunc("/api/item/delete", cors(handleDeleteItem))
http.HandleFunc("/api/reload", cors(func(w http.ResponseWriter, r *http.Request) {
jsonResp(w, map[string]string{"status": "saved"})
}))
http.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
jsonResp(w, map[string]string{"status": "ok"})
})
log.Println("admin-server listening on :3005")
log.Fatal(http.ListenAndServe(":3005", nil))
}

62
admin/index.html Normal file
View File

@ -0,0 +1,62 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>导航管理</title>
<style>
*{box-sizing:border-box;margin:0;padding:0}
body{font-family:system-ui,-apple-system,sans-serif;background:#f0f9ff;min-height:100vh;padding:24px}
.wrap{max-width:800px;margin:0 auto}
h1{font-size:22px;font-weight:800;color:#0f172a;margin-bottom:16px}
.card{background:rgba(255,255,255,.9);border-radius:14px;padding:18px;margin-bottom:14px;border:1px solid rgba(0,0,0,.06);box-shadow:0 2px 8px rgba(0,0,0,.04)}
.sec-hd{display:flex;justify-content:space-between;align-items:center;margin-bottom:10px}
.sec-nm{font-weight:700;color:#0f172a}
.badge{background:#0ea5e9;color:#fff;font-size:11px;padding:2px 7px;border-radius:9px;font-weight:600}
.itm{display:flex;align-items:center;gap:10px;padding:8px 12px;border-radius:10px;margin-bottom:4px;background:rgba(255,255,255,.5);transition:.15s}
.itm:hover{background:rgba(255,255,255,.85)}
.itm-i{flex:1;min-width:0}
.itm-t{font-weight:600;color:#1e293b;font-size:13px}
.itm-u{color:#64748b;font-size:11px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.btn-x{background:none;border:none;color:#ef4444;cursor:pointer;font-size:16px;padding:2px 6px;border-radius:6px}
.btn-x:hover{background:rgba(239,68,68,.1)}
.frm{background:rgba(255,255,255,.9);border-radius:14px;padding:18px;margin-top:18px;border:1px solid rgba(0,0,0,.06)}
.frm h2{font-size:16px;font-weight:700;margin-bottom:12px}
.row{display:flex;gap:10px;margin-bottom:10px;flex-wrap:wrap}
.fg{display:flex;flex-direction:column;gap:3px;flex:1;min-width:180px}
.fg label{font-size:12px;font-weight:600;color:#334155}
.fg input,.fg select{padding:8px 12px;border-radius:10px;border:1px solid rgba(0,0,0,.1);font-size:13px}
.btn-add{background:linear-gradient(135deg,#0ea5e9,#38bdf8);color:#fff;border:none;padding:10px 24px;border-radius:10px;font-weight:700;cursor:pointer;margin-top:6px}
.toast{position:fixed;top:16px;right:16px;background:#22c55e;color:#fff;padding:10px 16px;border-radius:10px;font-weight:600;z-index:9999;animation:sl .3s}
@keyframes sl{from{transform:translateX(100%);opacity:0}to{transform:none;opacity:1}}
</style>
</head>
<body>
<div class="wrap">
<h1> 导航管理</h1>
<div id="app"><p style="color:#64748b;text-align:center;padding:32px">加载中…</p></div>
<div class="frm">
<h2> 添加导航项</h2>
<div class="row"><div class="fg"><label>所属分组</label><select id="sel"></select></div></div>
<div class="row">
<div class="fg"><label>名称 *</label><input id="ti" placeholder="Gitea" required></div>
<div class="fg"><label>描述</label><input id="de" placeholder="代码仓库"></div>
</div>
<div class="row">
<div class="fg"><label>URL *</label><input id="ur" placeholder="https://example.com" required></div>
<div class="fg" style="max-width:180px"><label>图标</label><input id="ic" placeholder="fas fa-star"></div>
</div>
<button class="btn-add" onclick="add()">添加</button>
</div>
</div>
<script>
let S=[];const A='/admin/api';
async function L(){const r=await fetch(A+'/sections');S=await r.json();R();document.getElementById('sel').innerHTML=S.map((s,i)=>`<option value="${i}">${s.name}</option>`).join('')}
function R(){document.getElementById('app').innerHTML=S.map((s,si)=>`<div class="card"><div class="sec-hd"><span class="sec-nm">${s.name}</span><span class="badge">${s.items.length}</span></div>${s.items.map((it,ii)=>`<div class="itm"><div class="itm-i"><div class="itm-t">${it.title}</div><div class="itm-u">${it.url}</div></div><button class="btn-x" onclick="D(${si},${ii})" title="删除">✕</button></div>`).join('')}</div>`).join('')}
async function add(){const t=document.getElementById('ti').value.trim(),u=document.getElementById('ur').value.trim();if(!t||!u){toast('请填写名称和URL','#ef4444');return}const si=+document.getElementById('sel').value;const r=await fetch(A+'/item',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({sectionIndex:si,item:{title:t,description:document.getElementById('de').value.trim(),icon:document.getElementById('ic').value.trim(),url:u}})});if(r.ok){toast('已添加');['ti','de','ur','ic'].forEach(id=>document.getElementById(id).value='');L()}else toast('添加失败','#ef4444')}
async function D(si,ii){if(!confirm('确定删除「'+S[si].items[ii].title+'」?'))return;const r=await fetch(A+'/item/delete',{method:'DELETE',headers:{'Content-Type':'application/json'},body:JSON.stringify({sectionIndex:si,itemIndex:ii})});if(r.ok){toast('已删除');L()}}
function toast(m,b='#22c55e'){const d=document.createElement('div');d.className='toast';d.textContent=m;d.style.background=b;document.body.appendChild(d);setTimeout(()=>d.remove(),2500)}
L()
</script>
</body>
</html>

53
conf.example.yml Normal file
View File

@ -0,0 +1,53 @@
appConfig:
theme: default
dayTheme: default
nightTheme: midnight
customColors: {}
externalStyleSheet: []
webSearch:
searchEngine: custom
customSearchEngine: /search?q=
openingMethod: newtab
customCss: ""
layout: masonry
iconSize: large
language: zh-CN
statusCheck: true
statusCheckInterval: 60
pageInfo:
title: Navigation Portal
description: Self-hosted unified navigation
navLinks: []
footer: ""
sections:
- name: Code Hosting
icon: fas fa-code-branch
collapsed: false
items:
- title: Gitea
description: Code repository
icon: fas fa-code-branch
url: http://192.168.1.100:3000
statusCheck: true
- name: History
icon: fas fa-history
collapsed: false
items:
- title: Install browser extension...
description: History fills automatically after sync
icon: fas fa-info-circle
url: /admin/
- name: Tools
icon: fas fa-tools
collapsed: true
items:
- title: Example Service
description: Add your own services
icon: fas fa-cog
url: http://192.168.1.100:8080
pages: []

32
docker-compose.yml Normal file
View File

@ -0,0 +1,32 @@
version: "3.8"
services:
navi:
build: .
container_name: jianf-navi
image: jianf-navi:latest
restart: unless-stopped
ports:
- "3003:8080"
volumes:
- ./data:/app/data
environment:
# 设置后,浏览器插件同步和管理 UI 的写操作需要此令牌
- NAVI_SYNC_TOKEN=${NAVI_SYNC_TOKEN:-}
# 设置后,管理 API 的写操作需要此令牌
- NAVI_ADMIN_TOKEN=${NAVI_ADMIN_TOKEN:-}
healthcheck:
test: ["CMD", "wget", "-q", "--spider", "http://localhost:8080/healthz"]
interval: 30s
timeout: 5s
start_period: 30s
retries: 3
deploy:
resources:
limits:
memory: 512M
# 使用方式:
# 1. 复制 .env.example 为 .env 并修改
# 2. docker compose up -d
# 3. 访问 http://your-ip:3003

48
extension/README.md Normal file
View File

@ -0,0 +1,48 @@
# JianF Nav Sync — 浏览器扩展
将浏览历史同步到 JianF 统一导航门户,按总访问频次自动排序显示。
## 安装
### Chrome / Edge(MV3)
1. 打开 `chrome://extensions/`
2. 开启「开发者模式」
3. 点击「加载已解压的扩展程序」,选择 `chrome/` 目录
4. Edge 同理:`edge://extensions/`
### Firefox(MV2)
1. 打开 `about:debugging#/runtime/this-firefox`
2. 点击「临时加载附加组件」,选择 `firefox/manifest.json`
3. 如需永久安装:打包后通过 `about:addons` 安装
### iOS
Safari 不支持自定义扩展直接注入 `chrome.history` API。
推荐方案:使用 Safari Web Extension(需 Xcode)或通过快捷指令定时拉取。
## 配置
点击扩展图标 → 设置:
| 字段 | 说明 |
|------|------|
| 服务地址 | `https://nav.example.com`(你自己的导航服务地址) |
| 同步令牌 | 服务端环境变量 `NAVI_SYNC_TOKEN` 的值,留空则不鉴权 |
| 设备名称 | 用于区分多端,如 `desktop-chrome`、`android-chrome` |
## 行为
- 每 30 秒检查一次本地队列
- **仅在有待同步数据时发送 POST 请求**(无空轮询)
- 搜索引擎 URL 在本地过滤,不上传
- 同步失败时数据保留,下次重试
- 导航栏「历史访问」分组按 `total_visits` 降序自动填充
## 技术细节
- Chrome/Edge: Manifest V3 (Service Worker)
- Firefox: Manifest V2 (Event Page)
- 存储: `chrome.storage.local`
- API: `chrome.history.onVisited` + `chrome.alarms`

View File

@ -0,0 +1,132 @@
// JianF Nav Sync — Chrome/Edge MV3 service worker
'use strict';
const ALARM_NAME = 'navi-sync';
const SYNC_INTERVAL_MINUTES = 0.5; // 30 seconds
const SEARCH_DOMAINS = new Set([
'google.com', 'www.google.com', 'baidu.com', 'www.baidu.com',
'bing.com', 'www.bing.com', 'duckduckgo.com', 'www.duckduckgo.com',
'sogou.com', 'www.sogou.com', 'so.com', 'www.so.com',
'yahoo.com', 'www.yahoo.com', 'ask.com', 'www.ask.com',
'ecosia.org', 'www.ecosia.org', 'qwant.com', 'www.qwant.com',
]);
let pendingItems = [];
// Load pending from storage on startup
chrome.storage.local.get(['pending', 'serverUrl', 'syncToken'], (data) => {
pendingItems = data.pending || [];
});
// Listen to history visits
chrome.history.onVisited.addListener((historyItem) => {
try {
const url = new URL(historyItem.url);
// Skip non-http(s)
if (url.protocol !== 'http:' && url.protocol !== 'https:') return;
// Skip search engine URLs
const domain = url.hostname.replace(/^www\./, '');
if (SEARCH_DOMAINS.has(domain)) return;
pendingItems.push({
url: historyItem.url,
title: historyItem.title || '',
timestamp: historyItem.lastVisitTime / 1000, // ms -> seconds
visit_count: historyItem.visitCount || 1,
});
chrome.storage.local.set({ pending: pendingItems });
} catch (e) {
// invalid URL, skip
}
});
// Periodic sync alarm
chrome.alarms.create(ALARM_NAME, { periodInMinutes: SYNC_INTERVAL_MINUTES });
chrome.alarms.onAlarm.addListener((alarm) => {
if (alarm.name !== ALARM_NAME) return;
syncNow();
});
// Manual sync from options page
chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
if (msg.action === 'sync') {
syncNow().then((result) => sendResponse(result)).catch((e) => sendResponse({ error: e.message }));
return true; // async response
}
if (msg.action === 'getConfig') {
chrome.storage.local.get(['serverUrl', 'syncToken', 'deviceName'], (data) => {
sendResponse(data);
});
return true;
}
if (msg.action === 'setConfig') {
chrome.storage.local.set(msg.data, () => sendResponse({ ok: true }));
return true;
}
if (msg.action === 'clearPending') {
pendingItems = [];
chrome.storage.local.set({ pending: [] }, () => sendResponse({ ok: true }));
return true;
}
});
function detectDevice() {
const ua = navigator.userAgent.toLowerCase();
if (/mobile|android|iphone|ipad/.test(ua)) {
if (/firefox/.test(ua)) return 'mobile-firefox';
if (/edg/.test(ua)) return 'mobile-edge';
return 'mobile-chrome';
}
if (/firefox/.test(ua)) return 'desktop-firefox';
if (/edg/.test(ua)) return 'desktop-edge';
return 'desktop-chrome';
}
async function syncNow() {
if (pendingItems.length === 0) return { ok: true, skipped: true, reason: 'no pending' };
const { serverUrl, syncToken, deviceName } = await new Promise((resolve) => {
chrome.storage.local.get(['serverUrl', 'syncToken', 'deviceName'], resolve);
});
if (!serverUrl) return { ok: false, reason: 'server not configured' };
// Snapshot and clear pending
const toSend = [...pendingItems];
pendingItems = [];
chrome.storage.local.set({ pending: [] });
const body = JSON.stringify({
items: toSend.map((item) => ({
...item,
device: deviceName || detectDevice(),
})),
});
try {
const headers = { 'Content-Type': 'application/json' };
if (syncToken) headers['Authorization'] = `Bearer ${syncToken}`;
const resp = await fetch(`${serverUrl}/api/history/sync`, {
method: 'POST',
headers,
body,
});
if (!resp.ok) {
const text = await resp.text();
// On failure, put items back
pendingItems = [...toSend, ...pendingItems];
chrome.storage.local.set({ pending: pendingItems });
return { ok: false, status: resp.status, error: text };
}
return await resp.json();
} catch (e) {
// Network error — put items back
pendingItems = [...toSend, ...pendingItems];
chrome.storage.local.set({ pending: pendingItems });
return { ok: false, error: e.message };
}
}

View File

@ -0,0 +1,22 @@
{
"name": "JianF Nav Sync",
"version": "1.0.0",
"description": "Sync browsing history to JianF unified navigation portal",
"permissions": [
"history",
"storage",
"alarms"
],
"host_permissions": [
"<all_urls>"
],
"background": {
"service_worker": "background.js"
},
"icons": {
"16": "icons/icon16.png",
"48": "icons/icon48.png",
"128": "icons/icon128.png"
},
"options_page": "options.html"
}

View File

@ -0,0 +1,51 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>JianF Nav Sync 设置</title>
<style>
*{box-sizing:border-box;margin:0;padding:0}
body{font-family:system-ui,sans-serif;padding:24px;max-width:500px;margin:0 auto}
h1{font-size:18px;font-weight:700;margin-bottom:16px}
label{display:block;font-size:13px;font-weight:600;margin:10px 0 4px;color:#334155}
input{width:100%;padding:8px 12px;border:1px solid #ddd;border-radius:8px;font-size:14px}
button{margin-top:14px;padding:8px 20px;border:none;border-radius:8px;font-weight:600;cursor:pointer;font-size:14px}
.save{background:#0ea5e9;color:#fff}
.sync{background:#22c55e;color:#fff;margin-left:8px}
.status{margin-top:12px;font-size:12px;color:#64748b}
</style>
</head>
<body>
<h1> JianF Nav Sync</h1>
<label>导航服务地址</label>
<input id="url" placeholder="https://nav.example.com">
<label>同步令牌(可选)</label>
<input id="token" type="password" placeholder="NAVI_SYNC_TOKEN">
<label>设备名称</label>
<input id="device" placeholder="desktop-chrome">
<div>
<button class="save" onclick="save()">保存</button>
<button class="sync" onclick="doSync()">立即同步</button>
</div>
<div class="status" id="status"></div>
<script>
chrome.runtime.sendMessage({action:'getConfig'},(d)=>{
document.getElementById('url').value=d.serverUrl||'';
document.getElementById('token').value=d.syncToken||'';
document.getElementById('device').value=d.deviceName||'';
});
function save(){
chrome.runtime.sendMessage({action:'setConfig',data:{
serverUrl:document.getElementById('url').value.replace(/\/$/,''),
syncToken:document.getElementById('token').value,
deviceName:document.getElementById('device').value,
}},()=>{document.getElementById('status').textContent='已保存 OK'});
}
function doSync(){
chrome.runtime.sendMessage({action:'sync'},(r)=>{
document.getElementById('status').textContent=r.skipped?'无待同步':r.ok?`同步 ${r.count} 条,共 ${r.total}`:`失败: ${r.error||r.status}`;
});
}
</script>
</body>
</html>

View File

@ -0,0 +1,132 @@
// JianF Nav Sync — Chrome/Edge MV3 service worker
'use strict';
const ALARM_NAME = 'navi-sync';
const SYNC_INTERVAL_MINUTES = 0.5; // 30 seconds
const SEARCH_DOMAINS = new Set([
'google.com', 'www.google.com', 'baidu.com', 'www.baidu.com',
'bing.com', 'www.bing.com', 'duckduckgo.com', 'www.duckduckgo.com',
'sogou.com', 'www.sogou.com', 'so.com', 'www.so.com',
'yahoo.com', 'www.yahoo.com', 'ask.com', 'www.ask.com',
'ecosia.org', 'www.ecosia.org', 'qwant.com', 'www.qwant.com',
]);
let pendingItems = [];
// Load pending from storage on startup
chrome.storage.local.get(['pending', 'serverUrl', 'syncToken'], (data) => {
pendingItems = data.pending || [];
});
// Listen to history visits
chrome.history.onVisited.addListener((historyItem) => {
try {
const url = new URL(historyItem.url);
// Skip non-http(s)
if (url.protocol !== 'http:' && url.protocol !== 'https:') return;
// Skip search engine URLs
const domain = url.hostname.replace(/^www\./, '');
if (SEARCH_DOMAINS.has(domain)) return;
pendingItems.push({
url: historyItem.url,
title: historyItem.title || '',
timestamp: historyItem.lastVisitTime / 1000, // ms -> seconds
visit_count: historyItem.visitCount || 1,
});
chrome.storage.local.set({ pending: pendingItems });
} catch (e) {
// invalid URL, skip
}
});
// Periodic sync alarm
chrome.alarms.create(ALARM_NAME, { periodInMinutes: SYNC_INTERVAL_MINUTES });
chrome.alarms.onAlarm.addListener((alarm) => {
if (alarm.name !== ALARM_NAME) return;
syncNow();
});
// Manual sync from options page
chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
if (msg.action === 'sync') {
syncNow().then((result) => sendResponse(result)).catch((e) => sendResponse({ error: e.message }));
return true; // async response
}
if (msg.action === 'getConfig') {
chrome.storage.local.get(['serverUrl', 'syncToken', 'deviceName'], (data) => {
sendResponse(data);
});
return true;
}
if (msg.action === 'setConfig') {
chrome.storage.local.set(msg.data, () => sendResponse({ ok: true }));
return true;
}
if (msg.action === 'clearPending') {
pendingItems = [];
chrome.storage.local.set({ pending: [] }, () => sendResponse({ ok: true }));
return true;
}
});
function detectDevice() {
const ua = navigator.userAgent.toLowerCase();
if (/mobile|android|iphone|ipad/.test(ua)) {
if (/firefox/.test(ua)) return 'mobile-firefox';
if (/edg/.test(ua)) return 'mobile-edge';
return 'mobile-chrome';
}
if (/firefox/.test(ua)) return 'desktop-firefox';
if (/edg/.test(ua)) return 'desktop-edge';
return 'desktop-chrome';
}
async function syncNow() {
if (pendingItems.length === 0) return { ok: true, skipped: true, reason: 'no pending' };
const { serverUrl, syncToken, deviceName } = await new Promise((resolve) => {
chrome.storage.local.get(['serverUrl', 'syncToken', 'deviceName'], resolve);
});
if (!serverUrl) return { ok: false, reason: 'server not configured' };
// Snapshot and clear pending
const toSend = [...pendingItems];
pendingItems = [];
chrome.storage.local.set({ pending: [] });
const body = JSON.stringify({
items: toSend.map((item) => ({
...item,
device: deviceName || detectDevice(),
})),
});
try {
const headers = { 'Content-Type': 'application/json' };
if (syncToken) headers['Authorization'] = `Bearer ${syncToken}`;
const resp = await fetch(`${serverUrl}/api/history/sync`, {
method: 'POST',
headers,
body,
});
if (!resp.ok) {
const text = await resp.text();
// On failure, put items back
pendingItems = [...toSend, ...pendingItems];
chrome.storage.local.set({ pending: pendingItems });
return { ok: false, status: resp.status, error: text };
}
return await resp.json();
} catch (e) {
// Network error — put items back
pendingItems = [...toSend, ...pendingItems];
chrome.storage.local.set({ pending: pendingItems });
return { ok: false, error: e.message };
}
}

View File

@ -0,0 +1,26 @@
{
"name": "JianF Nav Sync",
"version": "1.0.0",
"description": "Sync browsing history to JianF unified navigation portal",
"permissions": [
"history",
"storage",
"alarms"
],
"background": {
"scripts": ["background.js"],
"persistent": false
},
"browser_action": {
"default_icon": "icons/icon48.png",
"default_popup": "options.html"
},
"options_ui": {
"page": "options.html",
"open_in_tab": false
},
"icons": {
"48": "icons/icon48.png",
"96": "icons/icon128.png"
}
}

View File

@ -0,0 +1,51 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>JianF Nav Sync 设置</title>
<style>
*{box-sizing:border-box;margin:0;padding:0}
body{font-family:system-ui,sans-serif;padding:24px;max-width:500px;margin:0 auto}
h1{font-size:18px;font-weight:700;margin-bottom:16px}
label{display:block;font-size:13px;font-weight:600;margin:10px 0 4px;color:#334155}
input{width:100%;padding:8px 12px;border:1px solid #ddd;border-radius:8px;font-size:14px}
button{margin-top:14px;padding:8px 20px;border:none;border-radius:8px;font-weight:600;cursor:pointer;font-size:14px}
.save{background:#0ea5e9;color:#fff}
.sync{background:#22c55e;color:#fff;margin-left:8px}
.status{margin-top:12px;font-size:12px;color:#64748b}
</style>
</head>
<body>
<h1> JianF Nav Sync</h1>
<label>导航服务地址</label>
<input id="url" placeholder="https://nav.example.com">
<label>同步令牌(可选)</label>
<input id="token" type="password" placeholder="NAVI_SYNC_TOKEN">
<label>设备名称</label>
<input id="device" placeholder="desktop-chrome">
<div>
<button class="save" onclick="save()">保存</button>
<button class="sync" onclick="doSync()">立即同步</button>
</div>
<div class="status" id="status"></div>
<script>
chrome.runtime.sendMessage({action:'getConfig'},(d)=>{
document.getElementById('url').value=d.serverUrl||'';
document.getElementById('token').value=d.syncToken||'';
document.getElementById('device').value=d.deviceName||'';
});
function save(){
chrome.runtime.sendMessage({action:'setConfig',data:{
serverUrl:document.getElementById('url').value.replace(/\/$/,''),
syncToken:document.getElementById('token').value,
deviceName:document.getElementById('device').value,
}},()=>{document.getElementById('status').textContent='已保存 OK'});
}
function doSync(){
chrome.runtime.sendMessage({action:'sync'},(r)=>{
document.getElementById('status').textContent=r.skipped?'无待同步':r.ok?`同步 ${r.count} 条,共 ${r.total}`:`失败: ${r.error||r.status}`;
});
}
</script>
</body>
</html>

5
history-server/go.mod Normal file
View File

@ -0,0 +1,5 @@
module history-server
go 1.21
require gopkg.in/yaml.v3 v3.0.1 // indirect

3
history-server/go.sum Normal file
View File

@ -0,0 +1,3 @@
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

364
history-server/main.go Normal file
View File

@ -0,0 +1,364 @@
package main
import (
"encoding/json"
"fmt"
"log"
"net/http"
"net/url"
"os"
"path/filepath"
"sort"
"strings"
"sync"
"gopkg.in/yaml.v3"
)
var (
dataDir = envOr("NAVI_DATA_DIR", "/app/data")
historyFile = filepath.Join(dataDir, "history.json")
syncToken = os.Getenv("NAVI_SYNC_TOKEN")
adminToken = os.Getenv("NAVI_ADMIN_TOKEN")
mu sync.RWMutex
history = map[string]*Entry{}
)
type Entry struct {
URL string `json:"url"`
Title string `json:"title"`
TotalVisits int `json:"total_visits"`
LastVisit float64 `json:"last_visit"`
Devices []string `json:"devices"`
}
type SyncItem struct {
URL string `json:"url"`
Title string `json:"title"`
Device string `json:"device"`
Timestamp float64 `json:"timestamp"`
VisitCount int `json:"visit_count"`
}
type SyncRequest struct {
Items []SyncItem `json:"items"`
}
func envOr(k, def string) string {
if v := os.Getenv(k); v != "" {
return v
}
return def
}
func load() {
data, err := os.ReadFile(historyFile)
if err != nil {
return
}
var entries []*Entry
if err := json.Unmarshal(data, &entries); err != nil {
log.Printf("load error: %v", err)
return
}
for _, e := range entries {
history[e.URL] = e
}
log.Printf("history: loaded %d entries", len(history))
}
func save() {
entries := make([]*Entry, 0, len(history))
for _, e := range history {
entries = append(entries, e)
}
data, _ := json.MarshalIndent(entries, "", " ")
os.MkdirAll(filepath.Dir(historyFile), 0755)
os.WriteFile(historyFile, data, 0644)
}
func cors(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
if r.Method == http.MethodOptions {
w.WriteHeader(204)
return
}
next(w, r)
}
}
func handleSync(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", 405)
return
}
var req SyncRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, err.Error(), 400)
return
}
if len(req.Items) == 0 || len(req.Items) > 1000 {
http.Error(w, "items must be 1-1000", 400)
return
}
mu.Lock()
defer mu.Unlock()
for _, item := range req.Items {
normalized, err := normalizeURL(item.URL)
if err != nil {
continue
}
if item.VisitCount <= 0 {
item.VisitCount = 1
}
if item.Device == "" {
item.Device = "unknown"
}
if e, ok := history[normalized]; ok {
e.TotalVisits += item.VisitCount
if item.Timestamp > e.LastVisit {
e.LastVisit = item.Timestamp
}
if len(item.Title) > len(e.Title) {
e.Title = item.Title
}
found := false
for _, d := range e.Devices {
if d == item.Device {
found = true
break
}
}
if !found {
e.Devices = append(e.Devices, item.Device)
}
} else {
history[normalized] = &Entry{
URL: normalized, Title: item.Title, TotalVisits: item.VisitCount,
LastVisit: item.Timestamp, Devices: []string{item.Device},
}
}
}
save()
sortDashyFavorites()
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{"status": "ok", "count": len(req.Items), "total": len(history)})
}
func handleGet(w http.ResponseWriter, r *http.Request) {
mu.RLock()
defer mu.RUnlock()
entries := make([]*Entry, 0, len(history))
for _, e := range history {
entries = append(entries, e)
}
sort.Slice(entries, func(i, j int) bool {
return entries[i].TotalVisits > entries[j].TotalVisits
})
if len(entries) > 100 {
entries = entries[:100]
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{"items": entries})
}
func handleClear(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodDelete {
http.Error(w, "method not allowed", 405)
return
}
mu.Lock()
defer mu.Unlock()
history = map[string]*Entry{}
save()
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{"status": "cleared"})
}
func handleStats(w http.ResponseWriter, r *http.Request) {
mu.RLock()
defer mu.RUnlock()
total := 0
for _, e := range history {
total += e.TotalVisits
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{"unique_urls": len(history), "total_visits": total})
}
func normalizeURL(raw string) (string, error) {
u, err := url.Parse(raw)
if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" {
return "", fmt.Errorf("invalid url: %s", raw)
}
u.Fragment = ""
u.Host = strings.ToLower(u.Host)
return u.String(), nil
}
// sortDashyFavorites reads conf.yml and sorts the first section's items by visit frequency.
func sortDashyFavorites() {
confFile := filepath.Join(dataDir, "conf.yml")
data, err := os.ReadFile(confFile)
if err != nil {
return
}
var cfg map[string]any
if err := yaml.Unmarshal(data, &cfg); err != nil {
return
}
sectionsRaw, ok := cfg["sections"].([]any)
if !ok || len(sectionsRaw) == 0 {
return
}
// Find sections by name
var favIdx, histIdx int = -1, -1
for i, s := range sectionsRaw {
m, ok := s.(map[string]any)
if !ok {
continue
}
name, _ := m["name"].(string)
switch name {
case "\u5e38\u7528\u670d\u52a1": // 常用服务
favIdx = i
case "\u5386\u53f2\u8bbf\u95ee": // 历史访问
histIdx = i
}
}
// Sort 常用服务 by visit count
if favIdx >= 0 {
sec := sectionsRaw[favIdx].(map[string]any)
itemsRaw, _ := sec["items"].([]any)
if len(itemsRaw) > 0 {
sort.SliceStable(itemsRaw, func(i, j int) bool {
ii, _ := itemsRaw[i].(map[string]any)
ij, _ := itemsRaw[j].(map[string]any)
ui, _ := ii["url"].(string)
uj, _ := ij["url"].(string)
vi := visitCount(ui)
vj := visitCount(uj)
return vi > vj
})
sec["items"] = itemsRaw
}
}
// Rebuild 历史访问 from history data (top 20, excluding URLs already in 常用服务)
if histIdx >= 0 {
favURLs := map[string]bool{}
if favIdx >= 0 {
sec := sectionsRaw[favIdx].(map[string]any)
if items, ok := sec["items"].([]any); ok {
for _, it := range items {
m, _ := it.(map[string]any)
u, _ := m["url"].(string)
favURLs[strings.ToLower(u)] = true
}
}
}
mu.RLock()
entries := make([]*Entry, 0, len(history))
for _, e := range history {
entries = append(entries, e)
}
mu.RUnlock()
sort.SliceStable(entries, func(i, j int) bool {
return entries[i].TotalVisits > entries[j].TotalVisits
})
histItems := make([]any, 0)
for _, e := range entries {
if favURLs[strings.ToLower(e.URL)] {
continue
}
title := e.Title
if title == "" {
title = e.URL
}
histItems = append(histItems, map[string]any{
"title": title,
"description": fmt.Sprintf("%d\u6b21\u8bbf\u95ee", e.TotalVisits),
"icon": "fas fa-globe",
"url": e.URL,
})
if len(histItems) >= 20 {
break
}
}
if len(histItems) == 0 {
histItems = append(histItems, map[string]any{
"title": "\u7b49\u5f85\u6d4f\u89c8\u5668\u63d2\u4ef6\u63a5\u5165...",
"description": "\u540c\u6b65\u540e\u6309\u603b\u8bbf\u95ee\u9891\u6b21\u81ea\u52a8\u6392\u5e8f",
"icon": "fas fa-info-circle",
"url": "/admin/",
})
}
sectionsRaw[histIdx].(map[string]any)["items"] = histItems
}
cfg["sections"] = sectionsRaw
out, err := yaml.Marshal(cfg)
if err != nil {
return
}
tmp := confFile + ".sort-tmp"
if err := os.WriteFile(tmp, out, 0644); err != nil {
return
}
os.Rename(tmp, confFile)
}
func visitCount(rawURL string) int {
u, err := url.Parse(rawURL)
if err != nil {
return 0
}
u.Fragment = ""
u.Host = strings.ToLower(u.Host)
key := u.String()
mu.RLock()
defer mu.RUnlock()
if e, ok := history[key]; ok {
return e.TotalVisits
}
return 0
}
func main() {
os.MkdirAll(dataDir, 0755)
load()
http.HandleFunc("/sync", cors(handleSync))
http.HandleFunc("/", cors(func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodGet {
handleGet(w, r)
} else if r.Method == http.MethodDelete {
handleClear(w, r)
} else {
http.Error(w, "method not allowed", 405)
}
}))
http.HandleFunc("/stats", cors(handleStats))
http.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
})
log.Println("history-server listening on :3004")
log.Fatal(http.ListenAndServe(":3004", nil))
}

87
nginx.conf Normal file
View File

@ -0,0 +1,87 @@
worker_processes auto;
pid /run/nginx.pid;
error_log /var/log/nginx/error.log warn;
events {
worker_connections 1024;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
sendfile on;
keepalive_timeout 65;
client_max_body_size 0;
gzip on;
gzip_types text/plain text/css application/json application/javascript text/xml;
# 全部服务通过统一 8080 端口对外
server {
listen 8080;
server_name _;
# Dashy 静态文件
root /app/dashy;
index index.html;
# ===== SearXNG 反向代理 =====
# SearXNG 路由是 /search(无尾斜杠),/search/ 会 404,故统一 rewrite 去掉尾斜杠。
# 结果页内静态资源/页面链接是绝对根路径(/static/、/preferences、/autocompleter 等)。
location ~ ^/search/?$ {
proxy_pass http://127.0.0.1:8888;
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;
}
# SearXNG 静态资源(主题/前端脚本,页面上以 /static/... 绝对路径引用)
location /static/ {
proxy_pass http://127.0.0.1:8888;
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;
}
# 其他 SearXNG 页面/接口(结果页内的链接会引用这些根路径)
location ~ ^/(autocompleter|preferences|stats|about|donate|opensearch.xml|image_proxy|translations)/? {
proxy_pass http://127.0.0.1:8888;
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;
}
# History API(内部 3004)
location /api/history/ {
proxy_pass http://127.0.0.1:3004/;
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;
}
# Admin WebUI(内部 3005)
location /admin/ {
proxy_pass http://127.0.0.1:3005/;
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;
}
# conf.yml 由 nginx 直接提供(从 data volume 读取)
location = /conf.yml {
alias /app/data/conf.yml;
}
# item-icons 从 data volume 提供
location /item-icons/ {
alias /app/data/item-icons/;
}
# Dashy SPA 路由兜底
location / {
try_files $uri $uri/ /index.html;
}
}
}

75
searxng-settings.yml Normal file
View File

@ -0,0 +1,75 @@
use_default_settings:
engines:
keep_only:
- google
- bing
- baidu
- sogou
- 360search
- wikipedia
- github
- stackoverflow
general:
instance_name: "JianF Search"
debug: false
search:
safe_search: 0
autocomplete: "baidu"
default_lang: "zh-CN"
server:
port: 8888
bind_address: "127.0.0.1"
secret_key: "changeme-in-production"
image_proxy: true
ui:
static_use_hash: true
default_theme: simple
query_in_title: true
results_on_new_tab: false
redis:
url: false
outgoing:
request_timeout: 3.0
max_request_timeout: 4.0
pool_connections: 100
pool_maxsize: 20
# SearXNG 的 curl_cffi 网络层只认这里配置的代理(不走环境变量 http_proxy)。
# 需要代理出网(例如服务器在国内访问不了 Google/Bing)时,把地址换成你自己的代理:
# proxies:
# all://: http://<proxy-ip>:7890
proxies: {}
engines:
# 默认引擎列表里 google/bing 等是 disabled,需显式启用
- name: google
engine: google
shortcut: g
disabled: false
- name: bing
engine: bing
shortcut: b
disabled: false
- name: baidu
engine: baidu
shortcut: bd
disabled: false
- name: sogou
engine: sogou
shortcut: sg
disabled: false
- name: 360search
engine: 360search
shortcut: 360
disabled: false
- name: wikipedia
engine: wikipedia
shortcut: wp
disabled: false
- name: github
engine: github
shortcut: gh
disabled: false
- name: stackoverflow
engine: stackoverflow
shortcut: st
disabled: false

42
supervisord.conf Normal file
View File

@ -0,0 +1,42 @@
[supervisord]
nodaemon=true
logfile=/var/log/supervisor/supervisord.log
pidfile=/run/supervisord.pid
childlogdir=/var/log/supervisor
[program:nginx]
command=nginx -g "daemon off;"
autostart=true
autorestart=true
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0
[program:searxng]
command=/app/searxng-venv/bin/python -m searx.webapp
# 不设 environment:继承 docker run -e SEARXNG_BASE_URL 等父进程环境变量
autostart=true
autorestart=true
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0
[program:history-server]
command=/usr/local/bin/history-server
autostart=true
autorestart=true
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0
[program:admin-server]
command=/usr/local/bin/admin-server
autostart=true
autorestart=true
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0

39
web/navi.js Normal file
View File

@ -0,0 +1,39 @@
(() => {
'use strict';
function mountSearch() {
if (document.querySelector('.navi-search-bar')) return;
const form = document.createElement('form');
form.className = 'navi-search-bar';
form.method = 'GET';
form.action = '/search';
form.target = '_blank';
form.setAttribute('role', 'search');
const icon = document.createElement('span');
icon.innerHTML = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>';
icon.setAttribute('aria-hidden', 'true');
const input = document.createElement('input');
input.type = 'search';
input.name = 'q';
input.placeholder = '搜索…';
input.autocomplete = 'off';
input.setAttribute('aria-label', '搜索');
const button = document.createElement('button');
button.type = 'submit';
button.textContent = '→';
button.title = '搜索';
form.append(icon, input, button);
document.body.appendChild(form);
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', mountSearch, { once: true });
} else {
mountSearch();
}
new MutationObserver(mountSearch).observe(document.documentElement, { childList: true, subtree: true });
})();