From 7e49d2b51cdc54ee67afb7093cc5a5649e55ac9d Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Tue, 8 Sep 2026 22:25:00 +0800 Subject: [PATCH] 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) --- .codegraph/.gitignore | 5 + .env.example | 8 + .gitattributes | 1 + .gitignore | 17 ++ Dockerfile | 78 ++++++ README.md | 413 ++++++++++++++++++++++++++++++++ admin-server/go.mod | 5 + admin-server/go.sum | 4 + admin-server/main.go | 215 +++++++++++++++++ admin/index.html | 62 +++++ conf.example.yml | 53 ++++ docker-compose.yml | 32 +++ extension/README.md | 48 ++++ extension/chrome/background.js | 132 ++++++++++ extension/chrome/manifest.json | 22 ++ extension/chrome/options.html | 51 ++++ extension/firefox/background.js | 132 ++++++++++ extension/firefox/manifest.json | 26 ++ extension/firefox/options.html | 51 ++++ history-server/go.mod | 5 + history-server/go.sum | 3 + history-server/main.go | 364 ++++++++++++++++++++++++++++ nginx.conf | 87 +++++++ searxng-settings.yml | 75 ++++++ supervisord.conf | 42 ++++ web/navi.js | 39 +++ 26 files changed, 1970 insertions(+) create mode 100644 .codegraph/.gitignore create mode 100644 .env.example create mode 100644 .gitattributes create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 admin-server/go.mod create mode 100644 admin-server/go.sum create mode 100644 admin-server/main.go create mode 100644 admin/index.html create mode 100644 conf.example.yml create mode 100644 docker-compose.yml create mode 100644 extension/README.md create mode 100644 extension/chrome/background.js create mode 100644 extension/chrome/manifest.json create mode 100644 extension/chrome/options.html create mode 100644 extension/firefox/background.js create mode 100644 extension/firefox/manifest.json create mode 100644 extension/firefox/options.html create mode 100644 history-server/go.mod create mode 100644 history-server/go.sum create mode 100644 history-server/main.go create mode 100644 nginx.conf create mode 100644 searxng-settings.yml create mode 100644 supervisord.conf create mode 100644 web/navi.js diff --git a/.codegraph/.gitignore b/.codegraph/.gitignore new file mode 100644 index 0000000..d20c0fe --- /dev/null +++ b/.codegraph/.gitignore @@ -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 diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..05b2e82 --- /dev/null +++ b/.env.example @@ -0,0 +1,8 @@ +# JianF 统一导航服务环境变量 +# 复制此文件为 .env 并修改 + +# 浏览器插件同步令牌(留空则不鉴权) +NAVI_SYNC_TOKEN= + +# 管理 API 令牌(留空则不鉴权) +NAVI_ADMIN_TOKEN= diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..f087b42 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +*.tar.gz filter=lfs diff=lfs merge=lfs -text diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..158457e --- /dev/null +++ b/.gitignore @@ -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 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..b96e6a4 --- /dev/null +++ b/Dockerfile @@ -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###' /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"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..2265391 --- /dev/null +++ b/README.md @@ -0,0 +1,413 @@ +# HomeDash — 自托管内网导航门户 + +将 **Dashy + SearXNG + 浏览器历史同步 + 管理后台**整合在单个 Docker 容器中,并附带**完整的内网服务发布方案**(带统一认证的反向代理 + webui4frpc 内网穿透),让外网通过域名安全访问内网服务。 + +## 架构总览(生产全链路) + +``` +公网用户 + │ + ▼ +阿里云 VPS (frps 服务端, :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@:220//HomeDash.git +cd HomeDash +cp .env.example .env # 编辑令牌 +docker compose up -d +``` + +访问 `http://: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://:3003/admin/` 可视化管理导航项。 + +API(需 `NAVI_ADMIN_TOKEN`): + +```bash +# 查看 +curl -H "Authorization: Bearer " http://:3003/admin/api/sections + +# 添加 +curl -H "Authorization: Bearer " -X POST -H 'Content-Type: application/json' \ + -d '{"sectionIndex":0,"item":{"title":"New","url":"https://example.com"}}' \ + http://:3003/admin/api/item + +# 删除 +curl -H "Authorization: Bearer " -X DELETE -H 'Content-Type: application/json' \ + -d '{"sectionIndex":0,"itemIndex":2}' \ + http://: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 -password <强密码> \ + -workdir /opt/webui4frpc/data +``` + +### 3. 在 WebUI 中创建隧道 + +webui4frpc 把配置存为 `configs/*.json`。一个隧道文件示例: + +```json +{ + "serverAddr": "", + "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 ` + +部署时通过环境变量注入令牌: + +```bash +docker run -d ... \ + -e NAVI_SYNC_TOKEN=<长随机串> \ # 浏览器扩展同步用 + -e NAVI_ADMIN_TOKEN=<长随机串> \ # 管理 API 写操作用 + jianf-navi:latest +``` + +浏览器扩展在设置里填同一个 `NAVI_SYNC_TOKEN`,每次同步请求带 `Authorization: Bearer `。 + +```bash +# 管理 API 示例(写操作必须带 admin token) +curl -H "Authorization: Bearer " \ + -X POST -H 'Content-Type: application/json' \ + -d '{"sectionIndex":0,"item":{"title":"New","url":"https://example.com"}}' \ + http://: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 diff --git a/admin-server/go.mod b/admin-server/go.mod new file mode 100644 index 0000000..5a01397 --- /dev/null +++ b/admin-server/go.mod @@ -0,0 +1,5 @@ +module admin-server + +go 1.21 + +require gopkg.in/yaml.v3 v3.0.1 diff --git a/admin-server/go.sum b/admin-server/go.sum new file mode 100644 index 0000000..a62c313 --- /dev/null +++ b/admin-server/go.sum @@ -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= diff --git a/admin-server/main.go b/admin-server/main.go new file mode 100644 index 0000000..e7391c6 --- /dev/null +++ b/admin-server/main.go @@ -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)) +} diff --git a/admin/index.html b/admin/index.html new file mode 100644 index 0000000..6195c06 --- /dev/null +++ b/admin/index.html @@ -0,0 +1,62 @@ + + + + + +导航管理 + + + +
+

