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:
5
.codegraph/.gitignore
vendored
Normal file
5
.codegraph/.gitignore
vendored
Normal 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
8
.env.example
Normal file
@ -0,0 +1,8 @@
|
||||
# JianF 统一导航服务环境变量
|
||||
# 复制此文件为 .env 并修改
|
||||
|
||||
# 浏览器插件同步令牌(留空则不鉴权)
|
||||
NAVI_SYNC_TOKEN=
|
||||
|
||||
# 管理 API 令牌(留空则不鉴权)
|
||||
NAVI_ADMIN_TOKEN=
|
||||
1
.gitattributes
vendored
Normal file
1
.gitattributes
vendored
Normal file
@ -0,0 +1 @@
|
||||
*.tar.gz filter=lfs diff=lfs merge=lfs -text
|
||||
17
.gitignore
vendored
Normal file
17
.gitignore
vendored
Normal 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
78
Dockerfile
Normal 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
413
README.md
Normal 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
5
admin-server/go.mod
Normal 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
4
admin-server/go.sum
Normal 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
215
admin-server/main.go
Normal 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
62
admin/index.html
Normal 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
53
conf.example.yml
Normal 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
32
docker-compose.yml
Normal 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
48
extension/README.md
Normal 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`
|
||||
132
extension/chrome/background.js
Normal file
132
extension/chrome/background.js
Normal 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 };
|
||||
}
|
||||
}
|
||||
22
extension/chrome/manifest.json
Normal file
22
extension/chrome/manifest.json
Normal 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"
|
||||
}
|
||||
51
extension/chrome/options.html
Normal file
51
extension/chrome/options.html
Normal 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>
|
||||
132
extension/firefox/background.js
Normal file
132
extension/firefox/background.js
Normal 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 };
|
||||
}
|
||||
}
|
||||
26
extension/firefox/manifest.json
Normal file
26
extension/firefox/manifest.json
Normal 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"
|
||||
}
|
||||
}
|
||||
51
extension/firefox/options.html
Normal file
51
extension/firefox/options.html
Normal 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
5
history-server/go.mod
Normal 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
3
history-server/go.sum
Normal 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
364
history-server/main.go
Normal 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
87
nginx.conf
Normal 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
75
searxng-settings.yml
Normal 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
42
supervisord.conf
Normal 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
39
web/navi.js
Normal 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 });
|
||||
})();
|
||||
Reference in New Issue
Block a user