From 0f0f211ac2be56bdf47ed7ca3e065d5ac77e5503 Mon Sep 17 00:00:00 2001 From: npmrun <1549469775@qq.com> Date: Wed, 6 May 2026 23:24:17 +0800 Subject: [PATCH] feat: initialize frontend with Svelte and Vite - Add package.json for frontend dependencies and scripts - Create AdminPanel component for managing short links - Implement App component for shortening URLs and admin access - Add API utility functions for token management and link operations - Set up main entry point for the Svelte application - Configure Vite with proxy settings for API requests - Create systemd service file for the short link server Co-authored-by: Copilot --- .drone.yml | 31 + .gitignore | 4 + Caddyfile | 16 + README.md | 184 ++++++ backend/admin.go | 109 ++++ backend/go.mod | 3 + backend/handler.go | 166 +++++ backend/handler_test.go | 239 ++++++++ backend/main.go | 70 +++ backend/shortlink.db | 20 + backend/store.go | 149 +++++ backend/store_test.go | 121 ++++ frontend/index.html | 13 + frontend/package-lock.json | 1299 ++++++++++++++++++++++++++++++++++++++++ frontend/package.json | 16 + frontend/src/AdminPanel.svelte | 576 ++++++++++++++++++ frontend/src/App.svelte | 301 ++++++++++ frontend/src/api.js | 72 +++ frontend/src/main.js | 7 + frontend/vite.config.js | 12 + short-link.service | 18 + 21 files changed, 3426 insertions(+) create mode 100644 .drone.yml create mode 100644 .gitignore create mode 100644 Caddyfile create mode 100644 README.md create mode 100644 backend/admin.go create mode 100644 backend/go.mod create mode 100644 backend/handler.go create mode 100644 backend/handler_test.go create mode 100644 backend/main.go create mode 100644 backend/shortlink.db create mode 100644 backend/store.go create mode 100644 backend/store_test.go create mode 100644 frontend/index.html create mode 100644 frontend/package-lock.json create mode 100644 frontend/package.json create mode 100644 frontend/src/AdminPanel.svelte create mode 100644 frontend/src/App.svelte create mode 100644 frontend/src/api.js create mode 100644 frontend/src/main.js create mode 100644 frontend/vite.config.js create mode 100644 short-link.service diff --git a/.drone.yml b/.drone.yml new file mode 100644 index 0000000..fc7ec0f --- /dev/null +++ b/.drone.yml @@ -0,0 +1,31 @@ +kind: pipeline +type: exec +name: deploy + +platform: + os: linux + arch: amd64 + +steps: + - name: build-backend + commands: + - cd backend && CGO_ENABLED=0 go build -ldflags="-s -w" -o short-link-server . + + - name: build-frontend + commands: + - cd frontend && npm ci && npm run build + + - name: deploy + commands: + - cp backend/short-link-server /opt/short-link/short-link-server.new + - cp -r frontend/dist/* /opt/short-link/frontend/ + - mv /opt/short-link/short-link-server.new /opt/short-link/short-link-server + - systemctl restart short-link + - systemctl reload caddy + depends_on: + - build-backend + - build-frontend + +trigger: + branch: + - main diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e600c0a --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +node_modules +dist +backend/.go +backend/file::memory:?cache=shared diff --git a/Caddyfile b/Caddyfile new file mode 100644 index 0000000..aed8792 --- /dev/null +++ b/Caddyfile @@ -0,0 +1,16 @@ +xieyaxin.top:8899 { + @api path /api/* + handle @api { + reverse_proxy localhost:8080 + } + + @shortcode path_regexp shortcode ^/[a-zA-Z0-9]{7}$ + handle @shortcode { + reverse_proxy localhost:8080 + } + + handle { + root * /opt/short-link/frontend + file_server + } +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..cdfe4c8 --- /dev/null +++ b/README.md @@ -0,0 +1,184 @@ +# 短链接生成器 + +粘贴长链接,一键变短。极简工具网站。 + +## 架构 + +``` +用户请求 → Caddy + ├── /api/* → Go 后端 :8080 + ├── /[a-zA-Z0-9]{7} → Go 后端 :8080 (短码重定向) + └── 其他 → 静态文件 /opt/short-link/frontend +``` + +``` +后端: Go 1.22 + net/http(标准库路由) + JSON 文件持久化 +前端: Svelte 4 + Vite 5 +网关: Caddy(自动 HTTPS) +部署: Drone CI → systemd +``` + +## 本地开发 + +### 后端 + +```bash +cd backend + +# 安装依赖 +go mod tidy + +# 运行 (默认 :8080) +ADDR=:8080 DOMAIN=http://localhost:8080 DB_PATH=./data.db ADMIN_TOKEN=dev go run . + +# 运行测试 +go test ./... +``` + +### 前端 + +```bash +cd frontend + +# 安装依赖 +npm install + +# 开发模式 (默认 :5173, API 请求会由 Vite 转发配置决定) +npm run dev + +# 构建 +npm run build +``` + +## 部署 + +### 环境要求 + +- Go 1.22+ +- Node.js 20+ +- Caddy 2.x +- systemd (Linux) + +### 手动部署 + +```bash +# 1. 构建 +cd backend && CGO_ENABLED=0 go build -ldflags="-s -w" -o short-link-server . +cd ../frontend && npm install && npm run build + +# 2. 部署文件 +mkdir -p /opt/short-link/data /opt/short-link/frontend +cp backend/short-link-server /opt/short-link/ +cp -r frontend/dist/* /opt/short-link/frontend/ + +# 3. 生成管理员 Token +openssl rand -hex 32 # 填入 short-link.service 的 ADMIN_TOKEN + +# 4. 注册服务 +cp short-link.service /etc/systemd/system/ +systemctl daemon-reload +systemctl enable --now short-link + +# 5. Caddy +cp Caddyfile /etc/caddy/ +systemctl reload caddy +``` + +### Drone CI 自动部署 + +推送到 `main` 分支自动触发。需在 Drone 配置 `ssh_key` secret(部署机 SSH 私钥)。 + +`.drone.yml` 完成了三步流水线: + +1. `golang:1.22` — 编译 Go 二进制 +2. `node:20-alpine` — 构建前端 +3. `alpine:3` — 上传文件 + `systemctl restart short-link` + `systemctl reload caddy` + +## 配置 + +| 环境变量 | 默认值 | 说明 | +|----------|--------|------| +| `ADDR` | `:8080` | 监听地址 | +| `DOMAIN` | `http://localhost:8080` | 短链接域名,用于拼接 short_url | +| `DB_PATH` | `./data.db` | JSON 数据文件路径 | +| `ADMIN_TOKEN` | (空) | 管理员 Bearer Token,空值时所有管理接口返回 401 | + +## API + +### POST /api/shorten + +``` +请求: { "url": "https://example.com/very/long/path" } +响应: { "code": "abc1234", "short_url": "https://xieyaxin.top:8899/abc1234", "original_url": "https://example.com/very/long/path" } +``` + +- 自动补全 `https://` 前缀 +- 仅支持 http/https 协议 +- 返回 400(URL 不合法)、500(服务端错误) + +### GET /:code + +``` +GET /abc1234 → 302 Found → Location: https://example.com/... +``` + +- 存在:302 临时重定向 +- 不存在:404 + +### 管理接口 + +以下接口需在请求头中携带 `Authorization: Bearer `。 + +#### GET /api/admin/links + +列出所有短链接,按创建时间倒序。 + +``` +→ 200 [{ "code": "abc1234", "original_url": "https://...", "visit_count": 42, "created_at": "2026-05-06T14:30:00Z" }] +→ 401 未授权 +``` + +#### PUT /api/admin/links/{code} + +更新短链接指向的原始 URL。 + +``` +请求: { "url": "https://new.example.com" } +→ 200 { "code": "abc1234", "original_url": "https://new.example.com", ... } +→ 404 短码不存在 +``` + +#### DELETE /api/admin/links/{code} + +删除短链接。 + +``` +→ 200 { "ok": true } +→ 404 短码不存在 +``` + +#### GET /api/admin/export + +导出全部数据。 + +``` +GET /api/admin/export → JSON 文件下载 (application/json) +GET /api/admin/export?format=csv → CSV 文件下载 (text/csv) +``` + +## 短码设计 + +- 7 位 base62 字符集 (a-z, A-Z, 0-9) +- `crypto/rand` 密码学安全随机数 +- 62^7 ≈ 3.5 万亿组合 +- 插入前检查唯一约束,碰撞重试最多 3 次 + +## 管理后台 + +访问 `/#admin` 进入管理界面: + +1. 输入 `ADMIN_TOKEN` 登录(浏览器 sessionStorage 保存,关闭标签页自动清除) +2. 表格展示所有短链接:短码、原始链接、访问次数、创建时间 +3. 行内编辑:修改原始 URL +4. 行内删除:确认后删除 +5. 导出数据:支持 JSON / CSV 格式下载 diff --git a/backend/admin.go b/backend/admin.go new file mode 100644 index 0000000..862dfdd --- /dev/null +++ b/backend/admin.go @@ -0,0 +1,109 @@ +package main + +import ( + "encoding/csv" + "encoding/json" + "log" + "net/http" + "strconv" + "strings" +) + +// HandleListLinks handles GET /api/admin/links — returns all records. +func (h *Handler) HandleListLinks(w http.ResponseWriter, r *http.Request) { + records, err := h.store.ListAll() + if err != nil { + log.Printf("list links error: %v", err) + jsonError(w, "internal error", http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(records) +} + +// HandleUpdateLink handles PUT /api/admin/links/{code} — updates a link's URL. +func (h *Handler) HandleUpdateLink(w http.ResponseWriter, r *http.Request) { + code := r.PathValue("code") + if code == "" { + jsonError(w, "missing short code", http.StatusBadRequest) + return + } + + var req struct { + URL string `json:"url"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + jsonError(w, "invalid request body", http.StatusBadRequest) + return + } + + req.URL = strings.TrimSpace(req.URL) + if req.URL == "" { + jsonError(w, "url is required", http.StatusBadRequest) + return + } + if !strings.HasPrefix(req.URL, "http://") && !strings.HasPrefix(req.URL, "https://") { + jsonError(w, "url must start with http:// or https://", http.StatusBadRequest) + return + } + + if err := h.store.Update(code, req.URL); err != nil { + log.Printf("update link error: %v", err) + jsonError(w, "short link not found", http.StatusNotFound) + return + } + + rec, _ := h.store.FindByCode(code) + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(rec) +} + +// HandleDeleteLink handles DELETE /api/admin/links/{code} — deletes a link. +func (h *Handler) HandleDeleteLink(w http.ResponseWriter, r *http.Request) { + code := r.PathValue("code") + if code == "" { + jsonError(w, "missing short code", http.StatusBadRequest) + return + } + + if err := h.store.Delete(code); err != nil { + log.Printf("delete link error: %v", err) + jsonError(w, "short link not found", http.StatusNotFound) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]bool{"ok": true}) +} + +// HandleExport handles GET /api/admin/export?format=json|csv — exports all data. +func (h *Handler) HandleExport(w http.ResponseWriter, r *http.Request) { + records, err := h.store.ListAll() + if err != nil { + log.Printf("export error: %v", err) + jsonError(w, "internal error", http.StatusInternalServerError) + return + } + + format := r.URL.Query().Get("format") + if format == "csv" { + w.Header().Set("Content-Type", "text/csv; charset=utf-8") + w.Header().Set("Content-Disposition", "attachment; filename=\"shortlinks.csv\"") + wr := csv.NewWriter(w) + wr.Write([]string{"code", "original_url", "visit_count", "created_at"}) + for _, rec := range records { + wr.Write([]string{ + rec.Code, + rec.OriginalURL, + strconv.FormatInt(rec.VisitCount, 10), + rec.CreatedAt.UTC().Format("2006-01-02T15:04:05Z"), + }) + } + wr.Flush() + return + } + + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Content-Disposition", "attachment; filename=\"shortlinks.json\"") + json.NewEncoder(w).Encode(records) +} diff --git a/backend/go.mod b/backend/go.mod new file mode 100644 index 0000000..02f4aa3 --- /dev/null +++ b/backend/go.mod @@ -0,0 +1,3 @@ +module shortlink + +go 1.22 diff --git a/backend/handler.go b/backend/handler.go new file mode 100644 index 0000000..06192e5 --- /dev/null +++ b/backend/handler.go @@ -0,0 +1,166 @@ +package main + +import ( + "crypto/rand" + "encoding/json" + "log" + "math/big" + "net/http" + "strings" +) + +const ( + base62Chars = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ" + codeLength = 7 + maxRetries = 3 +) + +// Handler holds references for HTTP handlers. +type Handler struct { + store *Store + baseURL string // optional; falls back to request Host + adminToken string +} + +// NewHandler creates a Handler. +func NewHandler(store *Store, baseURL, adminToken string) *Handler { + return &Handler{store: store, baseURL: baseURL, adminToken: adminToken} +} + +// generateCode produces a 7-character base62 code using crypto/rand. +func generateCode() (string, error) { + buf := make([]byte, codeLength) + for i := range buf { + n, err := rand.Int(rand.Reader, big.NewInt(62)) + if err != nil { + return "", err + } + buf[i] = base62Chars[n.Int64()] + } + return string(buf), nil +} + +// requireAdmin wraps a handler with Bearer token authentication. +// If adminToken is empty, all requests are rejected (fail-secure). +func (h *Handler) requireAdmin(next http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + token := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ") + if h.adminToken == "" || token != h.adminToken { + jsonError(w, "unauthorized", http.StatusUnauthorized) + return + } + next(w, r) + } +} + +// --- helpers --- + +func jsonError(w http.ResponseWriter, msg string, status int) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + json.NewEncoder(w).Encode(map[string]string{"error": msg}) +} + +// --- handlers --- + +// HandleCreate handles POST /api/shorten. +func (h *Handler) HandleCreate(w http.ResponseWriter, r *http.Request) { + var req struct { + URL string `json:"url"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + jsonError(w, "invalid request body", http.StatusBadRequest) + return + } + + req.URL = strings.TrimSpace(req.URL) + if req.URL == "" { + jsonError(w, "url is required", http.StatusBadRequest) + return + } + if !strings.HasPrefix(req.URL, "http://") && !strings.HasPrefix(req.URL, "https://") { + jsonError(w, "url must start with http:// or https://", http.StatusBadRequest) + return + } + + var code string + var err error + for attempt := 0; attempt < maxRetries; attempt++ { + code, err = generateCode() + if err != nil { + log.Printf("generate code error: %v", err) + jsonError(w, "failed to generate short code", http.StatusInternalServerError) + return + } + + if err = h.store.Create(code, req.URL); err == nil { + break // success + } + log.Printf("collision on code %q (attempt %d): %v", code, attempt+1, err) + } + if err != nil { + log.Printf("store create error after %d retries: %v", maxRetries, err) + jsonError(w, "failed to create short link", http.StatusInternalServerError) + return + } + + shortURL := h.resolveBaseURL(r) + "/" + code + + resp := map[string]string{ + "code": code, + "short_url": shortURL, + "original_url": req.URL, + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(resp) +} + +// HandleRedirect handles GET /{code}, redirecting to the original URL. +func (h *Handler) HandleRedirect(w http.ResponseWriter, r *http.Request) { + code := r.PathValue("code") + if code == "" { + jsonError(w, "missing short code", http.StatusBadRequest) + return + } + + // Skip API-like paths that snuck through the mux + if strings.HasPrefix(code, "api/") { + jsonError(w, "not found", http.StatusNotFound) + return + } + + url, err := h.store.FindByCode(code) + if err != nil { + log.Printf("store find error: %v", err) + jsonError(w, "internal error", http.StatusInternalServerError) + return + } + if url == nil { + jsonError(w, "short link not found", http.StatusNotFound) + return + } + + // Fire-and-forget visit counter + go func() { + if err := h.store.IncrementVisit(url.Code); err != nil { + log.Printf("increment visit error: %v", err) + } + }() + + // 302 — browsers don't cache it, user can update the link later + http.Redirect(w, r, url.OriginalURL, http.StatusFound) +} + +// resolveBaseURL returns the configured base URL or constructs one from the +// incoming request's Host header. +func (h *Handler) resolveBaseURL(r *http.Request) string { + if h.baseURL != "" { + return strings.TrimRight(h.baseURL, "/") + } + scheme := "http" + if r.TLS != nil { + scheme = "https" + } + return scheme + "://" + r.Host +} diff --git a/backend/handler_test.go b/backend/handler_test.go new file mode 100644 index 0000000..7eb9633 --- /dev/null +++ b/backend/handler_test.go @@ -0,0 +1,239 @@ +package main + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "path/filepath" + "strings" + "testing" +) + +func newTestHandler(t *testing.T) *Handler { + t.Helper() + store, err := NewStore(filepath.Join(t.TempDir(), "test.db")) + if err != nil { + t.Fatalf("NewStore() failed: %v", err) + } + t.Cleanup(func() { store.Close() }) + return NewHandler(store, "http://xieyaxin.top:8899", "test-token") +} + +func decodeBody(t *testing.T, body string) map[string]string { + t.Helper() + var resp map[string]string + if err := json.NewDecoder(strings.NewReader(body)).Decode(&resp); err != nil { + t.Fatalf("failed to decode response: %v", err) + } + return resp +} + +func TestHandleCreate_Valid(t *testing.T) { + h := newTestHandler(t) + + body := `{"url":"https://example.com"}` + w := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodPost, "/api/shorten", strings.NewReader(body)) + r.Header.Set("Content-Type", "application/json") + h.HandleCreate(w, r) + + if w.Code != http.StatusOK { + t.Fatalf("expected status 200, got %d: %s", w.Code, w.Body.String()) + } + + resp := decodeBody(t, w.Body.String()) + if len(resp["code"]) != 7 { + t.Fatalf("expected code length 7, got %d: %q", len(resp["code"]), resp["code"]) + } + if resp["short_url"] != "http://xieyaxin.top:8899/"+resp["code"] { + t.Fatalf("expected short_url %q, got %q", "http://xieyaxin.top:8899/"+resp["code"], resp["short_url"]) + } + if resp["original_url"] != "https://example.com" { + t.Fatalf("expected original_url %q, got %q", "https://example.com", resp["original_url"]) + } +} + +func TestHandleCreate_Consecutive(t *testing.T) { + h := newTestHandler(t) + + body1 := `{"url":"https://example.com/first"}` + w1 := httptest.NewRecorder() + r1 := httptest.NewRequest(http.MethodPost, "/api/shorten", strings.NewReader(body1)) + r1.Header.Set("Content-Type", "application/json") + h.HandleCreate(w1, r1) + + body2 := `{"url":"https://example.com/second"}` + w2 := httptest.NewRecorder() + r2 := httptest.NewRequest(http.MethodPost, "/api/shorten", strings.NewReader(body2)) + r2.Header.Set("Content-Type", "application/json") + h.HandleCreate(w2, r2) + + resp1 := decodeBody(t, w1.Body.String()) + resp2 := decodeBody(t, w2.Body.String()) + + if len(resp1["code"]) != 7 { + t.Fatalf("expected code length 7, got %d: %q", len(resp1["code"]), resp1["code"]) + } + if len(resp2["code"]) != 7 { + t.Fatalf("expected code length 7, got %d: %q", len(resp2["code"]), resp2["code"]) + } + if resp1["code"] == resp2["code"] { + t.Fatal("consecutive short links should have different codes") + } +} + +func TestHandleCreate_MissingURL(t *testing.T) { + h := newTestHandler(t) + + w := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodPost, "/api/shorten", strings.NewReader(`{}`)) + r.Header.Set("Content-Type", "application/json") + h.HandleCreate(w, r) + + if w.Code != http.StatusBadRequest { + t.Fatalf("expected status 400, got %d: %s", w.Code, w.Body.String()) + } + + resp := decodeBody(t, w.Body.String()) + if resp["error"] == "" { + t.Fatal("expected error message in response") + } +} + +func TestHandleCreate_EmptyURL(t *testing.T) { + h := newTestHandler(t) + + w := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodPost, "/api/shorten", strings.NewReader(`{"url":""}`)) + r.Header.Set("Content-Type", "application/json") + h.HandleCreate(w, r) + + if w.Code != http.StatusBadRequest { + t.Fatalf("expected status 400, got %d", w.Code) + } +} + +func TestHandleCreate_InvalidScheme(t *testing.T) { + h := newTestHandler(t) + + w := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodPost, "/api/shorten", strings.NewReader(`{"url":"ftp://example.com"}`)) + r.Header.Set("Content-Type", "application/json") + h.HandleCreate(w, r) + + if w.Code != http.StatusBadRequest { + t.Fatalf("expected status 400 for unsupported scheme, got %d", w.Code) + } +} + +func TestHandleCreate_WrongMethod(t *testing.T) { + h := newTestHandler(t) + + w := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodGet, "/api/shorten", nil) + h.HandleCreate(w, r) + + // HandleCreate itself does not check method (mux handles routing) + if w.Code != http.StatusBadRequest { + t.Fatalf("expected status 400 for GET request (no body), got %d", w.Code) + } +} + +func TestHandleCreate_InvalidBody(t *testing.T) { + h := newTestHandler(t) + + w := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodPost, "/api/shorten", strings.NewReader(`not json`)) + r.Header.Set("Content-Type", "application/json") + h.HandleCreate(w, r) + + if w.Code != http.StatusBadRequest { + t.Fatalf("expected status 400 for invalid JSON, got %d", w.Code) + } +} + +func TestHandleRedirect_Valid(t *testing.T) { + h := newTestHandler(t) + + // Create a short link first + body := `{"url":"https://example.com"}` + w := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodPost, "/api/shorten", strings.NewReader(body)) + r.Header.Set("Content-Type", "application/json") + h.HandleCreate(w, r) + + resp := decodeBody(t, w.Body.String()) + code := resp["code"] + + // Test redirect + w = httptest.NewRecorder() + r = httptest.NewRequest(http.MethodGet, "/"+code, nil) + r.SetPathValue("code", code) + h.HandleRedirect(w, r) + + if w.Code != http.StatusFound { + t.Fatalf("expected status 302 for valid code, got %d", w.Code) + } + if loc := w.Header().Get("Location"); loc != "https://example.com" { + t.Fatalf("expected Location header %q, got %q", "https://example.com", loc) + } +} + +func TestHandleRedirect_NotFound(t *testing.T) { + h := newTestHandler(t) + + w := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodGet, "/nonexist", nil) + r.SetPathValue("code", "nonexist") + h.HandleRedirect(w, r) + + if w.Code != http.StatusNotFound { + t.Fatalf("expected status 404 for non-existent code, got %d", w.Code) + } +} + +func TestHandleRedirect_EmptyCode(t *testing.T) { + h := newTestHandler(t) + + w := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodGet, "/", nil) + r.SetPathValue("code", "") + h.HandleRedirect(w, r) + + if w.Code != http.StatusBadRequest { + t.Fatalf("expected status 400 for empty code, got %d", w.Code) + } +} + +func TestStore_IncrementVisit(t *testing.T) { + h := newTestHandler(t) + + // Create a short link + w := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodPost, "/api/shorten", strings.NewReader(`{"url":"https://example.com"}`)) + r.Header.Set("Content-Type", "application/json") + h.HandleCreate(w, r) + + resp := decodeBody(t, w.Body.String()) + code := resp["code"] + + rec, err := h.store.FindByCode(code) + if err != nil || rec == nil { + t.Fatal("record not found after create") + } + + if err := h.store.IncrementVisit(code); err != nil { + t.Fatalf("IncrementVisit() failed: %v", err) + } + if err := h.store.IncrementVisit(code); err != nil { + t.Fatalf("IncrementVisit() failed: %v", err) + } + + rec, err = h.store.FindByCode(code) + if err != nil || rec == nil { + t.Fatal("record not found after increment") + } + if rec.VisitCount != 2 { + t.Fatalf("expected VisitCount 2, got %d", rec.VisitCount) + } +} diff --git a/backend/main.go b/backend/main.go new file mode 100644 index 0000000..77de2be --- /dev/null +++ b/backend/main.go @@ -0,0 +1,70 @@ +package main + +import ( + "context" + "flag" + "log" + "net/http" + "os" + "os/signal" + "strings" + "syscall" + "time" +) + +func main() { + port := flag.String("port", "8080", "listen port") + dbPath := flag.String("db", "shortlink.db", "sqlite database path") + baseURL := flag.String("base-url", "", "base url for short links (e.g. https://xieyaxin.top:8899)") + flag.Parse() + + store, err := NewStore(*dbPath) + if err != nil { + log.Fatalf("failed to open database: %v", err) + } + defer store.Close() + + adminToken := strings.TrimSpace(os.Getenv("ADMIN_TOKEN")) + h := NewHandler(store, *baseURL, adminToken) + + mux := http.NewServeMux() + mux.HandleFunc("POST /api/shorten", h.HandleCreate) + mux.HandleFunc("GET /{code}", h.HandleRedirect) + + // Admin routes + mux.HandleFunc("GET /api/admin/links", h.requireAdmin(h.HandleListLinks)) + mux.HandleFunc("PUT /api/admin/links/{code}", h.requireAdmin(h.HandleUpdateLink)) + mux.HandleFunc("DELETE /api/admin/links/{code}", h.requireAdmin(h.HandleDeleteLink)) + mux.HandleFunc("GET /api/admin/export", h.requireAdmin(h.HandleExport)) + + srv := &http.Server{ + Addr: ":" + *port, + Handler: mux, + ReadTimeout: 10 * time.Second, + WriteTimeout: 10 * time.Second, + IdleTimeout: 60 * time.Second, + } + + // Graceful shutdown — wait for SIGINT/SIGTERM, then drain connections + quit := make(chan os.Signal, 1) + signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) + + go func() { + log.Printf("listening on :%s", *port) + if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { + log.Fatalf("server error: %v", err) + } + }() + + <-quit + log.Println("shutting down...") + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + if err := srv.Shutdown(ctx); err != nil { + log.Fatalf("forced shutdown: %v", err) + } + + log.Println("bye") +} diff --git a/backend/shortlink.db b/backend/shortlink.db new file mode 100644 index 0000000..90987ac --- /dev/null +++ b/backend/shortlink.db @@ -0,0 +1,20 @@ +[ + { + "code": "BRmGN8s", + "original_url": "https://platform.deepseek.com/usage", + "visit_count": 2, + "created_at": "2026-05-06T22:50:16.706444479+08:00" + }, + { + "code": "YLHlgN0", + "original_url": "https://platform.deepseek.com/usage", + "visit_count": 2, + "created_at": "2026-05-06T23:09:34.035144208+08:00" + }, + { + "code": "DSqH5EQ", + "original_url": "https://platform.deepseek.com/usage", + "visit_count": 4, + "created_at": "2026-05-06T23:12:26.936674827+08:00" + } +] \ No newline at end of file diff --git a/backend/store.go b/backend/store.go new file mode 100644 index 0000000..1f56df2 --- /dev/null +++ b/backend/store.go @@ -0,0 +1,149 @@ +package main + +import ( + "encoding/json" + "fmt" + "os" + "sort" + "sync" + "sync/atomic" + "time" +) + +// URLRecord represents a stored link. +type URLRecord struct { + Code string `json:"code"` + OriginalURL string `json:"original_url"` + VisitCount int64 `json:"visit_count"` + CreatedAt time.Time `json:"created_at"` +} + +// Store is a simple JSON-file-backed key-value store. +type Store struct { + mu sync.RWMutex + file string + links map[string]*URLRecord // code -> record +} + +// NewStore opens (or creates) the JSON-backed store. +func NewStore(dbPath string) (*Store, error) { + s := &Store{ + file: dbPath, + links: make(map[string]*URLRecord), + } + + data, err := os.ReadFile(dbPath) + if err == nil { + var records []*URLRecord + if err := json.Unmarshal(data, &records); err == nil { + for _, rec := range records { + s.links[rec.Code] = rec + } + } + } else if !os.IsNotExist(err) { + return nil, fmt.Errorf("open store: %w", err) + } + + return s, nil +} + +// Create inserts a new URL with the given code. Returns an error if the code +// already exists. +func (s *Store) Create(code, originalURL string) error { + s.mu.Lock() + defer s.mu.Unlock() + + if _, exists := s.links[code]; exists { + return fmt.Errorf("code %q already exists", code) + } + + s.links[code] = &URLRecord{ + Code: code, + OriginalURL: originalURL, + CreatedAt: time.Now(), + } + return s.persist() +} + +// FindByCode looks up a URL record by its short code. +func (s *Store) FindByCode(code string) (*URLRecord, error) { + s.mu.RLock() + defer s.mu.RUnlock() + + rec, ok := s.links[code] + if !ok { + return nil, nil + } + return rec, nil +} + +// IncrementVisit atomically bumps the visit counter for the given code. +func (s *Store) IncrementVisit(code string) error { + s.mu.Lock() + defer s.mu.Unlock() + + rec, ok := s.links[code] + if !ok { + return fmt.Errorf("code %q not found", code) + } + + atomic.AddInt64(&rec.VisitCount, 1) + return s.persist() +} + +// Close is a no-op for the JSON store. +func (s *Store) Close() error { + return nil +} + +// ListAll returns all records sorted by CreatedAt descending (newest first). +func (s *Store) ListAll() ([]*URLRecord, error) { + s.mu.RLock() + defer s.mu.RUnlock() + + records := make([]*URLRecord, 0, len(s.links)) + for _, rec := range s.links { + records = append(records, rec) + } + sort.Slice(records, func(i, j int) bool { + return records[i].CreatedAt.After(records[j].CreatedAt) + }) + return records, nil +} + +// Update changes the OriginalURL of the record identified by code. +func (s *Store) Update(code, newURL string) error { + s.mu.Lock() + defer s.mu.Unlock() + + rec, ok := s.links[code] + if !ok { + return fmt.Errorf("code %q not found", code) + } + rec.OriginalURL = newURL + return s.persist() +} + +// Delete removes the record identified by code. +func (s *Store) Delete(code string) error { + s.mu.Lock() + defer s.mu.Unlock() + + if _, ok := s.links[code]; !ok { + return fmt.Errorf("code %q not found", code) + } + delete(s.links, code) + return s.persist() +} + +func (s *Store) persist() error { + records := make([]*URLRecord, 0, len(s.links)) + for _, rec := range s.links { + records = append(records, rec) + } + data, err := json.MarshalIndent(records, "", " ") + if err != nil { + return err + } + return os.WriteFile(s.file, data, 0644) +} diff --git a/backend/store_test.go b/backend/store_test.go new file mode 100644 index 0000000..b46e28c --- /dev/null +++ b/backend/store_test.go @@ -0,0 +1,121 @@ +package main + +import ( + "os" + "path/filepath" + "testing" +) + +func newTestStore(t *testing.T) *Store { + t.Helper() + s, err := NewStore(filepath.Join(t.TempDir(), "test.db")) + if err != nil { + t.Fatalf("NewStore() failed: %v", err) + } + t.Cleanup(func() { s.Close() }) + return s +} + +func TestStore_CreateAndFindByCode(t *testing.T) { + s := newTestStore(t) + + originalURL := "https://example.com/create-and-find" + + if err := s.Create("testabc", originalURL); err != nil { + t.Fatalf("Create() failed: %v", err) + } + + rec, err := s.FindByCode("testabc") + if err != nil { + t.Fatalf("FindByCode() failed: %v", err) + } + if rec == nil { + t.Fatal("FindByCode() returned nil, expected a record") + } + if rec.OriginalURL != originalURL { + t.Fatalf("expected OriginalURL %q, got %q", originalURL, rec.OriginalURL) + } + if rec.Code != "testabc" { + t.Fatalf("expected Code %q, got %q", "testabc", rec.Code) + } +} + +func TestStore_FindByCode_NonExistent(t *testing.T) { + s := newTestStore(t) + + rec, err := s.FindByCode("nonexist") + if err != nil { + t.Fatalf("FindByCode() for non-existent code failed: %v", err) + } + if rec != nil { + t.Fatal("expected nil for non-existent code, got a record") + } +} + +func TestStore_Create_Duplicate(t *testing.T) { + s := newTestStore(t) + + url := "https://example.com/dup" + if err := s.Create("dupcode", url); err != nil { + t.Fatalf("first Create() failed: %v", err) + } + if err := s.Create("dupcode", url); err == nil { + t.Fatal("second Create() with same code should fail") + } +} + +func TestStore_Persistence(t *testing.T) { + tmpFile, err := os.CreateTemp("", "shortlink-*.json") + if err != nil { + t.Fatal(err) + } + tmpPath := tmpFile.Name() + tmpFile.Close() + defer os.Remove(tmpPath) + + s1, err := NewStore(tmpPath) + if err != nil { + t.Fatalf("NewStore() failed: %v", err) + } + s1.Create("persist", "https://example.com/persist") + s1.Close() + + s2, err := NewStore(tmpPath) + if err != nil { + t.Fatalf("reopen store failed: %v", err) + } + defer s2.Close() + + rec, err := s2.FindByCode("persist") + if err != nil { + t.Fatalf("FindByCode() after reopen failed: %v", err) + } + if rec == nil { + t.Fatal("expected record after reopening store") + } + if rec.OriginalURL != "https://example.com/persist" { + t.Fatalf("unexpected OriginalURL: %q", rec.OriginalURL) + } +} + +func TestGenerateCode(t *testing.T) { + seen := make(map[string]bool) + for i := 0; i < 100; i++ { + code, err := generateCode() + if err != nil { + t.Fatalf("generateCode() failed: %v", err) + } + if len(code) != codeLength { + t.Fatalf("expected code length %d, got %d: %q", codeLength, len(code), code) + } + for _, c := range code { + if !((c >= '0' && c <= '9') || (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z')) { + t.Fatalf("invalid character %c in code %q", c, code) + } + } + if seen[code] { + t.Fatal("unexpected collision: duplicate code generated") + } + seen[code] = true + } +} diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..d8c99b5 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,13 @@ + + + + + + 短链接工具 + + + +
+ + + diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..e021083 --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,1299 @@ +{ + "name": "short-link-frontend", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "short-link-frontend", + "version": "0.1.0", + "devDependencies": { + "@sveltejs/vite-plugin-svelte": "^3.1.0", + "svelte": "^4.2.18", + "vite": "^5.4.0" + } + }, + "node_modules/@ampproject/remapping": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", + "integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.3.tgz", + "integrity": "sha512-x35CNW/ANXG3hE/EZpRU8MXX1JDN86hBb2wMGAtltkz7pc6cxgjpy1OMMfDosOQ+2hWqIkag/fGok1Yady9nGw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.3.tgz", + "integrity": "sha512-xw3xtkDApIOGayehp2+Rz4zimfkaX65r4t47iy+ymQB2G4iJCBBfj0ogVg5jpvjpn8UWn/+q9tprxleYeNp3Hw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.3.tgz", + "integrity": "sha512-vo6Y5Qfpx7/5EaamIwi0WqW2+zfiusVihKatLvtN1VFVy3D13uERk/6gZLU1UiHRL6fDXqj/ELIeVRGnvcTE1g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.3.tgz", + "integrity": "sha512-D+0QGcZhBzTN82weOnsSlY7V7+RMmPuF1CkbxyMAGE8+ZHeUjyb76ZiWmBlCu//AQQONvxcqRbwZTajZKqjuOw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.3.tgz", + "integrity": "sha512-6HnvHCT7fDyj6R0Ph7A6x8dQS/S38MClRWeDLqc0MdfWkxjiu1HSDYrdPhqSILzjTIC/pnXbbJbo+ft+gy/9hQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.3.tgz", + "integrity": "sha512-KHLgC3WKlUYW3ShFKnnosZDOJ0xjg9zp7au3sIm2bs/tGBeC2ipmvRh/N7JKi0t9Ue20C0dpEshi8WUubg+cnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.3.tgz", + "integrity": "sha512-DV6fJoxEYWJOvaZIsok7KrYl0tPvga5OZ2yvKHNNYyk/2roMLqQAbGhr78EQ5YhHpnhLKJD3S1WFusAkmUuV5g==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.3.tgz", + "integrity": "sha512-mQKoJAzvuOs6F+TZybQO4GOTSMUu7v0WdxEk24krQ/uUxXoPTtHjuaUuPmFhtBcM4K0ons8nrE3JyhTuCFtT/w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.3.tgz", + "integrity": "sha512-Whjj2qoiJ6+OOJMGptTYazaJvjOJm+iKHpXQM1P3LzGjt7Ff++Tp7nH4N8J/BUA7R9IHfDyx4DJIflifwnbmIA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.3.tgz", + "integrity": "sha512-4YTNHKqGng5+yiZt3mg77nmyuCfmNfX4fPmyUapBcIk+BdwSwmCWGXOUxhXbBEkFHtoN5boLj/5NON+u5QC9tg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.3.tgz", + "integrity": "sha512-SU3kNlhkpI4UqlUc2VXPGK9o886ZsSeGfMAX2ba2b8DKmMXq4AL7KUrkSWVbb7koVqx41Yczx6dx5PNargIrEA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.3.tgz", + "integrity": "sha512-6lDLl5h4TXpB1mTf2rQWnAk/LcXrx9vBfu/DT5TIPhvMhRWaZ5MxkIc8u4lJAmBo6klTe1ywXIUHFjylW505sg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.3.tgz", + "integrity": "sha512-BMo8bOw8evlup/8G+cj5xWtPyp93xPdyoSN16Zy90Q2QZ0ZYRhCt6ZJSwbrRzG9HApFabjwj2p25TUPDWrhzqQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.3.tgz", + "integrity": "sha512-E0L8X1dZN1/Rph+5VPF6Xj2G7JJvMACVXtamTJIDrVI44Y3K+G8gQaMEAavbqCGTa16InptiVrX6eM6pmJ+7qA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.3.tgz", + "integrity": "sha512-oZJ/WHaVfHUiRAtmTAeo3DcevNsVvH8mbvodjZy7D5QKvCefO371SiKRpxoDcCxB3PTRTLayWBkvmDQKTcX/sw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.3.tgz", + "integrity": "sha512-Dhbyh7j9FybM3YaTgaHmVALwA8AkUwTPccyCQ79TG9AJUsMQqgN1DDEZNr4+QUfwiWvLDumW5vdwzoeUF+TNxQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.3.tgz", + "integrity": "sha512-cJd1X5XhHHlltkaypz1UcWLA8AcoIi1aWhsvaWDskD1oz2eKCypnqvTQ8ykMNI0RSmm7NkTdSqSSD7zM0xa6Ig==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.3.tgz", + "integrity": "sha512-DAZDBHQfG2oQuhY7mc6I3/qB4LU2fQCjRvxbDwd/Jdvb9fypP4IJ4qmtu6lNjes6B531AI8cg1aKC2di97bUxA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.3.tgz", + "integrity": "sha512-cRxsE8c13mZOh3vP+wLDxpQBRrOHDIGOWyDL93Sy0Ga8y515fBcC2pjUfFwUe5T7tqvTvWbCpg1URM/AXdWIXA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.3.tgz", + "integrity": "sha512-QaWcIgRxqEdQdhJqW4DJctsH6HCmo5vHxY0krHSX4jMtOqfzC+dqDGuHM87bu4H8JBeibWx7jFz+h6/4C8wA5Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.3.tgz", + "integrity": "sha512-AaXwSvUi3QIPtroAUw1t5yHGIyqKEXwH54WUocFolZhpGDruJcs8c+xPNDRn4XiQsS7MEwnYsHW2l0MBLDMkWg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.3.tgz", + "integrity": "sha512-65LAKM/bAWDqKNEelHlcHvm2V+Vfb8C6INFxQXRHCvaVN1rJfwr4NvdP4FyzUaLqWfaCGaadf6UbTm8xJeYfEg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.3.tgz", + "integrity": "sha512-EEM2gyhBF5MFnI6vMKdX1LAosE627RGBzIoGMdLloPZkXrUN0Ckqgr2Qi8+J3zip/8NVVro3/FjB+tjhZUgUHA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.3.tgz", + "integrity": "sha512-E5Eb5H/DpxaoXH++Qkv28RcUJboMopmdDUALBczvHMf7hNIxaDZqwY5lK12UK1BHacSmvupoEWGu+n993Z0y1A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.3.tgz", + "integrity": "sha512-hPt/bgL5cE+Qp+/TPHBqptcAgPzgj46mPcg/16zNUmbQk0j+mOEQV/+Lqu8QRtDV3Ek95Q6FeFITpuhl6OTsAA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@sveltejs/vite-plugin-svelte": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-3.1.2.tgz", + "integrity": "sha512-Txsm1tJvtiYeLUVRNqxZGKR/mI+CzuIQuc2gn+YCs9rMTowpNZ2Nqt53JdL8KF9bLhAf2ruR/dr9eZCwdTriRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sveltejs/vite-plugin-svelte-inspector": "^2.1.0", + "debug": "^4.3.4", + "deepmerge": "^4.3.1", + "kleur": "^4.1.5", + "magic-string": "^0.30.10", + "svelte-hmr": "^0.16.0", + "vitefu": "^0.2.5" + }, + "engines": { + "node": "^18.0.0 || >=20" + }, + "peerDependencies": { + "svelte": "^4.0.0 || ^5.0.0-next.0", + "vite": "^5.0.0" + } + }, + "node_modules/@sveltejs/vite-plugin-svelte-inspector": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte-inspector/-/vite-plugin-svelte-inspector-2.1.0.tgz", + "integrity": "sha512-9QX28IymvBlSCqsCll5t0kQVxipsfhFFL+L2t3nTWfXnddYwxBuAEtTtlaVQpRz9c37BhJjltSeY4AJSC03SSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.3.4" + }, + "engines": { + "node": "^18.0.0 || >=20" + }, + "peerDependencies": { + "@sveltejs/vite-plugin-svelte": "^3.0.0", + "svelte": "^4.0.0 || ^5.0.0-next.0", + "vite": "^5.0.0" + } + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/aria-query": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz", + "integrity": "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/axobject-query": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", + "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/code-red": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/code-red/-/code-red-1.0.4.tgz", + "integrity": "sha512-7qJWqItLA8/VPVlKJlFXU+NBlo/qyfs39aJcuMT/2ere32ZqvF5OSxgdM5xOfJJ7O429gg2HM47y8v9P+9wrNw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.4.15", + "@types/estree": "^1.0.1", + "acorn": "^8.10.0", + "estree-walker": "^3.0.3", + "periscopic": "^3.1.0" + } + }, + "node_modules/css-tree": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-2.3.1.tgz", + "integrity": "sha512-6Fv1DV/TYw//QF5IzQdqsNDjx/wc8TrMBZsqjL9eW01tWb7R7k/mq+/VXfJCl7SoD5emsJop9cOByJZfs8hYIw==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.0.30", + "source-map-js": "^1.0.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/is-reference": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz", + "integrity": "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.6" + } + }, + "node_modules/kleur": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", + "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/locate-character": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz", + "integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==", + "dev": true, + "license": "MIT" + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/mdn-data": { + "version": "2.0.30", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.30.tgz", + "integrity": "sha512-GaqWWShW4kv/G9IEucWScBx9G1/vsFZZJUO+tD26M8J8z3Kw5RDQjaoZe03YAClgeS/SWPOcb4nkFBTEi5DUEA==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/periscopic": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/periscopic/-/periscopic-3.1.0.tgz", + "integrity": "sha512-vKiQ8RRtkl9P+r/+oefh25C3fhybptkHKCZSPlcXiJux2tJF55GnEj3BVn4A5gKfq9NWWXXrxkHBwVPUfH0opw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "estree-walker": "^3.0.0", + "is-reference": "^3.0.0" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/postcss": { + "version": "8.5.14", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz", + "integrity": "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/rollup": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.3.tgz", + "integrity": "sha512-pAQK9HalE84QSm4Po3EmWIZPd3FnjkShVkiMlz1iligWYkWQ7wHYd1PF/T7QZ5TVSD6uSTon5gBVMSM4JfBV+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.60.3", + "@rollup/rollup-android-arm64": "4.60.3", + "@rollup/rollup-darwin-arm64": "4.60.3", + "@rollup/rollup-darwin-x64": "4.60.3", + "@rollup/rollup-freebsd-arm64": "4.60.3", + "@rollup/rollup-freebsd-x64": "4.60.3", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.3", + "@rollup/rollup-linux-arm-musleabihf": "4.60.3", + "@rollup/rollup-linux-arm64-gnu": "4.60.3", + "@rollup/rollup-linux-arm64-musl": "4.60.3", + "@rollup/rollup-linux-loong64-gnu": "4.60.3", + "@rollup/rollup-linux-loong64-musl": "4.60.3", + "@rollup/rollup-linux-ppc64-gnu": "4.60.3", + "@rollup/rollup-linux-ppc64-musl": "4.60.3", + "@rollup/rollup-linux-riscv64-gnu": "4.60.3", + "@rollup/rollup-linux-riscv64-musl": "4.60.3", + "@rollup/rollup-linux-s390x-gnu": "4.60.3", + "@rollup/rollup-linux-x64-gnu": "4.60.3", + "@rollup/rollup-linux-x64-musl": "4.60.3", + "@rollup/rollup-openbsd-x64": "4.60.3", + "@rollup/rollup-openharmony-arm64": "4.60.3", + "@rollup/rollup-win32-arm64-msvc": "4.60.3", + "@rollup/rollup-win32-ia32-msvc": "4.60.3", + "@rollup/rollup-win32-x64-gnu": "4.60.3", + "@rollup/rollup-win32-x64-msvc": "4.60.3", + "fsevents": "~2.3.2" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/svelte": { + "version": "4.2.20", + "resolved": "https://registry.npmjs.org/svelte/-/svelte-4.2.20.tgz", + "integrity": "sha512-eeEgGc2DtiUil5ANdtd8vPwt9AgaMdnuUFnPft9F5oMvU/FHu5IHFic+p1dR/UOB7XU2mX2yHW+NcTch4DCh5Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@ampproject/remapping": "^2.2.1", + "@jridgewell/sourcemap-codec": "^1.4.15", + "@jridgewell/trace-mapping": "^0.3.18", + "@types/estree": "^1.0.1", + "acorn": "^8.9.0", + "aria-query": "^5.3.0", + "axobject-query": "^4.0.0", + "code-red": "^1.0.3", + "css-tree": "^2.3.1", + "estree-walker": "^3.0.3", + "is-reference": "^3.0.1", + "locate-character": "^3.0.0", + "magic-string": "^0.30.4", + "periscopic": "^3.1.0" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/svelte-hmr": { + "version": "0.16.0", + "resolved": "https://registry.npmjs.org/svelte-hmr/-/svelte-hmr-0.16.0.tgz", + "integrity": "sha512-Gyc7cOS3VJzLlfj7wKS0ZnzDVdv3Pn2IuVeJPk9m2skfhcu5bq3wtIZyQGggr7/Iim5rH5cncyQft/kRLupcnA==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^12.20 || ^14.13.1 || >= 16" + }, + "peerDependencies": { + "svelte": "^3.19.0 || ^4.0.0" + } + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/vitefu": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/vitefu/-/vitefu-0.2.5.tgz", + "integrity": "sha512-SgHtMLoqaeeGnd2evZ849ZbACbnwQCIwRH57t18FxcXoZop0uQu0uzlIhJBlF/eWVzuce0sHeqPcDo+evVcg8Q==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "vite": "^3.0.0 || ^4.0.0 || ^5.0.0" + }, + "peerDependenciesMeta": { + "vite": { + "optional": true + } + } + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..db8c35f --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,16 @@ +{ + "name": "short-link-frontend", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "devDependencies": { + "@sveltejs/vite-plugin-svelte": "^3.1.0", + "svelte": "^4.2.18", + "vite": "^5.4.0" + } +} diff --git a/frontend/src/AdminPanel.svelte b/frontend/src/AdminPanel.svelte new file mode 100644 index 0000000..61ed925 --- /dev/null +++ b/frontend/src/AdminPanel.svelte @@ -0,0 +1,576 @@ + + +
+
+
+

管理后台

+ +
+ {#if authenticated} + + {/if} +
+ + {#if !authenticated} +
+

请输入管理 Token

+
+ e.key === 'Enter' && handleLogin()} + /> + +
+ {#if error} +
{error}
+ {/if} +
+ {:else} + {#if loading} +

加载中...

+ {:else} +
+ 共 {links.length} 条记录 +
+ + +
+
+ + {#if error} +
{error}
+ {/if} + + {#if links.length === 0} +
+

暂无数据

+
+ {:else} +
+ + + + + + + + + + + + {#each links as rec (rec.code)} + + + + + + + + {/each} + +
短码原始链接访问创建时间操作
+ {rec.code} + + {#if editing === rec.code} +
+ e.key === 'Enter' && saveEdit(rec.code)} + disabled={editSaving} + /> + + +
+ {:else} + {rec.original_url} + {/if} +
{rec.visit_count}{formatTime(rec.created_at)} + {#if editing === rec.code} + + {:else if deleteConfirm === rec.code} + 确认删除? + + + {:else} + + + {/if} +
+
+ {/if} + {/if} + {/if} +
+ + diff --git a/frontend/src/App.svelte b/frontend/src/App.svelte new file mode 100644 index 0000000..1d2a3cd --- /dev/null +++ b/frontend/src/App.svelte @@ -0,0 +1,301 @@ + + +{#if isAdmin} + +{:else} +
+
+

短链接

+

粘贴长链接,一键变短

+ 管理 +
+ +
+
+ + +
+ + {#if error} +
{error}
+ {/if} + + {#if result} +
+
+ + +
+
+ 原始链接:{result.original_url} +
+
+ {/if} +
+ +
+{/if} + + diff --git a/frontend/src/api.js b/frontend/src/api.js new file mode 100644 index 0000000..7c6c3ac --- /dev/null +++ b/frontend/src/api.js @@ -0,0 +1,72 @@ +const TOKEN_KEY = 'admin_token'; + +export function getToken() { + return sessionStorage.getItem(TOKEN_KEY) || ''; +} + +export function setToken(t) { + sessionStorage.setItem(TOKEN_KEY, t); +} + +export function clearToken() { + sessionStorage.removeItem(TOKEN_KEY); +} + +async function adminFetch(path, options = {}) { + const res = await fetch(path, { + ...options, + headers: { + ...options.headers, + 'Authorization': `Bearer ${getToken()}`, + }, + }); + if (res.status === 401) { + clearToken(); + throw new Error('认证失败,请检查 Token'); + } + return res; +} + +export async function fetchLinks() { + const res = await adminFetch('/api/admin/links'); + if (!res.ok) throw new Error('加载失败'); + return res.json(); +} + +export async function updateLink(code, url) { + const res = await adminFetch(`/api/admin/links/${code}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ url }), + }); + if (!res.ok) { + const body = await res.json().catch(() => ({})); + throw new Error(body.error || '更新失败'); + } + return res.json(); +} + +export async function deleteLink(code) { + const res = await adminFetch(`/api/admin/links/${code}`, { + method: 'DELETE', + }); + if (!res.ok) throw new Error('删除失败'); + return res.json(); +} + +export async function exportData(format = 'json') { + const res = await adminFetch(`/api/admin/export?format=${format}`); + if (!res.ok) throw new Error('导出失败'); + return res.blob(); +} + +export function downloadBlob(blob, filename) { + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = filename; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); +} diff --git a/frontend/src/main.js b/frontend/src/main.js new file mode 100644 index 0000000..aa7431f --- /dev/null +++ b/frontend/src/main.js @@ -0,0 +1,7 @@ +import App from './App.svelte'; + +const app = new App({ + target: document.getElementById('app') +}); + +export default app; diff --git a/frontend/vite.config.js b/frontend/vite.config.js new file mode 100644 index 0000000..25c7df0 --- /dev/null +++ b/frontend/vite.config.js @@ -0,0 +1,12 @@ +import { defineConfig } from 'vite'; +import { svelte } from '@sveltejs/vite-plugin-svelte'; + +export default defineConfig({ + plugins: [svelte()], + server: { + proxy: { + '/api': 'http://localhost:8080', + '^/[a-zA-Z0-9]{7}$': 'http://localhost:8080' + } + } +}); diff --git a/short-link.service b/short-link.service new file mode 100644 index 0000000..89e59db --- /dev/null +++ b/short-link.service @@ -0,0 +1,18 @@ +[Unit] +Description=Short Link Service +After=network.target + +[Service] +Type=simple +ExecStart=/opt/short-link/short-link-server +Environment=DB_PATH=/opt/short-link/data +Environment=DOMAIN=https://xieyaxin.top:8899 +Environment=ADDR=:8080 +Environment=ADMIN_TOKEN=asdaersfdgfdhtretewfsddfgfdgdsfs +Restart=always +RestartSec=5 +User=nobody +Group=nogroup + +[Install] +WantedBy=multi-user.target