导航管理

+

加载中…

+
+

添加导航项

+
+
+
+
+
+
+
+
+
+ +
+
+ + + diff --git a/conf.example.yml b/conf.example.yml new file mode 100644 index 0000000..efa8c4f --- /dev/null +++ b/conf.example.yml @@ -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: [] diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..b2d0684 --- /dev/null +++ b/docker-compose.yml @@ -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 diff --git a/extension/README.md b/extension/README.md new file mode 100644 index 0000000..3a1694c --- /dev/null +++ b/extension/README.md @@ -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` diff --git a/extension/chrome/background.js b/extension/chrome/background.js new file mode 100644 index 0000000..3f789b3 --- /dev/null +++ b/extension/chrome/background.js @@ -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 }; + } +} diff --git a/extension/chrome/manifest.json b/extension/chrome/manifest.json new file mode 100644 index 0000000..a02a76c --- /dev/null +++ b/extension/chrome/manifest.json @@ -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": [ + "" + ], + "background": { + "service_worker": "background.js" + }, + "icons": { + "16": "icons/icon16.png", + "48": "icons/icon48.png", + "128": "icons/icon128.png" + }, + "options_page": "options.html" +} diff --git a/extension/chrome/options.html b/extension/chrome/options.html new file mode 100644 index 0000000..9c178d1 --- /dev/null +++ b/extension/chrome/options.html @@ -0,0 +1,51 @@ + + + + +JianF Nav Sync 设置 + + + +

JianF Nav Sync

+ + + + + + +
+ + +
+
+ + + diff --git a/extension/firefox/background.js b/extension/firefox/background.js new file mode 100644 index 0000000..3f789b3 --- /dev/null +++ b/extension/firefox/background.js @@ -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 }; + } +} diff --git a/extension/firefox/manifest.json b/extension/firefox/manifest.json new file mode 100644 index 0000000..efce36c --- /dev/null +++ b/extension/firefox/manifest.json @@ -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" + } +} diff --git a/extension/firefox/options.html b/extension/firefox/options.html new file mode 100644 index 0000000..9c178d1 --- /dev/null +++ b/extension/firefox/options.html @@ -0,0 +1,51 @@ + + + + +JianF Nav Sync 设置 + + + +

JianF Nav Sync

+ + + + + + +
+ + +
+
+ + + diff --git a/history-server/go.mod b/history-server/go.mod new file mode 100644 index 0000000..40598ad --- /dev/null +++ b/history-server/go.mod @@ -0,0 +1,5 @@ +module history-server + +go 1.21 + +require gopkg.in/yaml.v3 v3.0.1 // indirect diff --git a/history-server/go.sum b/history-server/go.sum new file mode 100644 index 0000000..4bc0337 --- /dev/null +++ b/history-server/go.sum @@ -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= diff --git a/history-server/main.go b/history-server/main.go new file mode 100644 index 0000000..a2b4472 --- /dev/null +++ b/history-server/main.go @@ -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)) +} diff --git a/nginx.conf b/nginx.conf new file mode 100644 index 0000000..8b274cd --- /dev/null +++ b/nginx.conf @@ -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; + } + } +} diff --git a/searxng-settings.yml b/searxng-settings.yml new file mode 100644 index 0000000..499ca4b --- /dev/null +++ b/searxng-settings.yml @@ -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://: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 diff --git a/supervisord.conf b/supervisord.conf new file mode 100644 index 0000000..79b5e34 --- /dev/null +++ b/supervisord.conf @@ -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 diff --git a/web/navi.js b/web/navi.js new file mode 100644 index 0000000..2da54e3 --- /dev/null +++ b/web/navi.js @@ -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 = ''; + 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 }); +})();