From c8cfe683619fdc426c1a495caf4c9ad1cf0530d0 Mon Sep 17 00:00:00 2001 From: lwt <215586800@qq.com> Date: Mon, 8 Jun 2026 20:53:14 +0800 Subject: [PATCH] feat: add software download center --- .gitignore | 8 + admin/software.html | 137 ++++ assets/css/components.css | 12 + assets/css/software.css | 1012 ++++++++++++++++++++++++++++++ assets/icons/software.svg | 19 + assets/js/login.js | 61 ++ assets/js/software-admin.js | 214 +++++++ assets/js/software.js | 321 ++++++++++ index.html | 19 + login.html | 51 ++ package-lock.json | 1007 +++++++++++++++++++++++++++++ package.json | 15 + server/data/players.json | 12 + server/data/software.json | 85 +++ server/server.js | 617 ++++++++++++++++++ server/uploads/icons/.gitkeep | 1 + server/uploads/packages/.gitkeep | 1 + software.html | 146 +++++ 18 files changed, 3738 insertions(+) create mode 100644 .gitignore create mode 100644 admin/software.html create mode 100644 assets/css/software.css create mode 100644 assets/icons/software.svg create mode 100644 assets/js/login.js create mode 100644 assets/js/software-admin.js create mode 100644 assets/js/software.js create mode 100644 login.html create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 server/data/players.json create mode 100644 server/data/software.json create mode 100644 server/server.js create mode 100644 server/uploads/icons/.gitkeep create mode 100644 server/uploads/packages/.gitkeep create mode 100644 software.html diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..78b8f96 --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +node_modules/ +server/uploads/packages/* +server/uploads/icons/* +!server/uploads/packages/.gitkeep +!server/uploads/icons/.gitkeep +.env +npm-debug.log* +server-test.*.log diff --git a/admin/software.html b/admin/software.html new file mode 100644 index 0000000..ff821cd --- /dev/null +++ b/admin/software.html @@ -0,0 +1,137 @@ + + + + + + + + sumi.work - 软件管理 + + + +
+
+ + + 软件管理 + + +
+
+ +
+
+

管理令牌

+

令牌通过请求头发送到服务端,用于新增、上传和更新软件。

+
+ + +
+

+
+ +
+
+

新增软件

+
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+

更新软件时只选择新的安装包即可替换下载文件;不选择文件会保留原安装包。

+
+ + +
+

+
+
+ +
+
+
+

软件列表

+

正在加载...

+
+ +
+
+
+
+
+ + + + diff --git a/assets/css/components.css b/assets/css/components.css index a8360b5..075978f 100644 --- a/assets/css/components.css +++ b/assets/css/components.css @@ -123,6 +123,12 @@ border: 1px solid rgba(35, 136, 255, 0.24); } +.quick-link.software { + color: #2049ee; + background: rgba(32, 73, 238, 0.08); + border: 1px solid rgba(32, 73, 238, 0.22); +} + .eyebrow { margin: 0 0 12px; color: var(--blue); @@ -656,6 +662,12 @@ background: linear-gradient(145deg, #f7fbff, #eef7ff); } +.card-software { + color: #2049ee; + border-color: rgba(32, 73, 238, 0.28); + background: linear-gradient(145deg, #f7fbff, #edf5ff); +} + @media (max-width: 1180px) { .brand::after { display: none; diff --git a/assets/css/software.css b/assets/css/software.css new file mode 100644 index 0000000..730f70f --- /dev/null +++ b/assets/css/software.css @@ -0,0 +1,1012 @@ +:root { + color-scheme: light; + --bg: #f3f6fb; + --panel: #ffffff; + --panel-soft: #f8fbff; + --text: #17171a; + --muted: #747b88; + --line: rgba(0, 0, 0, 0.08); + --blue: #2049ee; + --blue-dark: #1232da; + --blue-soft: #edf5ff; + --green: #00ab59; + --orange: #fa9014; + --red: #e54747; + --shadow: 0 16px 40px rgba(31, 53, 108, 0.08); + font-family: + Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", + "PingFang SC", "Microsoft YaHei", sans-serif; +} + +* { + box-sizing: border-box; +} + +html { + scroll-behavior: smooth; +} + +body { + min-width: 320px; + margin: 0; + color: var(--text); + background: var(--bg); +} + +a { + color: inherit; + text-decoration: none; +} + +button, +input, +select, +textarea { + font: inherit; +} + +button { + cursor: pointer; +} + +h1, +h2, +p, +dl, +dd { + margin: 0; +} + +.sr-only { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; +} + +.soft-topbar { + position: sticky; + top: 0; + z-index: 20; + background: rgba(255, 255, 255, 0.96); + border-bottom: 1px solid var(--line); + backdrop-filter: blur(16px); +} + +.soft-topbar-inner { + display: grid; + width: min(1200px, calc(100% - 32px)); + min-height: 88px; + grid-template-columns: auto 1fr minmax(220px, 320px) auto; + gap: 22px; + align-items: center; + margin-inline: auto; +} + +.login-topbar-inner { + grid-template-columns: auto 1fr; +} + +.soft-brand { + display: inline-flex; + align-items: center; + gap: 12px; + font-size: 20px; + font-weight: 800; +} + +.soft-brand img { + width: 40px; + height: 40px; +} + +.soft-nav { + display: flex; + align-items: center; + gap: 44px; +} + +.soft-nav a { + position: relative; + display: inline-flex; + min-height: 88px; + align-items: center; + color: #25272d; + font-size: 18px; + font-weight: 500; +} + +.soft-nav a:hover, +.soft-nav a:focus-visible, +.soft-nav a.is-active { + color: var(--blue); +} + +.soft-nav a.is-active::after { + position: absolute; + right: 0; + bottom: 0; + left: 0; + height: 3px; + background: var(--blue); + content: ""; +} + +.soft-search { + display: flex; + height: 42px; + align-items: center; + gap: 10px; + padding: 0 14px; + background: #f5f7fb; + border: 1px solid rgba(0, 0, 0, 0.08); + border-radius: 21px; +} + +.soft-search svg { + width: 18px; + height: 18px; + fill: none; + stroke: #8a92a1; + stroke-linecap: round; + stroke-linejoin: round; + stroke-width: 2; +} + +.soft-search input { + width: 100%; + min-width: 0; + color: var(--text); + background: transparent; + border: 0; + outline: 0; +} + +.player-status { + display: flex; + min-height: 42px; + align-items: center; + justify-content: flex-end; + gap: 10px; +} + +.player-login-link, +.player-logout { + display: inline-flex; + min-height: 36px; + align-items: center; + justify-content: center; + padding: 0 14px; + color: var(--blue); + background: rgba(32, 73, 238, 0.08); + border: 1px solid rgba(32, 73, 238, 0.18); + border-radius: 18px; + font-weight: 700; +} + +.player-logout { + color: #697182; + background: #f5f7fb; +} + +.player-name { + max-width: 130px; + overflow: hidden; + color: #25272d; + font-size: 14px; + font-weight: 700; + text-overflow: ellipsis; + white-space: nowrap; +} + +main, +.soft-footer { + width: min(1200px, calc(100% - 32px)); + margin-inline: auto; +} + +.download-hero { + position: relative; + min-height: 330px; + margin-top: 24px; + overflow: hidden; + background: + linear-gradient(90deg, rgba(237, 245, 255, 0.98) 0%, rgba(237, 245, 255, 0.82) 54%, rgba(255, 255, 255, 0.3) 100%), + linear-gradient(135deg, #dff0ff 0%, #f9fcff 52%, #fff6e8 100%); + border-radius: 8px; +} + +.download-hero::before { + position: absolute; + right: 42px; + bottom: -22px; + width: min(430px, 42vw); + height: 270px; + background: + linear-gradient(145deg, rgba(255, 255, 255, 0.94), rgba(255, 255, 255, 0.72)), + repeating-linear-gradient(90deg, transparent 0 36px, rgba(32, 73, 238, 0.08) 36px 37px); + border: 1px solid rgba(32, 73, 238, 0.1); + border-radius: 8px 8px 0 0; + box-shadow: var(--shadow); + content: ""; +} + +.download-hero::after { + position: absolute; + right: 86px; + bottom: 44px; + width: min(344px, 34vw); + height: 172px; + background: + linear-gradient(90deg, rgba(32, 73, 238, 0.12) 0 72px, transparent 72px 86px, rgba(0, 171, 89, 0.14) 86px 158px, transparent 158px 172px, rgba(250, 144, 20, 0.14) 172px 244px, transparent 244px 258px, rgba(229, 71, 71, 0.12) 258px 100%), + linear-gradient(180deg, transparent 0 76px, rgba(0, 0, 0, 0.08) 76px 77px, transparent 77px 100%); + border-radius: 8px; + content: ""; +} + +.hero-content { + position: relative; + z-index: 1; + width: min(740px, 100%); + padding: 52px 58px; +} + +.hero-kicker { + margin-bottom: 10px; + color: var(--blue); + font-size: 13px; + font-weight: 800; + letter-spacing: 0.12em; + text-transform: uppercase; +} + +.download-hero h1 { + margin-bottom: 14px; + color: #0f172a; + font-size: clamp(36px, 4.6vw, 50px); + line-height: 1.12; + letter-spacing: 0; +} + +.download-hero h1 span { + display: inline-block; +} + +.hero-copy { + max-width: 590px; + margin-bottom: 28px; + color: #3b4656; + font-size: 18px; + line-height: 1.7; +} + +.hero-actions { + display: flex; + flex-wrap: wrap; + gap: 14px; + margin-bottom: 30px; +} + +.primary-action, +.secondary-action, +.text-button, +.download-button, +.edit-button, +.danger-button { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 8px; + border: 0; + border-radius: 4px; + transition: + background 160ms ease, + color 160ms ease, + border-color 160ms ease, + transform 160ms ease; +} + +.primary-action, +.secondary-action { + min-height: 46px; + padding: 0 22px; + font-weight: 700; +} + +.primary-action { + color: #fff; + background: var(--blue); +} + +.primary-action:hover, +.primary-action:focus-visible { + background: var(--blue-dark); +} + +.secondary-action { + color: var(--blue); + background: #fff; + border: 1px solid rgba(32, 73, 238, 0.18); +} + +.primary-action svg, +.secondary-action svg, +.text-button svg, +.download-button svg, +.edit-button svg, +.danger-button svg { + width: 17px; + height: 17px; + fill: none; + stroke: currentColor; + stroke-linecap: round; + stroke-linejoin: round; + stroke-width: 2; +} + +.hero-stats { + display: flex; + flex-wrap: wrap; + gap: 18px; +} + +.hero-stats div { + min-width: 110px; + padding: 12px 14px; + background: rgba(255, 255, 255, 0.72); + border: 1px solid rgba(255, 255, 255, 0.8); + border-radius: 8px; +} + +.hero-stats dt { + color: #111827; + font-size: 24px; + font-weight: 800; +} + +.hero-stats dd { + margin-top: 2px; + color: var(--muted); + font-size: 13px; +} + +.category-strip { + display: flex; + flex-wrap: wrap; + gap: 12px; + margin-top: 20px; + padding: 18px 22px; + background: #fff; + border-radius: 8px; + box-shadow: var(--shadow); +} + +.category-button { + min-height: 36px; + padding: 0 18px; + color: #25272d; + background: transparent; + border: 1px solid transparent; + border-radius: 18px; +} + +.category-button:hover, +.category-button:focus-visible, +.category-button.is-active { + color: var(--blue); + background: rgba(32, 73, 238, 0.08); + border-color: rgba(32, 73, 238, 0.18); +} + +.software-section, +.all-section { + margin-top: 20px; + padding: 30px; + background: #fff; + border-radius: 8px; +} + +.section-title-row { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 18px; + margin-bottom: 22px; +} + +.section-title-row h2, +.topic-heading h2 { + color: #111827; + font-size: 22px; + line-height: 1.2; + letter-spacing: 0; +} + +.section-title-row p { + margin-top: 8px; + color: var(--muted); + font-size: 14px; + line-height: 1.6; +} + +.text-button { + min-height: 34px; + flex: 0 0 auto; + padding: 0 12px; + color: var(--blue); + background: #f4f7ff; +} + +.software-grid { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 12px; +} + +.software-card { + position: relative; + display: grid; + min-height: 112px; + grid-template-columns: 60px minmax(0, 1fr); + gap: 12px; + align-items: center; + padding: 14px; + background: #fff; + border: 1px solid var(--line); + border-radius: 8px; +} + +.software-card:hover, +.software-row:hover { + background: #eaf4ff; +} + +.app-icon { + display: grid; + width: 60px; + height: 60px; + place-items: center; + overflow: hidden; + color: #fff; + background: linear-gradient(135deg, #5db2ff, #2049ee); + border-radius: 8px; + font-size: 24px; + font-weight: 800; +} + +.app-icon img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.app-name-line { + display: flex; + min-width: 0; + align-items: center; + gap: 6px; + margin-bottom: 6px; +} + +.app-name { + overflow: hidden; + color: #181818; + font-size: 16px; + font-weight: 700; + line-height: 1.25; + text-overflow: ellipsis; + white-space: nowrap; +} + +.version-pill, +.category-pill { + display: inline-flex; + min-height: 18px; + align-items: center; + padding: 0 5px; + color: var(--green); + background: rgba(0, 171, 89, 0.1); + border-radius: 2px; + font-size: 11px; + white-space: nowrap; +} + +.category-pill { + color: var(--blue); + background: rgba(32, 73, 238, 0.08); +} + +.app-desc { + overflow: hidden; + color: #777f8f; + font-size: 13px; + line-height: 1.45; + text-overflow: ellipsis; + white-space: nowrap; +} + +.app-meta { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-top: 8px; + color: #9299a6; + font-size: 12px; +} + +.download-button { + min-height: 28px; + margin-top: 12px; + padding: 0 12px; + color: #fff; + background: var(--blue); + font-size: 13px; +} + +.download-button:hover, +.download-button:focus-visible { + background: var(--blue-dark); +} + +.download-button.is-disabled { + color: #8d95a3; + background: #eef1f6; + pointer-events: none; +} + +.download-button.login-required { + color: var(--blue); + background: rgba(32, 73, 238, 0.08); +} + +.download-button.login-required:hover, +.download-button.login-required:focus-visible { + color: #fff; + background: var(--blue); +} + +.login-main { + display: grid; + min-height: calc(100svh - 88px); + place-items: center; + padding: 44px 0; +} + +.login-panel { + display: grid; + width: min(840px, 100%); + grid-template-columns: minmax(0, 1fr) minmax(300px, 360px); + gap: 34px; + align-items: center; + padding: 42px; + background: + linear-gradient(135deg, rgba(237, 245, 255, 0.92), rgba(255, 255, 255, 0.88)), + #fff; + border: 1px solid rgba(32, 73, 238, 0.12); + border-radius: 8px; + box-shadow: var(--shadow); +} + +.login-panel h1 { + margin-bottom: 14px; + color: #0f172a; + font-size: clamp(34px, 5vw, 52px); + line-height: 1.08; + letter-spacing: 0; +} + +.login-copy { + color: #3b4656; + font-size: 17px; + line-height: 1.7; +} + +.login-form { + display: grid; + gap: 16px; + padding: 22px; + background: rgba(255, 255, 255, 0.78); + border: 1px solid rgba(0, 0, 0, 0.08); + border-radius: 8px; +} + +.topic-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 12px; + margin-top: 20px; +} + +.topic-panel { + min-height: 418px; + padding: 20px; + background: #fff; + border: 1px solid var(--line); + border-radius: 8px; +} + +.topic-orange { + background: linear-gradient(205deg, rgba(250, 144, 20, 0.1) 0%, rgba(255, 255, 255, 0.5) 44%), #fff; + border-color: rgba(250, 144, 20, 0.25); +} + +.topic-blue { + background: linear-gradient(205deg, rgba(32, 73, 238, 0.11) 0%, rgba(255, 255, 255, 0.5) 44%), #fff; + border-color: rgba(32, 73, 238, 0.24); +} + +.topic-green { + background: linear-gradient(205deg, rgba(0, 171, 89, 0.12) 0%, rgba(255, 255, 255, 0.5) 44%), #fff; + border-color: rgba(0, 171, 89, 0.24); +} + +.topic-heading { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-bottom: 14px; +} + +.topic-heading span { + display: inline-flex; + min-height: 22px; + align-items: center; + padding: 0 7px; + border-radius: 2px; + font-size: 12px; +} + +.topic-orange .topic-heading span { + color: var(--orange); + background: rgba(250, 144, 20, 0.1); +} + +.topic-blue .topic-heading span { + color: var(--blue); + background: rgba(32, 73, 238, 0.1); +} + +.topic-green .topic-heading span { + color: var(--green); + background: rgba(0, 171, 89, 0.1); +} + +.topic-list { + display: grid; + gap: 8px; +} + +.topic-item { + display: grid; + grid-template-columns: 52px minmax(0, 1fr) auto; + gap: 10px; + align-items: center; + min-height: 76px; + padding: 8px 10px; + border-radius: 8px; +} + +.topic-item:hover { + background: rgba(255, 255, 255, 0.74); +} + +.topic-item .app-icon { + width: 52px; + height: 52px; + font-size: 20px; +} + +.topic-item .download-button { + margin-top: 0; +} + +.all-list { + display: grid; + gap: 10px; +} + +.software-row { + display: grid; + grid-template-columns: 60px minmax(0, 1fr) auto; + gap: 14px; + align-items: center; + min-height: 92px; + padding: 14px 16px; + border: 1px solid var(--line); + border-radius: 8px; +} + +.row-actions { + display: flex; + align-items: center; + justify-content: flex-end; +} + +.empty-state { + padding: 28px; + color: var(--muted); + text-align: center; + background: var(--panel-soft); + border-radius: 8px; +} + +.soft-footer { + display: flex; + justify-content: center; + gap: 18px; + padding: 28px 0 34px; + color: #7b8290; + font-size: 14px; +} + +.admin-shell { + width: min(1200px, calc(100% - 32px)); + margin: 24px auto 40px; +} + +.admin-layout { + display: grid; + grid-template-columns: minmax(320px, 420px) minmax(0, 1fr); + gap: 18px; +} + +.admin-panel { + padding: 24px; + background: #fff; + border: 1px solid var(--line); + border-radius: 8px; + box-shadow: var(--shadow); +} + +.admin-panel h1, +.admin-panel h2 { + margin: 0 0 18px; + font-size: 22px; + line-height: 1.25; +} + +.admin-note, +.form-hint, +.status-message { + color: var(--muted); + font-size: 13px; + line-height: 1.6; +} + +.token-row { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 10px; + margin-top: 16px; +} + +.field-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 14px; +} + +.field { + display: grid; + gap: 7px; +} + +.field.full { + grid-column: 1 / -1; +} + +.field label, +.checkbox-field { + color: #2b303b; + font-size: 13px; + font-weight: 700; +} + +.field input, +.field select, +.field textarea, +.token-row input { + width: 100%; + min-height: 40px; + padding: 8px 10px; + color: var(--text); + background: #fbfcff; + border: 1px solid rgba(0, 0, 0, 0.1); + border-radius: 6px; + outline: 0; +} + +.field textarea { + min-height: 92px; + resize: vertical; +} + +.field input:focus, +.field select:focus, +.field textarea:focus, +.token-row input:focus { + border-color: rgba(32, 73, 238, 0.45); + box-shadow: 0 0 0 3px rgba(32, 73, 238, 0.08); +} + +.checkbox-row { + display: flex; + flex-wrap: wrap; + gap: 18px; + margin: 14px 0; +} + +.checkbox-field { + display: inline-flex; + align-items: center; + gap: 8px; +} + +.form-actions { + display: flex; + flex-wrap: wrap; + gap: 10px; + margin-top: 18px; +} + +.edit-button, +.danger-button { + min-height: 34px; + padding: 0 12px; + color: var(--blue); + background: #f4f7ff; +} + +.danger-button { + color: var(--red); + background: rgba(229, 71, 71, 0.08); +} + +.admin-list { + display: grid; + gap: 10px; +} + +.admin-item { + display: grid; + grid-template-columns: 52px minmax(0, 1fr) auto; + gap: 12px; + align-items: center; + padding: 12px; + border: 1px solid var(--line); + border-radius: 8px; +} + +.admin-item .app-icon { + width: 52px; + height: 52px; + font-size: 20px; +} + +.admin-item-actions { + display: flex; + flex-wrap: wrap; + gap: 8px; + justify-content: flex-end; +} + +.status-message { + min-height: 21px; + margin-top: 12px; +} + +.status-message.is-error { + color: var(--red); +} + +.status-message.is-ok { + color: var(--green); +} + +@media (max-width: 980px) { + .soft-topbar-inner { + grid-template-columns: 1fr; + gap: 12px; + padding: 16px 0; + } + + .soft-nav, + .soft-topbar-inner { + justify-items: start; + } + + .soft-nav { + flex-wrap: wrap; + gap: 18px; + } + + .player-status { + justify-content: flex-start; + } + + .soft-nav a { + min-height: 34px; + } + + .soft-nav a.is-active::after { + display: none; + } + + .software-grid, + .topic-grid, + .admin-layout, + .login-panel { + grid-template-columns: 1fr; + } + + .download-hero::before, + .download-hero::after { + opacity: 0.28; + } +} + +@media (max-width: 640px) { + main, + .soft-footer, + .admin-shell, + .soft-topbar-inner { + width: min(100% - 24px, 1200px); + } + + .hero-content, + .software-section, + .all-section, + .admin-panel, + .login-panel { + padding: 22px; + } + + .download-hero h1 { + font-size: clamp(32px, 10.5vw, 42px); + } + + .hero-copy { + font-size: 16px; + } + + .category-strip { + padding: 14px; + gap: 8px; + } + + .category-button { + min-height: 34px; + padding: 0 14px; + } + + .software-row, + .admin-item { + grid-template-columns: 52px minmax(0, 1fr); + } + + .row-actions, + .admin-item-actions { + grid-column: 1 / -1; + justify-content: stretch; + } + + .row-actions .download-button, + .admin-item-actions button { + width: 100%; + } + + .field-grid, + .token-row { + grid-template-columns: 1fr; + } + + .soft-footer { + flex-direction: column; + align-items: center; + gap: 6px; + text-align: center; + } +} diff --git a/assets/icons/software.svg b/assets/icons/software.svg new file mode 100644 index 0000000..5f0df0f --- /dev/null +++ b/assets/icons/software.svg @@ -0,0 +1,19 @@ + + Software download + + + + + + + + + + + + + + + + + diff --git a/assets/js/login.js b/assets/js/login.js new file mode 100644 index 0000000..49e0428 --- /dev/null +++ b/assets/js/login.js @@ -0,0 +1,61 @@ +const form = document.querySelector("#login-form"); +const nextInput = document.querySelector("#next-url"); +const statusEl = document.querySelector("#login-status"); + +const params = new URLSearchParams(window.location.search); +const requestedNext = params.get("next") || "/software.html"; + +function safeNext(value) { + if (!value) return "/software.html"; + if (value.startsWith("/") && !value.startsWith("//")) return value; + if (/^[a-z0-9._/-]+$/i.test(value)) return value; + return "/software.html"; +} + +function setStatus(message, type = "") { + statusEl.textContent = message; + statusEl.classList.toggle("is-error", type === "error"); + statusEl.classList.toggle("is-ok", type === "ok"); +} + +async function checkExistingLogin() { + try { + const response = await fetch("/api/auth/me", { headers: { Accept: "application/json" } }); + const payload = await response.json(); + if (payload.authenticated) { + window.location.href = safeNext(requestedNext); + } + } catch { + setStatus(""); + } +} + +nextInput.value = safeNext(requestedNext); + +form.addEventListener("submit", async (event) => { + event.preventDefault(); + const formData = new FormData(form); + const payload = Object.fromEntries(formData.entries()); + + try { + setStatus("正在登录..."); + const response = await fetch("/api/auth/login", { + method: "POST", + headers: { + Accept: "application/json", + "Content-Type": "application/json", + }, + body: JSON.stringify(payload), + }); + const data = await response.json(); + if (!response.ok) { + throw new Error(data.error || "登录失败"); + } + setStatus("登录成功,正在跳转。", "ok"); + window.location.href = safeNext(data.next || payload.next); + } catch (error) { + setStatus(error.message, "error"); + } +}); + +checkExistingLogin(); diff --git a/assets/js/software-admin.js b/assets/js/software-admin.js new file mode 100644 index 0000000..e1365ba --- /dev/null +++ b/assets/js/software-admin.js @@ -0,0 +1,214 @@ +const CATEGORY_NAMES = { + office: "办公效率", + dev: "开发工具", + media: "影音娱乐", + security: "安全系统", + network: "网络工具", + tools: "系统工具", +}; + +const els = { + token: document.querySelector("#admin-token"), + saveToken: document.querySelector("#save-token"), + tokenStatus: document.querySelector("#token-status"), + form: document.querySelector("#software-form"), + formTitle: document.querySelector("#form-title"), + formStatus: document.querySelector("#form-status"), + submitButton: document.querySelector("#submit-button"), + resetForm: document.querySelector("#reset-form"), + reload: document.querySelector("#reload-admin"), + list: document.querySelector("#admin-list"), + summary: document.querySelector("#admin-summary"), +}; + +let softwareItems = []; + +function escapeHtml(value = "") { + return String(value) + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + +function getToken() { + return localStorage.getItem("sumiSoftwareAdminToken") || ""; +} + +function setStatus(element, message, type = "") { + element.textContent = message; + element.classList.toggle("is-error", type === "error"); + element.classList.toggle("is-ok", type === "ok"); +} + +function getInitial(name = "") { + return Array.from(name.trim())[0]?.toUpperCase() || "S"; +} + +function iconMarkup(item) { + if (item.iconUrl) { + return `${escapeHtml(item.name)}`; + } + return ``; +} + +function normalizeItems(payload) { + const items = Array.isArray(payload) ? payload : payload?.items; + return (items || []).map((item) => ({ + ...item, + tags: Array.isArray(item.tags) ? item.tags : [], + })); +} + +function formatDate(value) { + if (!value) return "--"; + const date = new Date(value); + if (Number.isNaN(date.getTime())) return "--"; + return date.toLocaleString("zh-CN", { hour12: false }); +} + +function renderList() { + els.summary.textContent = `共 ${softwareItems.length} 款软件`; + els.list.innerHTML = softwareItems.map((item) => `
+ ${iconMarkup(item)} +
+
+

${escapeHtml(item.name)}

+ v${escapeHtml(item.version || "-")} + ${escapeHtml(CATEGORY_NAMES[item.category] || "其他")} +
+

${escapeHtml(item.description || "暂无简介")}

+
+ ${item.active === false ? "已隐藏" : "前台显示"} + ${item.featured ? "精选" : "普通"} + ${escapeHtml(item.size || "未知大小")} + ${formatDate(item.updatedAt)} +
+
+
+ + +
+
`).join(""); +} + +async function apiFetch(url, options = {}) { + const token = getToken(); + const headers = { + ...(options.headers || {}), + "X-Admin-Token": token, + }; + const response = await fetch(url, { ...options, headers }); + if (!response.ok) { + const message = await response.text(); + throw new Error(message || `HTTP ${response.status}`); + } + return response.headers.get("content-type")?.includes("application/json") ? response.json() : response.text(); +} + +async function loadSoftware() { + try { + setStatus(els.tokenStatus, "正在读取软件列表..."); + const payload = await apiFetch("/api/admin/software"); + softwareItems = normalizeItems(payload).sort((a, b) => new Date(b.updatedAt || 0) - new Date(a.updatedAt || 0)); + renderList(); + setStatus(els.tokenStatus, "令牌可用。", "ok"); + } catch (error) { + softwareItems = []; + renderList(); + setStatus(els.tokenStatus, `读取失败:${error.message}`, "error"); + } +} + +function resetForm() { + els.form.reset(); + els.form.elements.id.value = ""; + els.form.elements.platform.value = "Windows"; + els.form.elements.publisher.value = "sumi.work"; + els.form.elements.sort.value = "100"; + els.form.elements.active.checked = true; + els.formTitle.textContent = "新增软件"; + els.submitButton.textContent = "保存软件"; + setStatus(els.formStatus, ""); +} + +function fillForm(item) { + els.form.elements.id.value = item.id; + els.form.elements.name.value = item.name || ""; + els.form.elements.version.value = item.version || ""; + els.form.elements.category.value = item.category || "tools"; + els.form.elements.platform.value = item.platform || "Windows"; + els.form.elements.publisher.value = item.publisher || "sumi.work"; + els.form.elements.homepage.value = item.homepage || ""; + els.form.elements.description.value = item.description || ""; + els.form.elements.changelog.value = item.changelog || ""; + els.form.elements.tags.value = (item.tags || []).join(", "); + els.form.elements.sort.value = item.sort ?? 100; + els.form.elements.downloadUrl.value = item.externalDownloadUrl || ""; + els.form.elements.active.checked = item.active !== false; + els.form.elements.featured.checked = Boolean(item.featured); + els.form.elements.icon.value = ""; + els.form.elements.package.value = ""; + els.formTitle.textContent = `更新 ${item.name}`; + els.submitButton.textContent = "更新软件"; + setStatus(els.formStatus, "已载入当前软件,选择新安装包即可发布新版本。", "ok"); + window.scrollTo({ top: 0, behavior: "smooth" }); +} + +async function saveSoftware(event) { + event.preventDefault(); + const formData = new FormData(els.form); + const id = formData.get("id"); + formData.delete("id"); + + try { + setStatus(els.formStatus, "正在保存..."); + const url = id ? `/api/admin/software/${encodeURIComponent(id)}` : "/api/admin/software"; + const method = id ? "PUT" : "POST"; + await apiFetch(url, { method, body: formData }); + setStatus(els.formStatus, id ? "软件已更新。" : "软件已新增。", "ok"); + resetForm(); + await loadSoftware(); + } catch (error) { + setStatus(els.formStatus, `保存失败:${error.message}`, "error"); + } +} + +async function deleteSoftware(id) { + const item = softwareItems.find((entry) => entry.id === id); + if (!item || !window.confirm(`确定删除 ${item.name} 吗?`)) return; + + try { + await apiFetch(`/api/admin/software/${encodeURIComponent(id)}`, { method: "DELETE" }); + setStatus(els.tokenStatus, "软件已删除。", "ok"); + await loadSoftware(); + } catch (error) { + setStatus(els.tokenStatus, `删除失败:${error.message}`, "error"); + } +} + +els.token.value = getToken(); +els.saveToken.addEventListener("click", () => { + localStorage.setItem("sumiSoftwareAdminToken", els.token.value.trim()); + setStatus(els.tokenStatus, "令牌已保存。", "ok"); + loadSoftware(); +}); + +els.reload.addEventListener("click", loadSoftware); +els.resetForm.addEventListener("click", resetForm); +els.form.addEventListener("submit", saveSoftware); +els.list.addEventListener("click", (event) => { + const button = event.target.closest("button[data-action]"); + if (!button) return; + const item = softwareItems.find((entry) => entry.id === button.dataset.id); + if (button.dataset.action === "edit" && item) fillForm(item); + if (button.dataset.action === "delete") deleteSoftware(button.dataset.id); +}); + +if (getToken()) { + loadSoftware(); +} else { + renderList(); + setStatus(els.tokenStatus, "请输入服务端 ADMIN_TOKEN 后读取列表。"); +} diff --git a/assets/js/software.js b/assets/js/software.js new file mode 100644 index 0000000..3b3efbc --- /dev/null +++ b/assets/js/software.js @@ -0,0 +1,321 @@ +const CATEGORY_NAMES = { + office: "办公效率", + dev: "开发工具", + media: "影音娱乐", + security: "安全系统", + network: "网络工具", + tools: "系统工具", +}; + +const FALLBACK_SOFTWARE = [ + { + id: "sumi-launcher", + name: "Sumi Launcher", + version: "1.0.0", + category: "office", + description: "常用工具快速启动入口", + publisher: "sumi.work", + platform: "Windows", + size: "待上传", + updatedAt: "2026-06-08T00:00:00.000Z", + iconUrl: "assets/icons/software.svg", + downloadUrl: "", + tags: ["推荐"], + featured: true, + }, + { + id: "git-client", + name: "Git Client", + version: "2.0.0", + category: "dev", + description: "代码仓库和版本管理工具", + publisher: "sumi.work", + platform: "Windows", + size: "待上传", + updatedAt: "2026-06-08T00:00:00.000Z", + iconUrl: "assets/icons/git.svg", + downloadUrl: "", + tags: ["开发"], + featured: true, + }, + { + id: "sync-tool", + name: "Sync Tool", + version: "1.2.0", + category: "network", + description: "文件同步和分发工具", + publisher: "sumi.work", + platform: "Windows", + size: "待上传", + updatedAt: "2026-06-08T00:00:00.000Z", + iconUrl: "assets/icons/syncthing.svg", + downloadUrl: "", + tags: ["同步"], + featured: false, + }, +]; + +const state = { + items: [], + category: "all", + keyword: "", + player: null, + authLoaded: false, +}; + +const els = { + year: document.querySelector("#year"), + keyword: document.querySelector("#software-keyword"), + categoryButtons: [...document.querySelectorAll(".category-button")], + featuredList: document.querySelector("#featured-list"), + allList: document.querySelector("#all-list"), + emptyState: document.querySelector("#empty-state"), + resultSummary: document.querySelector("#result-summary"), + refreshButton: document.querySelector("#refresh-button"), + statCount: document.querySelector("#stat-count"), + statFeatured: document.querySelector("#stat-featured"), + statUpdated: document.querySelector("#stat-updated"), + topicOffice: document.querySelector("#topic-office"), + topicDev: document.querySelector("#topic-dev"), + topicSecurity: document.querySelector("#topic-security"), + playerStatus: document.querySelector("#player-status"), +}; + +function escapeHtml(value = "") { + return String(value) + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + +function formatDate(value) { + if (!value) return "--"; + const date = new Date(value); + if (Number.isNaN(date.getTime())) return "--"; + return date.toLocaleDateString("zh-CN", { + year: "numeric", + month: "2-digit", + day: "2-digit", + }); +} + +function getCategoryName(category) { + return CATEGORY_NAMES[category] || "其他"; +} + +function getInitial(name = "") { + return Array.from(name.trim())[0]?.toUpperCase() || "S"; +} + +function getDownloadHref(item) { + if (!item.downloadUrl) return ""; + return `/api/software/${encodeURIComponent(item.id)}/download`; +} + +function iconMarkup(item) { + const safeName = escapeHtml(item.name); + if (item.iconUrl) { + return `${safeName}`; + } + return ``; +} + +function downloadButton(item) { + const href = getDownloadHref(item); + if (!href) { + return `待上传`; + } + if (!state.player) { + return ` + + 登录下载 + `; + } + return ` + + 下载 + `; +} + +function renderPlayerStatus() { + if (!els.playerStatus) return; + if (!state.authLoaded) { + els.playerStatus.innerHTML = `检查登录中`; + return; + } + if (!state.player) { + els.playerStatus.innerHTML = `玩家登录`; + return; + } + els.playerStatus.innerHTML = `${escapeHtml(state.player.displayName || state.player.username)} + `; +} + +function softwareCard(item) { + return `
+ ${iconMarkup(item)} +
+
+

${escapeHtml(item.name)}

+ v${escapeHtml(item.version || "-")} +
+

${escapeHtml(item.description || "暂无简介")}

+
+ ${escapeHtml(getCategoryName(item.category))} + ${escapeHtml(item.size || "未知大小")} + ${formatDate(item.updatedAt)} +
+ ${downloadButton(item)} +
+
`; +} + +function topicItem(item) { + return `
+ ${iconMarkup(item)} +
+
+

${escapeHtml(item.name)}

+
+

${escapeHtml(item.description || "暂无简介")}

+
+ ${downloadButton(item)} +
`; +} + +function softwareRow(item) { + const tags = (item.tags || []).slice(0, 2).map((tag) => `${escapeHtml(tag)}`).join(""); + return `
+ ${iconMarkup(item)} +
+
+

${escapeHtml(item.name)}

+ v${escapeHtml(item.version || "-")} + ${escapeHtml(getCategoryName(item.category))} + ${tags} +
+

${escapeHtml(item.description || "暂无简介")}

+
+ ${escapeHtml(item.publisher || "sumi.work")} + ${escapeHtml(item.platform || "Windows")} + ${escapeHtml(item.size || "未知大小")} + 更新 ${formatDate(item.updatedAt)} +
+
+
${downloadButton(item)}
+
`; +} + +function normalizeItems(payload) { + const items = Array.isArray(payload) ? payload : payload?.items; + return (items || []).map((item) => ({ + ...item, + tags: Array.isArray(item.tags) ? item.tags : [], + })); +} + +function sortItems(items) { + return [...items].sort((a, b) => { + if (Boolean(a.featured) !== Boolean(b.featured)) return a.featured ? -1 : 1; + return new Date(b.updatedAt || 0) - new Date(a.updatedAt || 0); + }); +} + +function getFilteredItems() { + const keyword = state.keyword.trim().toLowerCase(); + return sortItems(state.items).filter((item) => { + const matchesCategory = state.category === "all" || item.category === state.category; + const matchesKeyword = !keyword || [item.name, item.description, item.publisher, ...(item.tags || [])] + .join(" ") + .toLowerCase() + .includes(keyword); + return matchesCategory && matchesKeyword; + }); +} + +function renderStats() { + const featured = state.items.filter((item) => item.featured).length; + const latest = sortItems(state.items)[0]?.updatedAt; + els.statCount.textContent = String(state.items.length); + els.statFeatured.textContent = String(featured); + els.statUpdated.textContent = formatDate(latest); +} + +function renderTopics() { + const byCategory = (category) => sortItems(state.items).filter((item) => item.category === category).slice(0, 5); + els.topicOffice.innerHTML = byCategory("office").map(topicItem).join("") || `

暂无办公软件。

`; + els.topicDev.innerHTML = byCategory("dev").map(topicItem).join("") || `

暂无开发工具。

`; + els.topicSecurity.innerHTML = byCategory("security").map(topicItem).join("") || `

暂无安全工具。

`; +} + +function render() { + const filtered = getFilteredItems(); + const featured = filtered.filter((item) => item.featured).slice(0, 8); + + els.featuredList.innerHTML = (featured.length ? featured : filtered.slice(0, 8)).map(softwareCard).join(""); + els.allList.innerHTML = filtered.map(softwareRow).join(""); + els.emptyState.hidden = filtered.length > 0; + els.resultSummary.textContent = `共找到 ${filtered.length} 款软件`; + renderStats(); + renderTopics(); + renderPlayerStatus(); +} + +async function loadAuth() { + try { + const response = await fetch("/api/auth/me", { headers: { Accept: "application/json" } }); + const payload = await response.json(); + state.player = payload.authenticated ? payload.player : null; + } catch { + state.player = null; + } finally { + state.authLoaded = true; + render(); + } +} + +async function loadSoftware() { + try { + const response = await fetch("/api/software", { headers: { Accept: "application/json" } }); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + state.items = normalizeItems(await response.json()); + } catch (error) { + state.items = FALLBACK_SOFTWARE; + } + render(); +} + +els.year.textContent = new Date().getFullYear(); +els.keyword.addEventListener("input", (event) => { + state.keyword = event.target.value; + render(); +}); + +els.categoryButtons.forEach((button) => { + button.addEventListener("click", () => { + state.category = button.dataset.category; + els.categoryButtons.forEach((item) => item.classList.toggle("is-active", item === button)); + render(); + }); +}); + +els.refreshButton.addEventListener("click", loadSoftware); +els.playerStatus.addEventListener("click", async (event) => { + if (!event.target.closest("#player-logout")) return; + await fetch("/api/auth/logout", { method: "POST", headers: { Accept: "application/json" } }); + state.player = null; + state.authLoaded = true; + render(); +}); + +loadAuth(); +loadSoftware(); diff --git a/index.html b/index.html index 83c602f..c7b0002 100644 --- a/index.html +++ b/index.html @@ -33,9 +33,21 @@ 连接状态 + + + 软件下载 +
+ + + 软件中心 + Gemini ↗ @@ -166,6 +178,13 @@ 开发平台 + + + + 软件下载 + 上传、版本更新与下载分发 + +
diff --git a/login.html b/login.html new file mode 100644 index 0000000..80b5ddc --- /dev/null +++ b/login.html @@ -0,0 +1,51 @@ + + + + + + + + sumi.work - 玩家登录 + + + +
+
+ + + sumi.work 软件中心 + + +
+
+ +
+
+
+

Player Access

+

玩家登录

+ +
+ +
+
+ + + + diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..bc18294 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,1007 @@ +{ + "name": "sumi-work", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "sumi-work", + "version": "1.0.0", + "dependencies": { + "express": "^5.1.0", + "multer": "^2.0.2" + } + }, + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/append-field": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz", + "integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==", + "license": "MIT" + }, + "node_modules/body-parser": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", + "integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==", + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^1.0.5", + "debug": "^4.4.3", + "http-errors": "^2.0.0", + "iconv-lite": "^0.7.0", + "on-finished": "^2.4.1", + "qs": "^6.14.1", + "raw-body": "^3.0.1", + "type-is": "^2.0.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "license": "MIT" + }, + "node_modules/busboy": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", + "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==", + "dependencies": { + "streamsearch": "^1.1.0" + }, + "engines": { + "node": ">=10.16.0" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/concat-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz", + "integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==", + "engines": [ + "node >= 6.0" + ], + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.0.2", + "typedarray": "^0.0.6" + } + }, + "node_modules/content-disposition": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", + "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "license": "MIT" + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", + "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "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==", + "license": "MIT" + }, + "node_modules/multer": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/multer/-/multer-2.1.1.tgz", + "integrity": "sha512-mo+QTzKlx8R7E5ylSXxWzGoXoZbOsRMpyitcht8By2KHvMbf3tjwosZ/Mu/XYU6UuJ3VZnODIrak5ZrPiPyB6A==", + "license": "MIT", + "dependencies": { + "append-field": "^1.0.0", + "busboy": "^1.6.0", + "concat-stream": "^2.0.0", + "type-is": "^1.6.18" + }, + "engines": { + "node": ">= 10.16.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/multer/node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/multer/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/multer/node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/multer/node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-to-regexp": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/qs": { + "version": "6.15.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz", + "integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/streamsearch": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", + "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/type-is": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", + "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", + "license": "MIT", + "dependencies": { + "content-type": "^2.0.0", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/type-is/node_modules/content-type": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/typedarray": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", + "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==", + "license": "MIT" + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..3ee444b --- /dev/null +++ b/package.json @@ -0,0 +1,15 @@ +{ + "name": "sumi-work", + "version": "1.0.0", + "private": true, + "type": "module", + "description": "sumi.work service gateway and software download center", + "scripts": { + "start": "node server/server.js", + "dev": "node --watch server/server.js" + }, + "dependencies": { + "express": "^5.1.0", + "multer": "^2.0.2" + } +} diff --git a/server/data/players.json b/server/data/players.json new file mode 100644 index 0000000..b92af0e --- /dev/null +++ b/server/data/players.json @@ -0,0 +1,12 @@ +{ + "players": [ + { + "username": "player", + "displayName": "玩家", + "active": true, + "passwordHash": "pbkdf2_sha256$120000$c586c0d064f172a52475a6f72401a17d$bbcfb3b6bf374edd93edcde984b33d5c548738df0dd8f95aa33530cdb1a8bbe9", + "createdAt": "2026-06-08T00:00:00.000Z", + "updatedAt": "2026-06-08T00:00:00.000Z" + } + ] +} diff --git a/server/data/software.json b/server/data/software.json new file mode 100644 index 0000000..b0ecc2e --- /dev/null +++ b/server/data/software.json @@ -0,0 +1,85 @@ +{ + "items": [ + { + "id": "sumi-launcher", + "name": "Sumi Launcher", + "version": "1.0.0", + "category": "office", + "description": "常用工具快速启动入口,适合放置内部服务和日常软件。", + "changelog": "初始化软件中心示例数据。", + "publisher": "sumi.work", + "platform": "Windows", + "homepage": "", + "tags": [ + "官方", + "推荐" + ], + "sort": 10, + "active": true, + "featured": true, + "externalDownloadUrl": "", + "downloadUrl": "", + "size": "待上传", + "sizeBytes": 0, + "sha256": "", + "iconUrl": "/assets/icons/software.svg", + "createdAt": "2026-06-08T00:00:00.000Z", + "updatedAt": "2026-06-08T00:00:00.000Z", + "downloadCount": 0 + }, + { + "id": "git-client", + "name": "Git Client", + "version": "2.0.0", + "category": "dev", + "description": "代码仓库和版本管理工具示例,可在后台替换成真实安装包。", + "changelog": "初始化软件中心示例数据。", + "publisher": "sumi.work", + "platform": "Windows", + "homepage": "", + "tags": [ + "开发", + "版本管理" + ], + "sort": 20, + "active": true, + "featured": true, + "externalDownloadUrl": "", + "downloadUrl": "", + "size": "待上传", + "sizeBytes": 0, + "sha256": "", + "iconUrl": "/assets/icons/git.svg", + "createdAt": "2026-06-08T00:00:00.000Z", + "updatedAt": "2026-06-08T00:00:00.000Z", + "downloadCount": 0 + }, + { + "id": "sync-tool", + "name": "Sync Tool", + "version": "1.2.0", + "category": "network", + "description": "文件同步和分发工具示例,用于占位展示软件下载列表。", + "changelog": "初始化软件中心示例数据。", + "publisher": "sumi.work", + "platform": "Windows", + "homepage": "", + "tags": [ + "同步", + "网络" + ], + "sort": 30, + "active": true, + "featured": false, + "externalDownloadUrl": "", + "downloadUrl": "", + "size": "待上传", + "sizeBytes": 0, + "sha256": "", + "iconUrl": "/assets/icons/syncthing.svg", + "createdAt": "2026-06-08T00:00:00.000Z", + "updatedAt": "2026-06-08T00:00:00.000Z", + "downloadCount": 0 + } + ] +} diff --git a/server/server.js b/server/server.js new file mode 100644 index 0000000..9645fe4 --- /dev/null +++ b/server/server.js @@ -0,0 +1,617 @@ +import crypto from "node:crypto"; +import fs from "node:fs"; +import fsp from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +import express from "express"; +import multer from "multer"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const projectRoot = path.resolve(__dirname, ".."); +const dataDir = path.join(__dirname, "data"); +const uploadDir = path.join(__dirname, "uploads"); +const packageDir = path.join(uploadDir, "packages"); +const iconDir = path.join(uploadDir, "icons"); +const dataFile = path.join(dataDir, "software.json"); +const playerFile = path.join(dataDir, "players.json"); +const port = Number(process.env.PORT || 3030); +const adminToken = process.env.ADMIN_TOKEN || "sumi-admin"; +const playerSessionCookie = "sumi_player_session"; +const playerSessionTtlMs = Number(process.env.PLAYER_SESSION_TTL_MS || 7 * 24 * 60 * 60 * 1000); +const passwordIterations = 120000; + +const app = express(); +const playerSessions = new Map(); + +function slugify(value) { + const slug = String(value || "") + .normalize("NFKD") + .replace(/[^\w\s.-]/g, "") + .trim() + .replace(/[\s_.]+/g, "-") + .replace(/-+/g, "-") + .toLowerCase() + .slice(0, 64); + return slug || `software-${Date.now()}`; +} + +function parseBoolean(value, fallback = false) { + if (value === undefined || value === null || value === "") return fallback; + return ["1", "true", "on", "yes"].includes(String(value).toLowerCase()); +} + +function parseTags(value) { + if (!value) return []; + if (Array.isArray(value)) return value.flatMap(parseTags); + return String(value) + .split(/[,,\n]/) + .map((tag) => tag.trim()) + .filter(Boolean) + .slice(0, 8); +} + +function formatSize(bytes = 0) { + const units = ["B", "KB", "MB", "GB"]; + let size = Number(bytes); + let unitIndex = 0; + while (size >= 1024 && unitIndex < units.length - 1) { + size /= 1024; + unitIndex += 1; + } + return `${size.toFixed(unitIndex === 0 ? 0 : 1)} ${units[unitIndex]}`; +} + +async function ensureStorage() { + await Promise.all([ + fsp.mkdir(dataDir, { recursive: true }), + fsp.mkdir(packageDir, { recursive: true }), + fsp.mkdir(iconDir, { recursive: true }), + ]); + try { + await fsp.access(dataFile); + } catch { + await writeStore({ items: [] }); + } + + try { + await fsp.access(playerFile); + } catch { + await writePlayers({ + players: [ + createPlayerRecord(process.env.PLAYER_USERNAME || "player", process.env.PLAYER_PASSWORD || "sumi-player"), + ], + }); + } + + if (process.env.PLAYER_USERNAME && process.env.PLAYER_PASSWORD) { + await upsertEnvPlayer(); + } +} + +async function readStore() { + const raw = await fsp.readFile(dataFile, "utf8"); + const parsed = JSON.parse(raw || "{}"); + return { items: Array.isArray(parsed.items) ? parsed.items : [] }; +} + +async function writeStore(store) { + const tmpFile = `${dataFile}.${process.pid}.tmp`; + await fsp.writeFile(tmpFile, `${JSON.stringify({ items: store.items }, null, 2)}\n`, "utf8"); + await fsp.rename(tmpFile, dataFile); +} + +async function readPlayers() { + const raw = await fsp.readFile(playerFile, "utf8"); + const parsed = JSON.parse(raw || "{}"); + return { players: Array.isArray(parsed.players) ? parsed.players : [] }; +} + +async function writePlayers(store) { + const tmpFile = `${playerFile}.${process.pid}.tmp`; + await fsp.writeFile(tmpFile, `${JSON.stringify({ players: store.players }, null, 2)}\n`, "utf8"); + await fsp.rename(tmpFile, playerFile); +} + +function createPlayerRecord(username, password, existing = {}) { + return { + ...existing, + username, + displayName: existing.displayName || "玩家", + active: true, + passwordHash: hashPassword(password), + updatedAt: new Date().toISOString(), + createdAt: existing.createdAt || new Date().toISOString(), + }; +} + +async function upsertEnvPlayer() { + const username = process.env.PLAYER_USERNAME; + const password = process.env.PLAYER_PASSWORD; + const store = await readPlayers(); + const index = store.players.findIndex((player) => player.username === username); + if (index === -1) { + store.players.push(createPlayerRecord(username, password)); + } else { + const existing = store.players[index]; + if (existing.active !== false && verifyPassword(password, existing.passwordHash)) { + return; + } + store.players[index] = createPlayerRecord(username, password, store.players[index]); + } + await writePlayers(store); +} + +async function hashFile(filePath) { + const hash = crypto.createHash("sha256"); + await new Promise((resolve, reject) => { + const stream = fs.createReadStream(filePath); + stream.on("data", (chunk) => hash.update(chunk)); + stream.on("error", reject); + stream.on("end", resolve); + }); + return hash.digest("hex"); +} + +function hashPassword(password, salt = crypto.randomBytes(16).toString("hex")) { + const hash = crypto.pbkdf2Sync(String(password), salt, passwordIterations, 32, "sha256").toString("hex"); + return `pbkdf2_sha256$${passwordIterations}$${salt}$${hash}`; +} + +function verifyPassword(password, encoded = "") { + const [scheme, iterationText, salt, expectedHash] = String(encoded).split("$"); + if (scheme !== "pbkdf2_sha256" || !iterationText || !salt || !expectedHash) return false; + const iterations = Number.parseInt(iterationText, 10); + const actual = crypto.pbkdf2Sync(String(password), salt, iterations, 32, "sha256"); + const expected = Buffer.from(expectedHash, "hex"); + return expected.length === actual.length && crypto.timingSafeEqual(expected, actual); +} + +function getToken(req) { + const auth = req.get("authorization") || ""; + if (auth.toLowerCase().startsWith("bearer ")) return auth.slice(7).trim(); + return req.get("x-admin-token") || req.query.token || ""; +} + +function parseCookies(req) { + return Object.fromEntries( + String(req.get("cookie") || "") + .split(";") + .map((pair) => pair.trim()) + .filter(Boolean) + .map((pair) => { + const index = pair.indexOf("="); + if (index === -1) return [pair, ""]; + return [pair.slice(0, index), decodeURIComponent(pair.slice(index + 1))]; + }), + ); +} + +function isHttpsRequest(req) { + return req.secure || req.get("x-forwarded-proto") === "https"; +} + +function setSessionCookie(req, res, token) { + const secure = isHttpsRequest(req) ? "; Secure" : ""; + res.setHeader( + "Set-Cookie", + `${playerSessionCookie}=${encodeURIComponent(token)}; HttpOnly; SameSite=Lax; Path=/; Max-Age=${Math.floor(playerSessionTtlMs / 1000)}${secure}`, + ); +} + +function clearSessionCookie(req, res) { + const secure = isHttpsRequest(req) ? "; Secure" : ""; + res.setHeader("Set-Cookie", `${playerSessionCookie}=; HttpOnly; SameSite=Lax; Path=/; Max-Age=0${secure}`); +} + +function publicPlayer(player) { + if (!player) return null; + return { + username: player.username, + displayName: player.displayName || player.username, + }; +} + +function getPlayerSession(req) { + const token = parseCookies(req)[playerSessionCookie]; + if (!token) return null; + const session = playerSessions.get(token); + if (!session) return null; + if (session.expiresAt <= Date.now()) { + playerSessions.delete(token); + return null; + } + session.expiresAt = Date.now() + playerSessionTtlMs; + return { token, session }; +} + +function requireAdmin(req, res, next) { + if (getToken(req) !== adminToken) { + res.status(401).json({ error: "Invalid admin token" }); + return; + } + next(); +} + +function requirePlayer(req, res, next) { + const playerSession = getPlayerSession(req); + if (!playerSession) { + const loginUrl = `/login.html?next=${encodeURIComponent(req.originalUrl)}`; + const accept = req.get("accept") || ""; + if (accept.includes("text/html") && !accept.includes("application/json")) { + res.redirect(302, loginUrl); + return; + } + res.status(401).json({ error: "Player login required", loginUrl }); + return; + } + req.player = playerSession.session.player; + next(); +} + +function publicItem(item, includePrivate = false) { + const copy = { ...item }; + if (!includePrivate) { + delete copy.storagePath; + delete copy.iconStoragePath; + delete copy.externalDownloadUrl; + if (resolveDownloadUrl(item)) { + copy.downloadUrl = `/api/software/${encodeURIComponent(item.id)}/download`; + } + } + return copy; +} + +function sortSoftware(items) { + return [...items].sort((a, b) => { + if (Boolean(a.featured) !== Boolean(b.featured)) return a.featured ? -1 : 1; + if ((a.sort ?? 100) !== (b.sort ?? 100)) return (a.sort ?? 100) - (b.sort ?? 100); + return new Date(b.updatedAt || 0) - new Date(a.updatedAt || 0); + }); +} + +async function makeFilePatch(files = {}) { + const patch = {}; + const packageFile = files.package?.[0]; + const iconFile = files.icon?.[0]; + + if (packageFile) { + patch.storagePath = packageFile.path; + patch.originalFileName = packageFile.originalname; + patch.fileName = packageFile.filename; + patch.sizeBytes = packageFile.size; + patch.size = formatSize(packageFile.size); + patch.sha256 = await hashFile(packageFile.path); + patch.downloadUrl = `/uploads/packages/${encodeURIComponent(packageFile.filename)}`; + patch.externalDownloadUrl = ""; + } + + if (iconFile) { + patch.iconStoragePath = iconFile.path; + patch.iconFileName = iconFile.filename; + patch.iconUrl = `/uploads/icons/${encodeURIComponent(iconFile.filename)}`; + } + + return patch; +} + +function getBodyPatch(body, existing = {}) { + const now = new Date().toISOString(); + return { + name: String(body.name || existing.name || "").trim(), + version: String(body.version || existing.version || "1.0.0").trim(), + category: String(body.category || existing.category || "tools").trim(), + description: String(body.description || existing.description || "").trim(), + changelog: String(body.changelog || existing.changelog || "").trim(), + publisher: String(body.publisher || existing.publisher || "sumi.work").trim(), + platform: String(body.platform || existing.platform || "Windows").trim(), + homepage: String(body.homepage || existing.homepage || "").trim(), + tags: parseTags(body.tags ?? existing.tags), + sort: Number.parseInt(body.sort ?? existing.sort ?? 100, 10), + active: parseBoolean(body.active, existing.active !== false), + featured: parseBoolean(body.featured, Boolean(existing.featured)), + externalDownloadUrl: String(body.downloadUrl || existing.externalDownloadUrl || "").trim(), + updatedAt: now, + }; +} + +function resolveDownloadUrl(item) { + return item.downloadUrl || item.externalDownloadUrl || ""; +} + +function isPathInside(parent, child) { + const relative = path.relative(parent, child); + return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative)); +} + +function resolvePackagePath(item) { + if (item.storagePath) return path.resolve(item.storagePath); + if (item.downloadUrl?.startsWith("/uploads/packages/")) { + return path.resolve(packageDir, path.basename(decodeURIComponent(item.downloadUrl))); + } + return ""; +} + +const storage = multer.diskStorage({ + destination(req, file, callback) { + callback(null, file.fieldname === "icon" ? iconDir : packageDir); + }, + filename(req, file, callback) { + const ext = path.extname(file.originalname || "").slice(0, 24); + const base = slugify(path.basename(file.originalname || "upload", ext)); + const suffix = crypto.randomBytes(6).toString("hex"); + callback(null, `${Date.now()}-${suffix}-${base}${ext}`); + }, +}); + +const upload = multer({ + storage, + limits: { + fileSize: Number(process.env.MAX_UPLOAD_BYTES || 1024 * 1024 * 1024), + files: 2, + }, + fileFilter(req, file, callback) { + if (file.fieldname === "icon" && !file.mimetype.startsWith("image/")) { + callback(new Error("Icon must be an image file")); + return; + } + callback(null, true); + }, +}); + +app.disable("x-powered-by"); +app.use(express.json({ limit: "1mb" })); +app.use(express.urlencoded({ extended: true })); +app.use("/uploads/icons", express.static(iconDir, { fallthrough: false })); + +app.get("/api/health", (req, res) => { + res.json({ ok: true, service: "sumi-software-center" }); +}); + +app.get("/api/auth/me", (req, res) => { + const playerSession = getPlayerSession(req); + if (!playerSession) { + res.json({ authenticated: false, player: null }); + return; + } + setSessionCookie(req, res, playerSession.token); + res.json({ authenticated: true, player: publicPlayer(playerSession.session.player) }); +}); + +app.post("/api/auth/login", async (req, res, next) => { + try { + const username = String(req.body.username || "").trim(); + const password = String(req.body.password || ""); + const nextUrl = String(req.body.next || "/software.html"); + const { players } = await readPlayers(); + const player = players.find((entry) => entry.username === username && entry.active !== false); + + if (!player || !verifyPassword(password, player.passwordHash)) { + res.status(401).json({ error: "账号或密码错误" }); + return; + } + + const token = crypto.randomBytes(32).toString("base64url"); + playerSessions.set(token, { + player: publicPlayer(player), + createdAt: Date.now(), + expiresAt: Date.now() + playerSessionTtlMs, + }); + setSessionCookie(req, res, token); + res.json({ authenticated: true, player: publicPlayer(player), next: nextUrl }); + } catch (error) { + next(error); + } +}); + +app.post("/api/auth/logout", (req, res) => { + const playerSession = getPlayerSession(req); + if (playerSession) { + playerSessions.delete(playerSession.token); + } + clearSessionCookie(req, res); + res.json({ authenticated: false }); +}); + +app.get("/api/software", async (req, res, next) => { + try { + const { items } = await readStore(); + const category = String(req.query.category || "all"); + const keyword = String(req.query.keyword || "").trim().toLowerCase(); + const visibleItems = items + .filter((item) => item.active !== false) + .filter((item) => category === "all" || !category || item.category === category) + .filter((item) => { + if (!keyword) return true; + return [item.name, item.description, item.publisher, ...(item.tags || [])] + .join(" ") + .toLowerCase() + .includes(keyword); + }); + res.json({ items: sortSoftware(visibleItems).map((item) => publicItem(item)) }); + } catch (error) { + next(error); + } +}); + +app.get("/api/software/:id/latest", async (req, res, next) => { + try { + const { items } = await readStore(); + const item = items.find((entry) => entry.id === req.params.id && entry.active !== false); + if (!item) { + res.status(404).json({ error: "Software not found" }); + return; + } + res.json({ + id: item.id, + name: item.name, + version: item.version, + changelog: item.changelog, + updatedAt: item.updatedAt, + size: item.size, + sizeBytes: item.sizeBytes, + sha256: item.sha256, + downloadUrl: `/api/software/${encodeURIComponent(item.id)}/download`, + }); + } catch (error) { + next(error); + } +}); + +app.get("/api/software/:id/download", requirePlayer, async (req, res, next) => { + try { + const store = await readStore(); + const item = store.items.find((entry) => entry.id === req.params.id && entry.active !== false); + if (!item) { + res.status(404).json({ error: "Software not found" }); + return; + } + + const downloadUrl = resolveDownloadUrl(item); + if (!downloadUrl) { + res.status(404).json({ error: "No package has been uploaded" }); + return; + } + + item.downloadCount = (item.downloadCount || 0) + 1; + await writeStore(store); + + const packagePath = resolvePackagePath(item); + if (packagePath) { + if (!isPathInside(packageDir, packagePath)) { + res.status(403).json({ error: "Invalid package path" }); + return; + } + await fsp.access(packagePath); + res.download(packagePath, item.originalFileName || item.fileName || path.basename(packagePath)); + return; + } + + res.redirect(downloadUrl); + } catch (error) { + next(error); + } +}); + +app.get("/api/admin/software", requireAdmin, async (req, res, next) => { + try { + const { items } = await readStore(); + res.json({ items: sortSoftware(items).map((item) => publicItem(item, true)) }); + } catch (error) { + next(error); + } +}); + +app.post("/api/admin/software", requireAdmin, upload.fields([{ name: "package", maxCount: 1 }, { name: "icon", maxCount: 1 }]), async (req, res, next) => { + try { + const bodyPatch = getBodyPatch(req.body); + if (!bodyPatch.name) { + res.status(400).json({ error: "Software name is required" }); + return; + } + + const store = await readStore(); + const baseId = slugify(bodyPatch.name); + let id = baseId; + while (store.items.some((item) => item.id === id)) { + id = `${baseId}-${crypto.randomBytes(3).toString("hex")}`; + } + + const filePatch = await makeFilePatch(req.files); + const now = new Date().toISOString(); + const externalDownloadUrl = bodyPatch.externalDownloadUrl; + const item = { + id, + createdAt: now, + downloadCount: 0, + ...bodyPatch, + ...filePatch, + downloadUrl: filePatch.downloadUrl || externalDownloadUrl, + externalDownloadUrl: filePatch.downloadUrl ? "" : externalDownloadUrl, + }; + + store.items.push(item); + await writeStore(store); + res.status(201).json({ item: publicItem(item, true) }); + } catch (error) { + next(error); + } +}); + +app.put("/api/admin/software/:id", requireAdmin, upload.fields([{ name: "package", maxCount: 1 }, { name: "icon", maxCount: 1 }]), async (req, res, next) => { + try { + const store = await readStore(); + const index = store.items.findIndex((item) => item.id === req.params.id); + if (index === -1) { + res.status(404).json({ error: "Software not found" }); + return; + } + + const existing = store.items[index]; + const bodyPatch = getBodyPatch(req.body, existing); + if (!bodyPatch.name) { + res.status(400).json({ error: "Software name is required" }); + return; + } + + const filePatch = await makeFilePatch(req.files); + const downloadUrl = filePatch.downloadUrl || bodyPatch.externalDownloadUrl || existing.downloadUrl || ""; + const item = { + ...existing, + ...bodyPatch, + ...filePatch, + downloadUrl, + externalDownloadUrl: filePatch.downloadUrl ? "" : bodyPatch.externalDownloadUrl, + }; + + store.items[index] = item; + await writeStore(store); + res.json({ item: publicItem(item, true) }); + } catch (error) { + next(error); + } +}); + +app.delete("/api/admin/software/:id", requireAdmin, async (req, res, next) => { + try { + const store = await readStore(); + const initialLength = store.items.length; + store.items = store.items.filter((item) => item.id !== req.params.id); + if (store.items.length === initialLength) { + res.status(404).json({ error: "Software not found" }); + return; + } + await writeStore(store); + res.status(204).end(); + } catch (error) { + next(error); + } +}); + +app.get("/software", (req, res) => res.redirect(301, "/software.html")); +app.get("/software/", (req, res) => res.sendFile(path.join(projectRoot, "software.html"))); +app.get("/login", (req, res) => res.redirect(301, "/login.html")); +app.get("/admin/software", (req, res) => res.sendFile(path.join(projectRoot, "admin", "software.html"))); +app.get("/", (req, res) => res.sendFile(path.join(projectRoot, "index.html"))); +app.get("/index.html", (req, res) => res.sendFile(path.join(projectRoot, "index.html"))); +app.get("/software.html", (req, res) => res.sendFile(path.join(projectRoot, "software.html"))); +app.get("/login.html", (req, res) => res.sendFile(path.join(projectRoot, "login.html"))); +app.get("/admin/software.html", (req, res) => res.sendFile(path.join(projectRoot, "admin", "software.html"))); +app.use("/assets", express.static(path.join(projectRoot, "assets"), { fallthrough: false })); + +app.use((error, req, res, next) => { + console.error(error); + res.status(500).json({ error: error.message || "Internal server error" }); +}); + +await ensureStorage(); + +app.listen(port, () => { + console.log(`sumi.work software center running at http://localhost:${port}`); + if (!process.env.ADMIN_TOKEN) { + console.log("ADMIN_TOKEN is not set. Using development token: sumi-admin"); + } + if (!process.env.PLAYER_USERNAME || !process.env.PLAYER_PASSWORD) { + console.log("PLAYER_USERNAME/PLAYER_PASSWORD is not set. Using development player: player / sumi-player"); + } +}); diff --git a/server/uploads/icons/.gitkeep b/server/uploads/icons/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/server/uploads/icons/.gitkeep @@ -0,0 +1 @@ + diff --git a/server/uploads/packages/.gitkeep b/server/uploads/packages/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/server/uploads/packages/.gitkeep @@ -0,0 +1 @@ + diff --git a/software.html b/software.html new file mode 100644 index 0000000..6130e4c --- /dev/null +++ b/software.html @@ -0,0 +1,146 @@ + + + + + + + + sumi.work - 软件下载 + + + +
+
+ + + sumi.work 软件中心 + + + + +
+
+ +
+
+
+

Official Download Center

+

海量软件下载,管理无忧

+

统一沉淀安装包、版本号、更新记录和下载入口,让常用软件找得到、下得准、更新快。

+ +
+
+
0
+
软件收录
+
+
+ +
精选推荐
+
+
+
--
+
最近更新
+
+
+
+
+ +
+ + + + + + +
+ +
+
+
+

精选软件

+

常用安装包、版本和更新记录集中展示。

+
+ +
+ +
+ +
+
+
+

办公必备

+ 效率套件 +
+
+
+
+
+

开发常用

+ 构建调试 +
+
+
+
+
+

安全维护

+ 系统工具 +
+
+
+
+ +
+
+
+

全部软件

+

正在加载软件列表...

+
+
+
+ +
+
+ + + + + +