package web
import (
"database/sql"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"log"
"math"
"net/http"
"os"
"path/filepath"
"strconv"
"strings"
"time"
"unicode"
"unicode/utf8"
"rond-with-you/internal/auth"
"rond-with-you/internal/gpx"
"rond-with-you/internal/ingest"
"rond-with-you/internal/rond"
"rond-with-you/internal/setting"
"rond-with-you/internal/votes"
"github.com/skip2/go-qrcode"
)
const maxUploadBytes = 512 << 20 // 512MB
// ---------- 登录 ----------
// captchaCookie 是验证码 id 的 cookie 名,与登录会话分开。
const captchaCookie = "rond_captcha"
// captchaImage 出一张验证码图,并把答案的 id 写进 cookie。
// 登录页的
直接引用它,重新加载页面就等于换一张。
func (s *Server) captchaImage(w http.ResponseWriter, r *http.Request) {
id, png, err := s.captcha.Issue()
if err != nil {
s.serverError(w, r, err)
return
}
http.SetCookie(w, &http.Cookie{
Name: captchaCookie, Value: id, Path: "/", MaxAge: 600,
HttpOnly: true, SameSite: http.SameSiteLaxMode,
})
w.Header().Set("Content-Type", "image/png")
w.Header().Set("Cache-Control", "no-store")
w.Header().Set("Content-Length", strconv.Itoa(len(png)))
w.Write(png)
}
// checkCaptcha 校验提交上来的验证码;无论对错都会作废当前这一张。
func (s *Server) checkCaptcha(r *http.Request) bool {
c, err := r.Cookie(captchaCookie)
if err != nil {
return false
}
return s.captcha.Verify(c.Value, r.PostFormValue("captcha"))
}
func (s *Server) loginPage(w http.ResponseWriter, r *http.Request) {
sets, err := s.sets.Load(r.Context())
if err != nil {
s.serverError(w, r, err)
return
}
if u, _ := s.sessionUser(r); u != nil {
http.Redirect(w, r, "/admin", http.StatusSeeOther)
return
}
d := LoginData{Settings: sets, Next: r.URL.Query().Get("next")}
if r.URL.Query().Get("expired") == "1" {
d.Flash = "请先登录后台"
}
s.render(w, "admin/login", d)
}
func (s *Server) loginSubmit(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
if err := r.ParseForm(); err != nil {
s.serverError(w, r, err)
return
}
username := strings.TrimSpace(r.PostFormValue("username"))
password := r.PostFormValue("password")
next := r.PostFormValue("next")
// 先过验证码:错误与过期都只提示重试,不透露别的信息。
// 页面重新渲染时
会再取一张新图(响应带 no-store)。
if !s.checkCaptcha(r) {
sets, _ := s.sets.Load(ctx)
s.renderStatus(w, http.StatusOK, "admin/login", LoginData{
Settings: sets, Err: "验证码不正确或已过期,请重新输入", Next: next,
})
return
}
u, err := s.auth.Authenticate(ctx, username, password)
if err != nil {
sets, _ := s.sets.Load(ctx)
code := http.StatusOK
if !errors.Is(err, auth.ErrInvalidCredentials) {
code = http.StatusInternalServerError
}
msg := "用户名或密码错误"
if code == http.StatusInternalServerError {
msg = "登录失败:" + err.Error()
}
s.renderStatus(w, code, "admin/login", LoginData{Settings: sets, Err: msg, Next: next})
return
}
// 已开启两步验证:口令过关只算第一步,签发短时中间令牌进入验证码页
if _, enabled, _ := s.auth.TOTPState(ctx, u.ID); enabled {
sets, _ := s.sets.Load(ctx)
s.render(w, "admin/twofa-login", TwoFALoginData{
Settings: sets, Pending: s.auth.SignPending2FA(u.ID, u.Hash), Next: next,
})
return
}
s.login(w, u.ID, u.Hash)
target := "/admin"
if strings.HasPrefix(next, "/admin") {
target = next
}
http.Redirect(w, r, target, http.StatusSeeOther)
}
// ---------- 后台首页 ----------
func (s *Server) adminHome(w http.ResponseWriter, r *http.Request) {
p, err := s.pageBase(r, "admin", "后台管理")
if err != nil {
s.serverError(w, r, err)
return
}
// 上传备份、导入迷雾、开关两步验证都是「处理完跳回本页」,提示挂在 URL 上,这里得接住
p.SetFlashFromQuery(r)
d := AdminData{Page: p, MaxSize: maxUploadBytes}
if p.User != nil {
d.DefaultPW = auth.VerifyPassword(p.User.Hash, "rond-admin")
}
if ix, err := s.fogIndex(); err == nil {
d.FogBlocks, d.FogCells = ix.Stats()
if d.FogBlocks > 0 {
d.FogArea = ix.AreaKm2()
}
}
d.FogMasks = len(s.masksFor())
var fogUpdated sql.NullTime
if err := s.db.QueryRowContext(r.Context(),
`SELECT max(updated_at) FROM fog_blocks`).Scan(&fogUpdated); err != nil {
s.serverError(w, r, err)
return
}
if fogUpdated.Valid {
t := fogUpdated.Time
d.FogUpdatedAt = &t
}
rows, err := s.db.QueryContext(r.Context(), `SELECT id, original_name, uploaded_at, visit_count, place_count,
raw_visit_count, first_visit_at, last_visit_at, size_bytes
FROM datasets WHERE status='done' ORDER BY uploaded_at DESC`)
if err != nil {
s.serverError(w, r, err)
return
}
defer rows.Close()
for rows.Next() {
var ds Dataset
if err := rows.Scan(&ds.ID, &ds.Name, &ds.UploadedAt, &ds.Visits, &ds.Places,
&ds.RawVisits, &ds.First, &ds.Last, &ds.Size); err != nil {
s.serverError(w, r, err)
return
}
d.Datasets = append(d.Datasets, ds)
}
s.render(w, "admin/index", d)
}
func (s *Server) adminUpload(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
u := userFrom(ctx)
r.Body = http.MaxBytesReader(w, r.Body, maxUploadBytes+1<<20)
if err := r.ParseMultipartForm(32 << 20); err != nil {
s.adminFlash(w, r, "err", "读取上传内容失败:"+err.Error())
return
}
file, hdr, err := r.FormFile("backup")
if err != nil {
s.adminFlash(w, r, "err", "请选择要上传的 .rondbackup 文件")
return
}
defer file.Close()
name := filepath.Base(hdr.Filename)
if !strings.HasSuffix(strings.ToLower(name), ".rondbackup") && !strings.HasSuffix(strings.ToLower(name), ".zip") {
s.adminFlash(w, r, "err", "文件类型不正确,请上传 rond 导出的 .rondbackup 备份")
return
}
stored := filepath.Join(s.cfg.UploadDir, fmt.Sprintf("%d-%s", time.Now().Unix(), sanitize(name)))
out, err := os.Create(stored)
if err != nil {
s.serverError(w, r, err)
return
}
if _, err := io.Copy(out, file); err != nil {
out.Close()
s.serverError(w, r, err)
return
}
out.Close()
sha, size, err := ingest.File(stored)
if err != nil {
s.serverError(w, r, err)
return
}
// 同一个文件重复上传:直接复用已有数据集,既不新建也不重建。
// 顺手补一次基线库(早期版本导入的数据集还没留),这样导出 rondbackup 立刻可用。
var oldID int64
if err := s.db.QueryRowContext(ctx, `SELECT id FROM datasets WHERE user_id=$1 AND sha256=$2`,
u.ID, sha).Scan(&oldID); err == nil && oldID > 0 {
base := s.baselinePath(oldID)
if _, serr := os.Stat(base); serr != nil {
if werr := rond.WriteBaseline(stored, base); werr != nil {
log.Printf("补写基线库失败(导出 rondbackup 时需重新上传): %v", werr)
}
}
os.Remove(stored)
s.adminFlash(w, r, "ok", fmt.Sprintf("这个文件已经导入过(数据集 #%d),未重复导入", oldID))
return
}
tmp, err := ingest.TempDir(s.cfg.DataDir, name)
if err != nil {
s.serverError(w, r, err)
return
}
defer os.RemoveAll(tmp)
sqlDB, err := rond.Open(stored, tmp)
if err != nil {
s.adminFlash(w, r, "err", "解析备份失败:"+err.Error())
return
}
backup, perr := rond.Parse(sqlDB)
sqlDB.Close()
if perr != nil {
s.adminFlash(w, r, "err", "读取备份数据失败:"+perr.Error())
return
}
datasetID, st, err := ingest.Import(ctx, s.db, u.ID, name, sha, size, backup)
if err != nil {
log.Printf("导入失败: %v", err)
s.adminFlash(w, r, "err", "写入数据库失败:"+err.Error())
return
}
// 留一份基线库:导出 rondbackup 时以它为底、只覆盖我们管理的实体表。
// 只在「新导入」时写——若拿导出包重传(内容与已有数据集一致),绝不能用它当基线,
// 否则 rond 原生的标志位(ZPINSTYLE_/ZRADIUS/ZVISIT.ZRAW…)会被洗成 NULL,越导越失真。
// 老数据集缺基线的情况,交给 ensureBaseline 按当初上传的原始包重建。
if !st.Identical {
if err := rond.WriteBaseline(stored, s.baselinePath(datasetID)); err != nil {
log.Printf("写入基线库失败(导出 rondbackup 时需重新上传原始备份): %v", err)
}
}
if st.Identical {
s.adminFlash(w, r, "ok", fmt.Sprintf(
"这份备份的记录与已有数据集 #%d 完全一致(%d 条到访、%d 个地点),未新建快照,当前展示不变",
datasetID, st.Visits, st.Places))
return
}
msg := fmt.Sprintf("导入成功:新增 %d 条到访记录、%d 个地点、%d 条原始定位,时间跨度 %s ~ %s(数据集 #%d,已切换为当前展示)",
st.Visits, st.Places, st.RawVisits, fmtDate(st.FirstVisit), fmtDate(st.LastVisit), datasetID)
if st.Skipped > 0 {
msg += fmt.Sprintf(";另有 %d 条重复记录已跳过", st.Skipped)
}
// 缺地址 / 没名字的地点顺手反查补全:它们既匹配不上城市黑名单,地图上还显示成「未命名」
msg += s.backfillAfterImport(ctx, datasetID)
// rond 里按次到访写的备注顺带回填成地点备注
if n, err := s.notes.BackfillRond(ctx, u.ID, datasetID); err != nil {
log.Printf("回填 rond 备注失败: %v", err)
} else if n > 0 {
msg += fmt.Sprintf(";已从 rond 备注回填 %d 个地点", n)
}
s.adminFlash(w, r, "ok", msg)
}
// adminNotesRond 手动重跑「把 rond 备份里的备注回填成地点备注」。
func (s *Server) adminNotesRond(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
ds, err := s.datasetFor(ctx)
if err != nil {
s.serverError(w, r, err)
return
}
if ds == nil {
redirectNotes(w, r, "err", "还没有导入任何备份")
return
}
n, err := s.notes.BackfillRond(ctx, userFrom(ctx).ID, ds.ID)
if err != nil {
redirectNotes(w, r, "err", "回填失败:"+err.Error())
return
}
redirectNotes(w, r, "ok", fmt.Sprintf("已从 rond 备注回填 %d 个地点", n))
}
func (s *Server) adminActivate(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
u := userFrom(ctx)
id, err := parseInt64(r.PathValue("id"))
if err != nil {
s.adminFlash(w, r, "err", "无效的数据集编号")
return
}
if _, err := s.db.ExecContext(ctx, `UPDATE datasets SET is_active = (id = $1) WHERE user_id = $2`, id, u.ID); err != nil {
s.serverError(w, r, err)
return
}
// 备注是跟着「当前展示的数据集」来的,换了数据集要重算一遍
if n, err := s.notes.BackfillRond(ctx, u.ID, id); err != nil {
log.Printf("回填 rond 备注失败: %v", err)
} else if n > 0 {
s.adminFlash(w, r, "ok", fmt.Sprintf("已切换展示的数据集;已从 rond 备注回填 %d 个地点", n))
return
}
s.adminFlash(w, r, "ok", "已切换展示的数据集")
}
func (s *Server) adminDelete(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
u := userFrom(ctx)
id, err := parseInt64(r.PathValue("id"))
if err != nil {
s.adminFlash(w, r, "err", "无效的数据集编号")
return
}
if _, err := s.db.ExecContext(ctx, `DELETE FROM datasets WHERE id=$1 AND user_id=$2`, id, u.ID); err != nil {
s.serverError(w, r, err)
return
}
// 删除后若没有生效数据集,自动启用最新的一份。
// 这一步失败必须说出来:否则站上会一份生效数据都没有,页面全空,提示却说「已删除」。
var cnt int
if err := s.db.QueryRowContext(ctx, `SELECT count(*) FROM datasets WHERE user_id=$1 AND is_active`, u.ID).Scan(&cnt); err == nil && cnt == 0 {
if _, err := s.db.ExecContext(ctx, `UPDATE datasets SET is_active=true WHERE id = (
SELECT id FROM datasets WHERE user_id=$1 AND status='done' ORDER BY uploaded_at DESC LIMIT 1)`, u.ID); err != nil {
log.Printf("删除数据集后自动启用最新一份失败: %v", err)
s.adminFlash(w, r, "err", "数据集已删除,但没有找到可自动启用的备份,请重新上传一次备份")
return
}
}
s.adminFlash(w, r, "ok", "已删除该数据集及其全部记录")
}
// ---------- 账号:用户名 ----------
// usernameSubmit 改用户名。用户名是登录凭据,所以要再验一次当前口令。
func (s *Server) usernameSubmit(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
u := userFrom(ctx)
if err := r.ParseForm(); err != nil {
s.serverError(w, r, err)
return
}
name := strings.TrimSpace(r.PostFormValue("username"))
cur := r.PostFormValue("current")
p, err := s.pageBase(r, "admin", "账号设置")
if err != nil {
s.serverError(w, r, err)
return
}
d := PasswordData{Page: p, Username: u.Username}
fail := func(msg string) {
d.Err = msg
s.renderStatus(w, http.StatusOK, "admin/password", d)
}
if msg := validateUsername(name, u.Username); msg != "" {
fail(msg)
return
}
if err := s.auth.ChangeUsername(ctx, u.ID, cur, name); err != nil {
switch {
case errors.Is(err, auth.ErrInvalidCredentials):
fail("当前密码不正确")
case errors.Is(err, auth.ErrUsernameTaken):
fail("用户名「" + name + "」已被占用")
default:
s.serverError(w, r, err)
}
return
}
http.Redirect(w, r, "/admin/password?saved=name", http.StatusSeeOther)
}
// validateUsername 校验用户名,返回中文错误信息(空串表示通过)。
// 用户名只用于口令登录,允许中文;但空格与控制字符会让登录框里难以辨认,禁掉。
func validateUsername(name, current string) string {
if n := utf8.RuneCountInString(name); n < 2 || n > 32 {
return "用户名需要 2~32 个字符"
}
if name == current {
return "新用户名与当前用户名相同"
}
for _, r := range name {
if unicode.IsSpace(r) || unicode.IsControl(r) {
return "用户名不能包含空格或不可见字符"
}
}
return ""
}
// ---------- 站点设置 ----------
func (s *Server) settingsPage(w http.ResponseWriter, r *http.Request) {
p, err := s.pageBase(r, "admin", "站点设置")
if err != nil {
s.serverError(w, r, err)
return
}
layers := make([]MapLayerOption, 0, len(s.tiles.base))
for _, l := range s.tiles.base {
layers = append(layers, MapLayerOption{Key: l.Key, Label: l.Label})
}
d := SettingsData{Page: p, Modules: setting.AllModules, Pages: setting.AllPages,
MapInfo: s.mapInfo(), MapLayers: layers, S3Info: s.s3Info()}
d.Flash = r.URL.Query().Get("saved")
s.render(w, "admin/settings", d)
}
// mapInfo 汇总当前生效的底图,供后台只读展示。
func (s *Server) mapInfo() string {
names := make([]string, 0, len(s.tiles.base))
for _, l := range s.tiles.base {
names = append(names, l.Label)
}
provider := map[string]string{"amap": "高德地图", "tianditu": "天地图", "custom": "自定义图层"}[s.cfg.MapProvider]
note := "免密钥瓦片,浏览器直连"
if _, ok := s.tiles.upstream[s.tiles.base[0].Key]; ok {
note = "经服务端代理,密钥不出现在页面源码中"
}
return fmt.Sprintf("%s(%s)· %s · %s", provider, s.cfg.MapProvider, strings.Join(names, " / "), note)
}
// s3Info 汇总对象存储的配置状态,供后台只读展示(配没配齐一眼可见)。
func (s *Server) s3Info() string {
if !s.media.S3Ready() {
return "未配置(s3_endpoint / s3_bucket / s3_access_key / s3_secret_key 有缺项)"
}
out := fmt.Sprintf("已配置:%s / %s / %s", s.cfg.S3Endpoint, s.cfg.S3Bucket, s.cfg.S3Region)
if s.cfg.S3PublicBase == "" {
out += ";缺 s3_public_base,浏览器取不到图片"
} else {
out += ";公开域名 " + s.cfg.S3PublicBase
}
return out
}
func (s *Server) settingsSubmit(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
if err := r.ParseForm(); err != nil {
s.serverError(w, r, err)
return
}
cur, err := s.sets.Load(ctx)
if err != nil {
s.serverError(w, r, err)
return
}
next := cur
next.SiteTitle = strings.TrimSpace(firstNonEmpty(r.PostFormValue("site_title"), "我的足迹地图"))
next.SiteSubtitle = strings.TrimSpace(r.PostFormValue("site_subtitle"))
next.OwnerName = strings.TrimSpace(firstNonEmpty(r.PostFormValue("owner_name"), "站长"))
next.OwnerAvatar = strings.TrimSpace(firstNonEmpty(r.PostFormValue("owner_avatar"), "🐾"))
next.OwnerBio = strings.TrimSpace(r.PostFormValue("owner_bio"))
next.HideHome = r.PostFormValue("hide_home") == "on"
next.HideWork = r.PostFormValue("hide_work") == "on"
next.NotesPublic = r.PostFormValue("notes_public") == "on"
next.MarksPublic = r.PostFormValue("marks_public") == "on"
next.MinDwellMin = atoiOr(r.PostFormValue("min_dwell_min"), 0)
if next.MinDwellMin < 0 {
next.MinDwellMin = 0
}
next.AccentColor = validHex(firstNonEmpty(r.PostFormValue("accent_color"), "#2f7d6e"), cur.AccentColor)
next.VisitorHome = setting.NormalizeVisitorHome(r.PostFormValue("visitor_home"))
// 底图只接受当前实际存在的图层 Key(换过 map_provider 后旧值就该失效),
// 空串表示「用第一张」——让 visitor 至少能拿到一张能用的底图。
next.MapDefaultBase = ""
if want := strings.TrimSpace(r.PostFormValue("map_default_base")); want != "" {
for _, l := range s.tiles.base {
if l.Key == want {
next.MapDefaultBase = want
break
}
}
}
// 图片存哪边:选了对象存储但凭据没配齐时给出提示(仍照存设置,方便先配好再用)
next.MediaBackend = setting.MediaBackendLocal
if r.PostFormValue("media_backend") == setting.MediaBackendS3 {
next.MediaBackend = setting.MediaBackendS3
if !s.media.S3Ready() {
log.Printf("设置里选了对象存储,但 conf/app.ini 的 s3_* 没配齐,图片上传会失败")
}
}
mods := r.PostForm["modules"]
next.Modules = nil
for _, m := range setting.AllModules {
for _, picked := range mods {
if picked == m.Key {
next.Modules = append(next.Modules, m.Key)
}
}
}
// 一个都不选会让主页彻底空白,这种情况按「全部开启」处理
if len(next.Modules) == 0 {
for _, m := range setting.AllModules {
next.Modules = append(next.Modules, m.Key)
}
}
// 页面可见性按勾选原样保存:全不选 = 访客只能看主页
next.Pages = nil
for _, pg := range setting.AllPages {
for _, picked := range r.PostForm["pages"] {
if picked == pg.Key {
next.Pages = append(next.Pages, pg.Key)
}
}
}
// 区域列表:一行一个,省 / 市 / 区都认(如「广东省」「贵阳市」「南明区」)
next.Regions = nil
for _, line := range strings.Split(r.PostFormValue("regions"), "\n") {
if v := strings.TrimSpace(line); v != "" {
next.Regions = append(next.Regions, v)
}
}
// 只认这两个值,表单被改坏时回退到白名单(更保守的一侧)
next.RegionsMode = setting.RegionsWhitelist
if r.PostFormValue("regions_mode") == setting.RegionsBlacklist {
next.RegionsMode = setting.RegionsBlacklist
}
if err := s.sets.Save(ctx, next); err != nil {
s.serverError(w, r, err)
return
}
http.Redirect(w, r, "/admin/settings?saved=1", http.StatusSeeOther)
}
// ---------- 修改口令 ----------
func (s *Server) passwordPage(w http.ResponseWriter, r *http.Request) {
u := userFrom(r.Context())
p, err := s.pageBase(r, "admin", "账号设置")
if err != nil {
s.serverError(w, r, err)
return
}
d := PasswordData{Page: p, Username: u.Username}
d.Flash = r.URL.Query().Get("saved")
s.render(w, "admin/password", d)
}
func (s *Server) passwordSubmit(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
u := userFrom(ctx)
if err := r.ParseForm(); err != nil {
s.serverError(w, r, err)
return
}
cur := r.PostFormValue("current")
next := r.PostFormValue("password")
confirm := r.PostFormValue("confirm")
p, err := s.pageBase(r, "admin", "修改密码")
if err != nil {
s.serverError(w, r, err)
return
}
d := PasswordData{Page: p, Username: u.Username}
fail := func(msg string) {
d.Err = msg
s.renderStatus(w, http.StatusOK, "admin/password", d)
}
if len(next) < 8 {
fail("新密码至少 8 位")
return
}
if next != confirm {
fail("两次输入的新密码不一致")
return
}
if err := s.auth.ChangePassword(ctx, u.ID, cur, next); err != nil {
if errors.Is(err, auth.ErrInvalidCredentials) {
fail("当前密码不正确")
return
}
s.serverError(w, r, err)
return
}
// 口令变更后旧会话失效,需要重新登录
http.SetCookie(w, &http.Cookie{Name: s.cookie, Value: "", Path: "/", MaxAge: -1, HttpOnly: true})
http.Redirect(w, r, "/admin/login?expired=1", http.StatusSeeOther)
}
// ---------- 小工具 ----------
func (s *Server) adminFlash(w http.ResponseWriter, r *http.Request, kind, msg string) {
http.Redirect(w, r, "/admin?"+kind+"="+urlQueryEscape(msg), http.StatusSeeOther)
}
func urlQueryEscape(s string) string {
var b strings.Builder
for _, r := range []byte(s) {
switch {
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', r == '-', r == '_', r == '.', r == '~':
b.WriteByte(r)
case r == ' ':
b.WriteByte('+')
default:
fmt.Fprintf(&b, "%%%02X", r)
}
}
return b.String()
}
func sanitize(name string) string {
name = filepath.Base(name)
var b strings.Builder
for _, r := range name {
switch {
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', r == '.', r == '-', r == '_':
b.WriteRune(r)
default:
if r > 127 {
b.WriteRune(r)
} else {
b.WriteByte('_')
}
}
}
if b.Len() == 0 {
return "backup.rondbackup"
}
return b.String()
}
func parseInt64(s string) (int64, error) {
var n int64
_, err := fmt.Sscan(strings.TrimSpace(s), &n)
return n, err
}
func firstNonEmpty(vals ...string) string {
for _, v := range vals {
if strings.TrimSpace(v) != "" {
return v
}
}
return ""
}
func validHex(s, fallback string) string {
if len(s) == 7 && s[0] == '#' {
if _, err := fmt.Sscanf(s[1:], "%06x", new(uint32)); err == nil {
return s
}
}
return fallback
}
func fmtDate(t *time.Time) string {
if t == nil {
return "—"
}
return t.Local().Format("2006-01-02")
}
// ---------- 地点备注与别名 ----------
// ---------- 地点备注管理 ----------
// NoteRow 是备注管理页的一行:一个地点 + 它的备注 / 别名 / 结论。
type NoteRow struct {
SrcPK int
Alias string
Content string
RondNote string
Verdict int
Up int
Down int
UpdatedAt time.Time
PlaceID int64
PlaceName string
PlaceCity string
PlaceGone bool
VisitCount int
HasNote bool
}
const notesPerPage = 50
// notesPage 列出当前数据集的全部地点(含未写备注的),支持筛选 / 分页 / 就地编辑;
// 另外单列「备注挂在已不在数据集的地点」这类孤儿备注,便于清理。
func (s *Server) notesPage(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
p, err := s.pageBase(r, "admin", "地点备注")
if err != nil {
s.serverError(w, r, err)
return
}
q := r.URL.Query()
kw := strings.TrimSpace(q.Get("q"))
status := q.Get("status")
if status != "has" && status != "none" {
status = "all"
}
city := strings.TrimSpace(q.Get("city"))
sortBy := q.Get("sort")
if sortBy != "note" && sortBy != "name" && sortBy != "talk" {
sortBy = "visit"
}
d := NotesData{Page: p, Query: kw, Status: status, City: city, Sort: sortBy, NotesPublic: p.Settings.NotesPublic}
d.Flash, d.Err = q.Get("ok"), q.Get("err")
ds, err := s.datasetFor(ctx)
if err != nil {
s.serverError(w, r, err)
return
}
if ds == nil {
s.render(w, "admin/notes", d)
return
}
if d.CityOptions, err = s.q.Cities(ctx, ds.ID, nil, false); err != nil {
s.serverError(w, r, err)
return
}
where := []string{"p.dataset_id = $1"}
args := []any{ds.ID}
if kw != "" {
args = append(args, "%"+kw+"%")
n := len(args)
where = append(where, fmt.Sprintf(
"(COALESCE(n.alias,'') ILIKE $%d OR COALESCE(n.note,'') ILIKE $%d OR COALESCE(n.rond_note,'') ILIKE $%d OR COALESCE(p.name,'') ILIKE $%d OR COALESCE(p.city,'') ILIKE $%d)",
n, n, n, n, n))
}
if city != "" {
args = append(args, city)
where = append(where, fmt.Sprintf("p.city = $%d", len(args)))
}
switch status {
case "has":
where = append(where, "(COALESCE(n.alias,'') <> '' OR COALESCE(n.note,'') <> '' OR COALESCE(n.rond_note,'') <> '')")
case "none":
where = append(where, "COALESCE(n.alias,'') = '' AND COALESCE(n.note,'') = '' AND COALESCE(n.rond_note,'') = ''")
}
order := "p.visit_count DESC, p.name"
switch sortBy {
case "name":
order = "p.name"
case "note":
order = "n.updated_at DESC NULLS LAST, p.name"
case "talk":
order = "(COALESCE((SELECT count(*) FROM place_visitor_votes vv WHERE vv.place_key = " + placeKeyExpr + "),0)) DESC, p.visit_count DESC"
}
base := `FROM places p
LEFT JOIN place_notes n ON n.place_key = ` + placeKeyExpr + `
LEFT JOIN place_votes v ON v.place_key = ` + placeKeyExpr + `
WHERE ` + strings.Join(where, " AND ")
var total int
if err := s.db.QueryRowContext(ctx, `SELECT count(*) `+base, args...).Scan(&total); err != nil {
s.serverError(w, r, err)
return
}
d.Total = total
d.PageCount = (total + notesPerPage - 1) / notesPerPage
if d.PageNo > d.PageCount && d.PageCount > 0 {
d.PageNo = d.PageCount
}
limitArgs := append(append([]any{}, args...), notesPerPage, (d.PageNo-1)*notesPerPage)
q2 := `SELECT p.src_pk, COALESCE(n.alias,''), COALESCE(n.note,''), COALESCE(n.rond_note,''), COALESCE(v.verdict,0),
COALESCE((SELECT count(*) FROM place_visitor_votes vv WHERE vv.place_key = ` + placeKeyExpr + ` AND vv.vote=1),0),
COALESCE((SELECT count(*) FROM place_visitor_votes vv WHERE vv.place_key = ` + placeKeyExpr + ` AND vv.vote=-1),0),
COALESCE(n.updated_at, to_timestamp(0)), p.id, COALESCE(p.name,''), COALESCE(p.city,''), COALESCE(p.visit_count,0)
` + base + ` ORDER BY ` + order + fmt.Sprintf(` LIMIT $%d OFFSET $%d`, len(args)+1, len(args)+2)
rows, err := s.db.QueryContext(ctx, q2, limitArgs...)
if err != nil {
s.serverError(w, r, err)
return
}
defer rows.Close()
for rows.Next() {
var n NoteRow
if err := rows.Scan(&n.SrcPK, &n.Alias, &n.Content, &n.RondNote, &n.Verdict, &n.Up, &n.Down,
&n.UpdatedAt, &n.PlaceID, &n.PlaceName, &n.PlaceCity, &n.VisitCount); err != nil {
s.serverError(w, r, err)
return
}
n.HasNote = n.Alias != "" || n.Content != "" || n.RondNote != ""
d.Rows = append(d.Rows, n)
}
if err := rows.Err(); err != nil {
s.serverError(w, r, err)
return
}
// 孤儿备注:src_pk 已不在当前数据集
orows, err := s.db.QueryContext(ctx, `SELECT n.src_pk, n.alias, n.note, n.rond_note, n.updated_at
FROM place_notes n
WHERE n.dataset_id=$1
AND NOT EXISTS (SELECT 1 FROM places p WHERE p.dataset_id=$1 AND p.src_pk=n.src_pk)
ORDER BY n.updated_at DESC`, ds.ID)
if err != nil {
s.serverError(w, r, err)
return
}
defer orows.Close()
for orows.Next() {
var n NoteRow
if err := orows.Scan(&n.SrcPK, &n.Alias, &n.Content, &n.RondNote, &n.UpdatedAt); err != nil {
s.serverError(w, r, err)
return
}
n.PlaceGone = true
n.HasNote = true
d.Orphans = append(d.Orphans, n)
}
if err := orows.Err(); err != nil {
s.serverError(w, r, err)
return
}
d.BackURL = d.PageLink(d.PageNo)
s.render(w, "admin/notes", d)
}
// adminNotesBulk 对勾选的地点批量操作:删除备注 / 清空备注 / 设置或取消结论。
func (s *Server) adminNotesBulk(w http.ResponseWriter, r *http.Request) {
u := userFrom(r.Context())
if err := r.ParseForm(); err != nil {
s.serverError(w, r, err)
return
}
ds, err := s.datasetFor(r.Context())
if err != nil || ds == nil {
redirectNotes(w, r, "err", "还没有可编辑的数据集")
return
}
ids := parseInt64s(r.PostForm["src_pk"])
action := r.PostFormValue("action")
if len(ids) == 0 {
redirectNotes(w, r, "err", "没有勾选任何地点")
return
}
var msg string
switch action {
case "delete":
for _, id := range ids {
if err := s.notes.Delete(r.Context(), u.ID, ds.ID, int(id)); err != nil {
s.serverError(w, r, err)
return
}
}
msg = fmt.Sprintf("已删除 %d 个地点的备注与别名", len(ids))
case "clear":
for _, id := range ids {
if err := s.notes.SetNote(r.Context(), u.ID, ds.ID, int(id), ""); err != nil {
s.serverError(w, r, err)
return
}
}
msg = fmt.Sprintf("已清空 %d 个地点的备注(保留别名)", len(ids))
case "verdict":
verdict := atoiOr(r.PostFormValue("verdict"), 0)
if verdict != votes.VerdictRecommend && verdict != votes.VerdictAvoid {
verdict = 0
}
for _, id := range ids {
if err := s.votes.SaveVerdict(r.Context(), u.ID, ds.ID, int(id), verdict); err != nil {
s.serverError(w, r, err)
return
}
}
if verdict == 0 {
msg = fmt.Sprintf("已取消 %d 个地点的结论", len(ids))
} else {
msg = fmt.Sprintf("已为 %d 个地点设置结论", len(ids))
}
default:
redirectNotes(w, r, "err", "未知的批量操作")
return
}
redirectNotes(w, r, "ok", msg)
}
// redirectNotes 回到备注管理页并带上提示(保留来源页的筛选条件)。
func redirectNotes(w http.ResponseWriter, r *http.Request, kind, msg string) {
back := r.Referer()
if !strings.Contains(back, "/admin/notes") {
back = "/admin/notes"
}
sep := "?"
if strings.Contains(back, "?") {
sep = "&"
}
http.Redirect(w, r, back+sep+kind+"="+urlQueryEscape(msg), http.StatusSeeOther)
}
// adminPlaceNote 保存地点的别名与备注,按 src_pk 存(换数据集不丢)。
func (s *Server) adminPlaceNote(w http.ResponseWriter, r *http.Request) {
u := userFrom(r.Context())
if err := r.ParseForm(); err != nil {
s.serverError(w, r, err)
return
}
ds, err := s.datasetFor(r.Context())
if err != nil || ds == nil {
s.adminFlash(w, r, "err", "还没有可编辑的数据集")
return
}
srcPK, err := strconv.ParseInt(r.PostFormValue("src_pk"), 10, 32)
if err != nil {
s.adminFlash(w, r, "err", "地点标识无效")
return
}
if err := s.notes.Save(r.Context(), u.ID, ds.ID, int(srcPK),
r.PostFormValue("alias"), r.PostFormValue("note")); err != nil {
s.serverError(w, r, err)
return
}
// 备注管理页的行内表单带 next,保存后回到原处(保留筛选);
// 没有 next 时按 Referer 判断,都没有就回后台首页。
if next := r.PostFormValue("next"); strings.HasPrefix(next, "/") {
sep := "?"
if strings.Contains(next, "?") {
sep = "&"
}
http.Redirect(w, r, next+sep+"ok="+urlQueryEscape("备注已保存"), http.StatusSeeOther)
return
}
if strings.Contains(r.Referer(), "/admin/notes") {
redirectNotes(w, r, "ok", "备注已保存")
return
}
s.adminFlash(w, r, "ok", "备注已保存")
}
// ---------- 两步验证(TOTP) ----------
// login2FA 登录第二步:校验验证码后签发正式会话。
func (s *Server) login2FA(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
if err := r.ParseForm(); err != nil {
s.serverError(w, r, err)
return
}
pending := r.PostFormValue("pending")
code := r.PostFormValue("code")
next := r.PostFormValue("next")
// 中间令牌格式 "2fa|uid|exp|mac",绑定了口令摘要,UserByID 取回摘要后才能验签
var uid int64
var err error
if parts := strings.Split(pending, "|"); len(parts) == 4 {
uid, err = parseInt64(parts[1])
} else {
err = errors.New("令牌格式无效")
}
var u *auth.User
if err == nil {
if u, err = s.auth.UserByID(ctx, uid); err == nil {
if _, ok := s.auth.ParsePending2FA(pending, u.Hash); !ok {
err = errors.New("令牌无效")
}
}
}
sets, _ := s.sets.Load(ctx)
fail := func(msg string) {
s.render(w, "admin/twofa-login", TwoFALoginData{
Settings: sets, Pending: pending, Next: next, Err: msg,
})
}
if err != nil {
fail("登录会话已过期,请重新登录")
return
}
secret, enabled, err := s.auth.TOTPState(ctx, u.ID)
if err != nil || !enabled || secret == "" {
fail("两步验证状态异常,请重新登录")
return
}
if !auth.VerifyTOTP(secret, code) {
fail("验证码不正确")
return
}
s.login(w, u.ID, u.Hash)
target := "/admin"
if strings.HasPrefix(next, "/admin") {
target = next
}
http.Redirect(w, r, target, http.StatusSeeOther)
}
// twofaPage 管理两步验证:展示状态、扫码绑定的二维码与确认 / 关闭表单。
func (s *Server) twofaPage(w http.ResponseWriter, r *http.Request) {
p, err := s.pageBase(r, "admin", "两步验证")
if err != nil {
s.serverError(w, r, err)
return
}
p.SetFlashFromQuery(r)
d := TwoFAData{Page: p}
u := userFrom(r.Context())
secret, enabled, err := s.auth.TOTPState(r.Context(), u.ID)
if err != nil {
s.serverError(w, r, err)
return
}
d.Enabled = enabled
if !enabled && secret != "" {
// 已生成待确认:展示二维码与秘钥,等管理员输入验证码确认
d.Pending = true
d.Secret = secret
url := auth.OTPAuthURL(u.Username, secret)
if qr, err := qrcode.New(url, qrcode.Medium); err == nil {
if png, err := qr.PNG(220); err == nil {
d.QR = base64.StdEncoding.EncodeToString(png)
}
}
}
s.render(w, "admin/twofa", d)
}
func (s *Server) twofaSetup(w http.ResponseWriter, r *http.Request) {
u := userFrom(r.Context())
if _, enabled, _ := s.auth.TOTPState(r.Context(), u.ID); enabled {
s.adminFlash(w, r, "err", "两步验证已开启,先关闭才能重新绑定")
return
}
secret, err := auth.GenerateTOTPSecret()
if err != nil {
s.serverError(w, r, err)
return
}
if err := s.auth.SetTOTPSecret(r.Context(), u.ID, secret); err != nil {
s.serverError(w, r, err)
return
}
http.Redirect(w, r, "/admin/twofa", http.StatusSeeOther)
}
func (s *Server) twofaConfirm(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
u := userFrom(ctx)
if err := r.ParseForm(); err != nil {
s.serverError(w, r, err)
return
}
secret, enabled, err := s.auth.TOTPState(ctx, u.ID)
if err != nil || enabled || secret == "" {
s.adminFlash(w, r, "err", "当前没有待确认的两步验证绑定")
return
}
if !auth.VerifyTOTP(secret, r.PostFormValue("code")) {
s.adminFlash(w, r, "err", "验证码不正确,请确认验证器时间后重试")
return
}
if err := s.auth.EnableTOTP(ctx, u.ID); err != nil {
s.serverError(w, r, err)
return
}
s.adminFlash(w, r, "ok", "两步验证已开启,下次登录需要输入验证码")
}
func (s *Server) twofaDisable(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
u := userFrom(ctx)
if err := r.ParseForm(); err != nil {
s.serverError(w, r, err)
return
}
secret, enabled, err := s.auth.TOTPState(ctx, u.ID)
if err != nil || !enabled || secret == "" {
s.adminFlash(w, r, "err", "两步验证尚未开启")
return
}
// 关闭也要求出具新鲜验证码,防止被劫持的会话直接拆掉 2FA
if !auth.VerifyTOTP(secret, r.PostFormValue("code")) {
s.adminFlash(w, r, "err", "验证码不正确,未关闭")
return
}
if err := s.auth.DisableTOTP(ctx, u.ID); err != nil {
s.serverError(w, r, err)
return
}
s.adminFlash(w, r, "ok", "两步验证已关闭")
}
// ---------- GPX 轨迹 ----------
const maxGpxBytes = 32 << 20
// adminGpxUpload 导入 GPX:rond 只记录地点之间的直线位移,
// 导入真实路径后可在地图上叠加显示。
func (s *Server) adminGpxUpload(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
u := userFrom(ctx)
r.Body = http.MaxBytesReader(w, r.Body, maxGpxBytes+1<<20)
if err := r.ParseMultipartForm(32 << 20); err != nil {
s.adminFlash(w, r, "err", "读取上传内容失败:"+err.Error())
return
}
file, hdr, err := r.FormFile("gpx")
if err != nil {
s.adminFlash(w, r, "err", "请选择要上传的 .gpx 文件")
return
}
defer file.Close()
name := strings.ToLower(filepath.Base(hdr.Filename))
if !strings.HasSuffix(name, ".gpx") && !strings.HasSuffix(name, ".xml") {
s.adminFlash(w, r, "err", "文件类型不正确,请上传 .gpx 轨迹文件")
return
}
data, err := io.ReadAll(io.LimitReader(file, maxGpxBytes+1))
if err != nil {
s.adminFlash(w, r, "err", "读取文件失败:"+err.Error())
return
}
tracks, err := gpx.Parse(data)
if err != nil {
s.adminFlash(w, r, "err", err.Error())
return
}
base := strings.TrimSuffix(filepath.Base(hdr.Filename), filepath.Ext(hdr.Filename))
total := 0
for _, t := range tracks {
pts := make([][2]float64, 0, len(t.Points))
for _, p := range t.Points {
pts = append(pts, [2]float64{round6(p.Lat), round6(p.Lon)})
}
raw, err := json.Marshal(pts)
if err != nil {
s.serverError(w, r, err)
return
}
label := t.Name
if label == "" {
label = base
}
var started any
if !t.Time.IsZero() {
started = t.Time
}
if _, err := s.db.ExecContext(ctx, `INSERT INTO gpx_tracks
(user_id, name, point_count, length_km, started_at, points)
VALUES ($1,$2,$3,$4,$5,$6)`, u.ID, label, len(pts), t.LengthKm(), started, raw); err != nil {
s.serverError(w, r, err)
return
}
total += len(pts)
}
s.adminFlash(w, r, "ok", fmt.Sprintf("GPX 导入成功:%d 条轨迹 / %d 个点", len(tracks), total))
}
func (s *Server) adminGpxDelete(w http.ResponseWriter, r *http.Request) {
id, err := strconv.ParseInt(r.PostFormValue("id"), 10, 64)
if err != nil {
s.adminFlash(w, r, "err", "轨迹编号无效")
return
}
if _, err := s.db.ExecContext(r.Context(), `DELETE FROM gpx_tracks WHERE id=$1`, id); err != nil {
s.serverError(w, r, err)
return
}
s.adminFlash(w, r, "ok", "轨迹已删除")
}
func round6(v float64) float64 { return math.Round(v*1e6) / 1e6 }