Files
rond-with-you/internal/web/handlers_admin.go
T
tamakyi 4015ddf373 时间框跳段 + 弹窗不再脏地址栏 + 统计脚本做成后台开关
时间框(flatpickr allowInput 的手输体验)
- flatpickr 把 <input type=datetime-local> 就地改成 type=text,原生分段编辑器被替换掉;
  allowInput 又是「整串解析」语义:实测 parseDate('2026') 只出年份、'202666' 才出月份,
  于是「年份很长才跳下一位」;'20260606' 还会被静默错解成 2030-12-06 且不报错。
- 新增 static/js/datetime-input.js:自定义 parseDate(纯数字只收 4/6/8/12/14 位,
  歧义与非法日期一律拒)+ 输入引导(段满即补分隔符,敲完 4 位年份立刻跳月份位)。
  按标准格式不做自动补零、不前滚;越界值原样保留交报错,不静默改成合法值。
- 三处页面(编辑/照片/专题)共用 attachPicker,配置与原先一致,模板净减 5 行。

弹窗
- window.prompt 会把提示文本与默认值写进地址栏并留进历史(confirm/alert 无此问题)。
- 新增 static/js/prompt-box.js 替代:原生 <dialog>.showModal(),遮罩/Esc/焦点齐全,
  旧浏览器降级成页面顶部一条。已换 report.html 复制链接兜底与 edit.html 活动改名。
- 门禁 no_prompt_test.go:静态扫模板禁止 prompt(,并钉住 JS 与 CSS 都在。

统计脚本
- 原来只能改 conf/app.ini(要重启),现在后台「站点设置」可开关 + 填写,默认关闭。
- cleanTrackScript 挡住注入面:开闭 <script> 标签数不配平(提前闭合会让页面散架)、
  src 的 javascript:/data: 伪协议、超长截断。注意不能用「出现 </script 就拒」当规则——
  百度统计、Umami 贴的就是完整 HTML,正常用法会被挡死。

门禁
- datetime-input.test.js:34 条断言(node 直跑,无依赖)。
- TestCleanTrackScript:逐条边界 + 定义性不变量(任何输入都不得产出含 </script 的非空结果)。
- 三项均做过变异验证,改坏实现都会被对应测试抓到。

实测:真实站点逐键 2→20→202→2026-→2026-06-→2026-06-06T;
统计脚本六个场景(默认关/只填不开/开+URL/后台与登录页不挂/危险输入/关掉恢复)全部符合预期。
全量 internal/web 204 例 0 skip,其余包全绿,gofmt 干净。
2026-10-08 18:27:59 +08:00

1290 lines
41 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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。
// 登录页的 <img src="/captcha"> 直接引用它,重新加载页面就等于换一张。
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"))
}
// tooManyTries 把等待时长写成一句人话。向上取整,免得显示「请 0 秒后再试」。
func tooManyTries(wait time.Duration) string {
sec := int(wait/time.Second) + 1
if sec < 60 {
return fmt.Sprintf("登录尝试过于频繁,请 %d 秒后再试", sec)
}
return fmt.Sprintf("登录尝试过于频繁,请 %d 分钟后再试", (sec+59)/60)
}
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")
// 先过验证码:错误与过期都只提示重试,不透露别的信息。
// 页面重新渲染时 <img> 会再取一张新图(响应带 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
}
// 爆破短锁:验证码之后还有一道,连错到阈值就按用户名指数退避
if wait := s.loginGuard.RetryAfter(username); wait > 0 {
sets, _ := s.sets.Load(ctx)
s.renderStatus(w, http.StatusTooManyRequests, "admin/login", LoginData{
Settings: sets, Err: tooManyTries(wait), Next: next,
})
return
}
u, err := s.auth.Authenticate(ctx, username, password)
if err != nil {
sets, _ := s.sets.Load(ctx)
code := http.StatusOK
msg := "用户名或密码错误"
if !errors.Is(err, auth.ErrInvalidCredentials) {
// 登录页是公开的,内部错误只进日志,不回显给门外的人
log.Printf("登录失败 %s: %v", username, err)
code = http.StatusInternalServerError
msg = "登录失败,请稍后重试"
} else if wait := s.loginGuard.Fail(username); wait > 0 {
code = http.StatusTooManyRequests
msg = tooManyTries(wait)
}
s.renderStatus(w, code, "admin/login", LoginData{Settings: sets, Err: msg, Next: next})
return
}
s.loginGuard.Succeed(username)
// 已开启两步验证:口令过关只算第一步,签发短时中间令牌进入验证码页
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, r, u.ID, u.Hash)
target := "/admin"
// 同样先过站内路径判据(挡控制字符/引号),再限定必须落在 /admin 下
if next = safeInternalPath(next); 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())
// 图片挂载体检:行与对象对不上就当场可见(见 imageHealth 的注释)
d.ImgRows, d.ImgObjects, d.ImgOrphans = s.imageHealth(r.Context())
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)))
// 落盘之后每条失败路径都要把半成品删掉:否则反复上传坏包会在 uploads 里堆垃圾。
// 只有「新导入成功」那一支会 keepStored = true —— 那份上传件要留给导出当基线源
// (findSourceArchive 就是按 datasets.sha256 在 uploads 里找它)。
keepStored := false
defer func() {
if !keepStored {
os.Remove(stored)
}
}()
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 {
keepStored = true
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
}
next.TrackScript = cleanTrackScript(r.PostFormValue("track_script"))
// 开关只在「勾选 + 脚本非空」时才生效:勾了但没填内容等于没开,
// 否则 layout 里会挂一个空 <script>,看着像开了其实什么也没统计。
next.TrackEnabled = r.PostFormValue("track_enabled") == "on" && next.TrackScript != ""
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)
}
// cleanTrackScript 收拾管理员粘进来的统计脚本。
//
// 这段内容会被原样注入**每一个公开页面**,所以要挡住几类会伤到访客的输入。
// 但**不能**简单地把「出现 </script 就拒」当规则:百度统计、Umami 这类服务
// 贴进来的就是完整 HTML,一行里必然带 </script>,一拒就把正常用法挡死了。
//
// 真正的注入面只有一种:**未闭合的 <script> 里出现 </script> 会提前闭合**,
// 后面的内容当正文渲染,页面布局散架。判据因此是「奇偶」:
// 开标签数与闭合标签数不配平 ⇒ 有提前闭合 ⇒ 拒。配平则整段原样放行。
//
// 另外挡住:javascript: / data: 这类伪协议的 src(点击即执行),
// 以及超长内容(多半是误粘了整份 HTML 文档,截断)。
func cleanTrackScript(v string) string {
v = strings.TrimSpace(v)
if v == "" {
return ""
}
const maxLen = 4096
if len(v) > maxLen {
v = v[:maxLen]
}
low := strings.ToLower(v)
// 开闭标签数必须配平,否则有人能靠 </script> 提前闭合、把后面的内容
// 从「脚本」变成「正文」注入到页面里。
open := strings.Count(low, "<script")
// 去掉属性里的 <script 字样不算开标签(如 data-src="<script"),这里只做保守计数:
// 宁可多拒一次,也别放过真的注入。
shut := strings.Count(low, "</script")
if shut > open {
return ""
}
// src 只放行 http/https 与站内相对路径,挡掉 javascript: / data: 伪协议
if idx := strings.Index(low, "src="); idx >= 0 {
rest := strings.TrimLeft(v[idx+4:], "\"'")
if rest != "" && !strings.HasPrefix(rest, "http://") &&
!strings.HasPrefix(rest, "https://") && !strings.HasPrefix(rest, "/") {
return ""
}
}
return v
}
// ---------- 修改口令 ----------
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":
if err := s.notes.DeleteBulk(r.Context(), u.ID, ds.ID, ids); err != nil {
s.serverError(w, r, err)
return
}
msg = fmt.Sprintf("已删除 %d 个地点的备注与别名", len(ids))
case "clear":
if err := s.notes.SetNoteBulk(r.Context(), u.ID, ds.ID, ids); 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
}
if err := s.votes.SaveVerdictBulk(r.Context(), u.ID, ds.ID, ids, 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 := safeInternalPath(r.PostFormValue("next")); 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
}
// 两步验证也要限速:中间令牌有 5 分钟有效期,不限速的话 6 位码在窗口内
// 足够被打穿。键带前缀,与「口令那一环」分开计数。
guardKey := "2fa:" + u.Username
if wait := s.loginGuard.RetryAfter(guardKey); wait > 0 {
fail(tooManyTries(wait))
return
}
if !auth.VerifyTOTP(secret, code) {
if wait := s.loginGuard.Fail(guardKey); wait > 0 {
fail(tooManyTries(wait))
return
}
fail("验证码不正确")
return
}
// 同一个码在有效窗口内只用一次,否则被偷看的码可以再换一个会话。
// 这一条不计入爆破计数:用户手抖重复提交也会撞上,不该因此被越锁越久。
if !s.totpReplay.Consume(u.ID, code) {
fail("这个验证码刚用过,请等下一个")
return
}
s.loginGuard.Succeed(guardKey)
s.login(w, r, u.ID, u.Hash)
target := "/admin"
if next = safeInternalPath(next); 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) {
u := userFrom(r.Context())
id, err := strconv.ParseInt(r.PostFormValue("id"), 10, 64)
if err != nil {
s.adminFlash(w, r, "err", "轨迹编号无效")
return
}
// 带上 user_id:这个 id 是客户端传来的,只按 id 删就能删掉别人的轨迹。
res, err := s.db.ExecContext(r.Context(), `DELETE FROM gpx_tracks WHERE id=$1 AND user_id=$2`, id, u.ID)
if err != nil {
s.serverError(w, r, err)
return
}
if n, _ := res.RowsAffected(); n == 0 {
s.adminFlash(w, r, "err", "轨迹不存在")
return
}
s.adminFlash(w, r, "ok", "轨迹已删除")
}
func round6(v float64) float64 { return math.Round(v*1e6) / 1e6 }