Remove setup code from first-run initialization
This commit is contained in:
1 parent
8b54258df5
commit
8b54ccba18
10 files changed
+125
-88
No files matched your search
@@ -12,7 +12,7 @@ install -d -m 750 -o 65532 -g 65532 data
|
|||||||
docker compose --env-file /docker/main/.env up -d --build --wait
|
docker compose --env-file /docker/main/.env up -d --build --wait
|
||||||
```
|
```
|
||||||
|
|
||||||
持久化数据位于 `deploy/data/`,与 `.dev-data/` 独立。首次初始化的启动码位于 `deploy/data/setup-code.txt`。
|
持久化数据位于 `deploy/data/`,与 `.dev-data/` 独立。首次打开网页时直接设置登录密码。
|
||||||
宿主机 Nginx 配置为 `deploy/nginx/raw.134333.xyz.conf`,复用现有通配证书及 WebSocket 升级映射;安装到 `/etc/nginx/conf.d/134333.xyz/` 后,先执行 `nginx -t` 再重载。
|
宿主机 Nginx 配置为 `deploy/nginx/raw.134333.xyz.conf`,复用现有通配证书及 WebSocket 升级映射;安装到 `/etc/nginx/conf.d/134333.xyz/` 后,先执行 `nginx -t` 再重载。
|
||||||
域名 `raw.134333.xyz` 在 DNSPod 设置 CNAME 指向 `cdn.134333.xyz`(与 AlphaPulse 一致);还需在 Cloudflare for SaaS 的 `unino.top` 区域添加该自定义域名,并等待域名与托管证书均为 `active`。仅设置 CNAME 或旧区域中的 A 记录不足以完成 CDN 接入。访问地址为 `https://raw.134333.xyz`。修改端口时需同步端口登记和 Nginx 上游。
|
域名 `raw.134333.xyz` 在 DNSPod 设置 CNAME 指向 `cdn.134333.xyz`(与 AlphaPulse 一致);还需在 Cloudflare for SaaS 的 `unino.top` 区域添加该自定义域名,并等待域名与托管证书均为 `active`。仅设置 CNAME 或旧区域中的 A 记录不足以完成 CDN 接入。访问地址为 `https://raw.134333.xyz`。修改端口时需同步端口登记和 Nginx 上游。
|
||||||
|
|
||||||
@@ -29,7 +29,7 @@ docker run -d --name rawmark \
|
|||||||
rawmark
|
rawmark
|
||||||
```
|
```
|
||||||
|
|
||||||
- 首次打开需要输入启动码,位于数据目录的 `setup-code.txt`(如 `~/rawmark-data/setup-code.txt`),设置密码后自动删除。
|
- 首次打开直接设置登录密码,之后凭密码登录;请在将新实例开放给他人访问前完成初始化。
|
||||||
- 笔记以普通 `.md` 文件保存在 `数据目录/vault/`,图片按 Typora 习惯存放在同级的 `笔记名.assets/`,可直接用 Typora 等编辑器打开;外部修改会实时同步到网页。
|
- 笔记以普通 `.md` 文件保存在 `数据目录/vault/`,图片按 Typora 习惯存放在同级的 `笔记名.assets/`,可直接用 Typora 等编辑器打开;外部修改会实时同步到网页。
|
||||||
- `--user` 让容器以宿主机用户身份读写数据目录;不指定时容器以 UID 65532 运行,需要该用户对目录有写权限。
|
- `--user` 让容器以宿主机用户身份读写数据目录;不指定时容器以 UID 65532 运行,需要该用户对目录有写权限。
|
||||||
- 请通过 HTTPS 反向代理(Caddy、Nginx 等)访问,并转发 WebSocket(`/api/v1/events`)。
|
- 请通过 HTTPS 反向代理(Caddy、Nginx 等)访问,并转发 WebSocket(`/api/v1/events`)。
|
||||||
@@ -52,7 +52,7 @@ scripts/dev.sh # 后端 go run -tags dev + 前端 Vite 热更新,Ctrl
|
|||||||
scripts/dev.sh --host # 前端监听所有网卡,手机访问 http://<电脑 IP>:5173
|
scripts/dev.sh --host # 前端监听所有网卡,手机访问 http://<电脑 IP>:5173
|
||||||
```
|
```
|
||||||
|
|
||||||
浏览器打开 http://localhost:5173 ,启动码在 `.dev-data/setup-code.txt`。端口与数据目录可用 `API_PORT`、`WEB_PORT`、`RAWMARK_DATA` 覆盖,详见 `scripts/dev.sh --help`。
|
浏览器打开 http://localhost:5173 ,首次使用直接设置登录密码。端口与数据目录可用 `API_PORT`、`WEB_PORT`、`RAWMARK_DATA` 覆盖,详见 `scripts/dev.sh --help`。
|
||||||
|
|
||||||
检查:
|
检查:
|
||||||
|
|
||||||
|
|||||||
+1
-16
@@ -62,21 +62,7 @@ func run(addr string) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
defer st.Close()
|
defer st.Close()
|
||||||
am, err := auth.New(st)
|
am := auth.New(st)
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// 启动码写入数据目录下的文件而非日志:只有能访问服务器的人才能完成首次初始化
|
|
||||||
codeFile := filepath.Join(dataDir, "setup-code.txt")
|
|
||||||
if code := am.SetupCode(); code != "" {
|
|
||||||
if err := os.WriteFile(codeFile, []byte(code+"\n"), 0o600); err != nil {
|
|
||||||
return fmt.Errorf("写入启动码失败: %w", err)
|
|
||||||
}
|
|
||||||
slog.Warn("尚未初始化:请在浏览器中打开 RawMark,输入启动码完成设置", "启动码文件", codeFile)
|
|
||||||
} else {
|
|
||||||
os.Remove(codeFile)
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
||||||
defer stop()
|
defer stop()
|
||||||
@@ -97,7 +83,6 @@ func run(addr string) error {
|
|||||||
srv := api.New(v, am, st, hub, idx)
|
srv := api.New(v, am, st, hub, idx)
|
||||||
srv.ThemesDir = filepath.Join(dataDir, "themes")
|
srv.ThemesDir = filepath.Join(dataDir, "themes")
|
||||||
srv.PluginsDir = filepath.Join(dataDir, "plugins")
|
srv.PluginsDir = filepath.Join(dataDir, "plugins")
|
||||||
srv.OnSetup = func() { os.Remove(codeFile) }
|
|
||||||
go srv.FollowMoves(ctx)
|
go srv.FollowMoves(ctx)
|
||||||
httpServer := &http.Server{
|
httpServer := &http.Server{
|
||||||
Addr: addr,
|
Addr: addr,
|
||||||
|
|||||||
+3
-8
@@ -38,8 +38,6 @@ type Server struct {
|
|||||||
index *search.Index
|
index *search.Index
|
||||||
// 串行化设置的"读取-合并-写入"
|
// 串行化设置的"读取-合并-写入"
|
||||||
settingsMu sync.Mutex
|
settingsMu sync.Mutex
|
||||||
// OnSetup 在首次初始化完成后调用(用于删除启动码文件)
|
|
||||||
OnSetup func()
|
|
||||||
// ThemesDir 指向数据目录中的主题目录;为空时只有内置主题。
|
// ThemesDir 指向数据目录中的主题目录;为空时只有内置主题。
|
||||||
ThemesDir string
|
ThemesDir string
|
||||||
PluginsDir string
|
PluginsDir string
|
||||||
@@ -228,21 +226,18 @@ func (s *Server) authState(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) setup(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) setup(w http.ResponseWriter, r *http.Request) {
|
||||||
var body struct{ Code, Password string }
|
var body struct{ Password string }
|
||||||
if !readJSON(w, r, &body) {
|
if !readJSON(w, r, &body) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
switch err := s.auth.Setup(body.Code, body.Password); {
|
switch err := s.auth.Setup(body.Password); {
|
||||||
case errors.Is(err, auth.ErrBadSetupCode), errors.Is(err, auth.ErrWeakPassword):
|
case errors.Is(err, auth.ErrWeakPassword):
|
||||||
writeError(w, 400, err.Error())
|
writeError(w, 400, err.Error())
|
||||||
case errors.Is(err, auth.ErrAlreadySetUp):
|
case errors.Is(err, auth.ErrAlreadySetUp):
|
||||||
writeError(w, 409, err.Error())
|
writeError(w, 409, err.Error())
|
||||||
case err != nil:
|
case err != nil:
|
||||||
internalError(w, r, err)
|
internalError(w, r, err)
|
||||||
default:
|
default:
|
||||||
if s.OnSetup != nil {
|
|
||||||
s.OnSetup()
|
|
||||||
}
|
|
||||||
s.startSession(w, r, body.Password)
|
s.startSession(w, r, body.Password)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/cookiejar"
|
"net/http/cookiejar"
|
||||||
@@ -148,11 +149,8 @@ func newFixture(t *testing.T) *fixture {
|
|||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
t.Cleanup(func() { st.Close() })
|
t.Cleanup(func() { st.Close() })
|
||||||
am, err := auth.New(st)
|
am := auth.New(st)
|
||||||
if err != nil {
|
if err := am.Setup("password123"); err != nil {
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if err := am.Setup(am.SetupCode(), "password123"); err != nil {
|
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
idx, err := search.Open(filepath.Join(dir, "index.db"), v)
|
idx, err := search.Open(filepath.Join(dir, "index.db"), v)
|
||||||
@@ -645,3 +643,97 @@ func TestChangePassword(t *testing.T) {
|
|||||||
t.Errorf("新密码登录,得到 %d", code)
|
t.Errorf("新密码登录,得到 %d", code)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestSetupWithoutCode(t *testing.T) {
|
||||||
|
st, err := store.Open(filepath.Join(t.TempDir(), "setup.db"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { st.Close() })
|
||||||
|
server := New(nil, auth.New(st), st, nil, nil)
|
||||||
|
srv := httptest.NewServer(server.Handler(fstest.MapFS{}))
|
||||||
|
t.Cleanup(srv.Close)
|
||||||
|
f := &fixture{t: t, url: srv.URL}
|
||||||
|
owner := newClient()
|
||||||
|
before := decode[struct{ SetUp, Authenticated bool }](t, f.do(owner, "GET", "/api/v1/auth/state", "", nil))
|
||||||
|
if before.SetUp || before.Authenticated {
|
||||||
|
t.Fatal("新实例不应已初始化或登录")
|
||||||
|
}
|
||||||
|
if status := f.status(owner, "POST", "/api/v1/auth/setup", `{"password":"short"}`, nil); status != 400 {
|
||||||
|
t.Fatalf("弱密码应被拒绝: %d", status)
|
||||||
|
}
|
||||||
|
if status := f.status(owner, "POST", "/api/v1/auth/setup", `{"password":"password123"}`, nil); status != 200 {
|
||||||
|
t.Fatalf("无需启动码即可初始化: %d", status)
|
||||||
|
}
|
||||||
|
after := decode[struct{ SetUp, Authenticated bool }](t, f.do(owner, "GET", "/api/v1/auth/state", "", nil))
|
||||||
|
if !after.SetUp || !after.Authenticated {
|
||||||
|
t.Fatal("初始化后应自动登录")
|
||||||
|
}
|
||||||
|
if status := f.status(newClient(), "POST", "/api/v1/auth/setup", `{"password":"replacement"}`, nil); status != 409 {
|
||||||
|
t.Fatalf("重复初始化必须拒绝: %d", status)
|
||||||
|
}
|
||||||
|
// 新建 Manager 模拟重启,不能重新开放密码设置。
|
||||||
|
reopened := auth.New(st)
|
||||||
|
if err := reopened.Setup("replacement"); !errors.Is(err, auth.ErrAlreadySetUp) {
|
||||||
|
t.Fatalf("重启后不能覆盖密码: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := reopened.Login("test", "password123"); err != nil {
|
||||||
|
t.Fatal("原密码应仍然有效", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConcurrentSetupCannotOverwritePassword(t *testing.T) {
|
||||||
|
st, err := store.Open(filepath.Join(t.TempDir(), "setup.db"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { st.Close() })
|
||||||
|
manager := auth.New(st)
|
||||||
|
server := New(nil, manager, st, nil, nil)
|
||||||
|
srv := httptest.NewServer(server.Handler(fstest.MapFS{}))
|
||||||
|
t.Cleanup(srv.Close)
|
||||||
|
passwords := []string{"password-one", "password-two"}
|
||||||
|
type outcome struct {
|
||||||
|
index, status int
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
results := make(chan outcome, 2)
|
||||||
|
for index, password := range passwords {
|
||||||
|
go func() {
|
||||||
|
res, err := newClient().Post(srv.URL+"/api/v1/auth/setup", "application/json", strings.NewReader(`{"password":"`+password+`"}`))
|
||||||
|
if err != nil {
|
||||||
|
results <- outcome{index: index, err: err}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
res.Body.Close()
|
||||||
|
results <- outcome{index: index, status: res.StatusCode}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
winner, conflicts := -1, 0
|
||||||
|
for range passwords {
|
||||||
|
result := <-results
|
||||||
|
if result.err != nil {
|
||||||
|
t.Fatal(result.err)
|
||||||
|
}
|
||||||
|
switch result.status {
|
||||||
|
case 200:
|
||||||
|
if winner >= 0 {
|
||||||
|
t.Fatal("初始化成功不止一次")
|
||||||
|
}
|
||||||
|
winner = result.index
|
||||||
|
case 409:
|
||||||
|
conflicts++
|
||||||
|
default:
|
||||||
|
t.Fatalf("初始化状态不符: %d", result.status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if winner < 0 || conflicts != 1 {
|
||||||
|
t.Fatal("必须只有一个初始化请求成功")
|
||||||
|
}
|
||||||
|
if _, err := manager.Login("test", passwords[winner]); err != nil {
|
||||||
|
t.Fatal("成功请求的密码未保存", err)
|
||||||
|
}
|
||||||
|
if _, err := manager.Login("test", passwords[1-winner]); !errors.Is(err, auth.ErrBadCredentials) {
|
||||||
|
t.Fatal("失败请求不应修改密码", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -98,7 +98,7 @@
|
|||||||
},
|
},
|
||||||
"/auth/setup": {
|
"/auth/setup": {
|
||||||
"post": {
|
"post": {
|
||||||
"summary": "首次设置登录密码(需服务器数据目录中的启动码)",
|
"summary": "首次设置登录密码",
|
||||||
"tags": [
|
"tags": [
|
||||||
"登录"
|
"登录"
|
||||||
],
|
],
|
||||||
@@ -112,15 +112,11 @@
|
|||||||
"schema": {
|
"schema": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"code": {
|
|
||||||
"type": "string"
|
|
||||||
},
|
|
||||||
"password": {
|
"password": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": [
|
"required": [
|
||||||
"code",
|
|
||||||
"password"
|
"password"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -139,7 +135,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"400": {
|
"400": {
|
||||||
"description": "启动码错误或密码过短",
|
"description": "密码过短",
|
||||||
"content": {
|
"content": {
|
||||||
"application/json": {
|
"application/json": {
|
||||||
"schema": {
|
"schema": {
|
||||||
@@ -158,7 +154,8 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
"description": "仅未初始化实例可设置密码;首次成功后自动登录,已初始化时返回 409。"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"/auth/login": {
|
"/auth/login": {
|
||||||
|
|||||||
+12
-32
@@ -40,7 +40,6 @@ const (
|
|||||||
|
|
||||||
var (
|
var (
|
||||||
ErrAlreadySetUp = errors.New("已完成初始化")
|
ErrAlreadySetUp = errors.New("已完成初始化")
|
||||||
ErrBadSetupCode = errors.New("启动码错误")
|
|
||||||
ErrWeakPassword = fmt.Errorf("密码至少 %d 位", MinPasswordLen)
|
ErrWeakPassword = fmt.Errorf("密码至少 %d 位", MinPasswordLen)
|
||||||
ErrBadCredentials = errors.New("密码错误")
|
ErrBadCredentials = errors.New("密码错误")
|
||||||
ErrRateLimited = errors.New("尝试次数过多,请稍后再试")
|
ErrRateLimited = errors.New("尝试次数过多,请稍后再试")
|
||||||
@@ -48,33 +47,15 @@ var (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type Manager struct {
|
type Manager struct {
|
||||||
store *store.Store
|
store *store.Store
|
||||||
// 首次初始化需要的一次性启动码,仅打印在服务日志中,防止部署后被他人抢先设置密码
|
|
||||||
setupCode string
|
|
||||||
|
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
failures map[string][]time.Time
|
failures map[string][]time.Time
|
||||||
blocked map[string]time.Time // 来源 -> 解封时间
|
blocked map[string]time.Time // 来源 -> 解封时间
|
||||||
now func() time.Time
|
now func() time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
func New(s *store.Store) (*Manager, error) {
|
func New(s *store.Store) *Manager {
|
||||||
m := &Manager{store: s, failures: map[string][]time.Time{}, blocked: map[string]time.Time{}, now: time.Now}
|
return &Manager{store: s, failures: map[string][]time.Time{}, blocked: map[string]time.Time{}, now: time.Now}
|
||||||
ok, err := m.IsSetUp()
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if !ok {
|
|
||||||
m.setupCode = randomCode()
|
|
||||||
}
|
|
||||||
return m, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// SetupCode 返回启动码;已初始化时为空
|
|
||||||
func (m *Manager) SetupCode() string {
|
|
||||||
m.mu.Lock()
|
|
||||||
defer m.mu.Unlock()
|
|
||||||
return m.setupCode
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Manager) IsSetUp() (bool, error) {
|
func (m *Manager) IsSetUp() (bool, error) {
|
||||||
@@ -82,14 +63,16 @@ func (m *Manager) IsSetUp() (bool, error) {
|
|||||||
return ok, err
|
return ok, err
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Manager) Setup(code, password string) error {
|
func (m *Manager) Setup(password string) error {
|
||||||
expected := m.SetupCode()
|
// 已初始化时提前拒绝,避免公开接口重复计算密码哈希;并发首次写入仍由数据库保证原子性。
|
||||||
if expected == "" {
|
setUp, err := m.IsSetUp()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if setUp {
|
||||||
return ErrAlreadySetUp
|
return ErrAlreadySetUp
|
||||||
}
|
}
|
||||||
if subtle.ConstantTimeCompare([]byte(strings.ToUpper(strings.TrimSpace(code))), []byte(expected)) != 1 {
|
|
||||||
return ErrBadSetupCode
|
|
||||||
}
|
|
||||||
if len([]rune(password)) < MinPasswordLen {
|
if len([]rune(password)) < MinPasswordLen {
|
||||||
return ErrWeakPassword
|
return ErrWeakPassword
|
||||||
}
|
}
|
||||||
@@ -100,9 +83,6 @@ func (m *Manager) Setup(code, password string) error {
|
|||||||
if !ok {
|
if !ok {
|
||||||
return ErrAlreadySetUp
|
return ErrAlreadySetUp
|
||||||
}
|
}
|
||||||
m.mu.Lock()
|
|
||||||
m.setupCode = ""
|
|
||||||
m.mu.Unlock()
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -348,7 +328,7 @@ func hashToken(token string) string {
|
|||||||
return hex.EncodeToString(sum[:])
|
return hex.EncodeToString(sum[:])
|
||||||
}
|
}
|
||||||
|
|
||||||
// randomCode 生成形如 ABCD-EFGH 的启动码,去掉易混淆字符
|
// randomCode 生成形如 ABCD-EFGH 的 API 令牌标识,去掉易混淆字符
|
||||||
func randomCode() string {
|
func randomCode() string {
|
||||||
const alphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
|
const alphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
|
||||||
b := make([]byte, 8)
|
b := make([]byte, 8)
|
||||||
|
|||||||
@@ -16,11 +16,8 @@ func newManager(t *testing.T) (*Manager, *time.Time) {
|
|||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
t.Cleanup(func() { st.Close() })
|
t.Cleanup(func() { st.Close() })
|
||||||
m, err := New(st)
|
m := New(st)
|
||||||
if err != nil {
|
if err := m.Setup("password123"); err != nil {
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if err := m.Setup(m.SetupCode(), "password123"); err != nil {
|
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
clock := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
|
clock := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
|
||||||
|
|||||||
@@ -82,10 +82,6 @@ for _ in $(seq 1 120); do
|
|||||||
done
|
done
|
||||||
curl -fs "http://127.0.0.1:$API_PORT/api/v1/health" >/dev/null 2>&1 || fail "后端 60 秒内未就绪,请查看上方 [server] 日志"
|
curl -fs "http://127.0.0.1:$API_PORT/api/v1/health" >/dev/null 2>&1 || fail "后端 60 秒内未就绪,请查看上方 [server] 日志"
|
||||||
|
|
||||||
if [[ -f "$RAWMARK_DATA/setup-code.txt" ]]; then
|
|
||||||
echo "==> 首次使用:启动码在 $RAWMARK_DATA/setup-code.txt"
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "==> 启动前端 http://localhost:$WEB_PORT"
|
echo "==> 启动前端 http://localhost:$WEB_PORT"
|
||||||
(cd web && exec npx vite "${VITE_ARGS[@]}") 2>&1 | prefix web &
|
(cd web && exec npx vite "${VITE_ARGS[@]}") 2>&1 | prefix web &
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import { api, ApiError } from './api'
|
|||||||
const props = defineProps<{ mode: 'setup' | 'login' }>()
|
const props = defineProps<{ mode: 'setup' | 'login' }>()
|
||||||
const emit = defineEmits<{ done: [] }>()
|
const emit = defineEmits<{ done: [] }>()
|
||||||
|
|
||||||
const code = ref('')
|
|
||||||
const password = ref('')
|
const password = ref('')
|
||||||
const confirm = ref('')
|
const confirm = ref('')
|
||||||
const error = ref('')
|
const error = ref('')
|
||||||
@@ -20,7 +19,7 @@ async function submit() {
|
|||||||
}
|
}
|
||||||
busy.value = true
|
busy.value = true
|
||||||
try {
|
try {
|
||||||
if (isSetup.value) await api.setup(code.value, password.value)
|
if (isSetup.value) await api.setup(password.value)
|
||||||
else await api.login(password.value)
|
else await api.login(password.value)
|
||||||
emit('done')
|
emit('done')
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -36,12 +35,8 @@ async function submit() {
|
|||||||
<form class="rm-auth-card" @submit.prevent="submit">
|
<form class="rm-auth-card" @submit.prevent="submit">
|
||||||
<h1>RawMark</h1>
|
<h1>RawMark</h1>
|
||||||
<p v-if="isSetup" class="rm-auth-hint">
|
<p v-if="isSetup" class="rm-auth-hint">
|
||||||
首次使用,请设置登录密码。启动码位于服务器数据目录的 <code>setup-code.txt</code> 中。
|
首次使用,请设置登录密码。
|
||||||
</p>
|
</p>
|
||||||
<label v-if="isSetup">
|
|
||||||
<span>启动码</span>
|
|
||||||
<input v-model="code" autocomplete="one-time-code" autocapitalize="characters" placeholder="XXXX-XXXX" required />
|
|
||||||
</label>
|
|
||||||
<label>
|
<label>
|
||||||
<span>密码</span>
|
<span>密码</span>
|
||||||
<input
|
<input
|
||||||
|
|||||||
+1
-1
@@ -112,7 +112,7 @@ const json = async <T>(p: Promise<Response>): Promise<T> => (await p).json()
|
|||||||
|
|
||||||
export const api = {
|
export const api = {
|
||||||
authState: () => json<{ setUp: boolean; authenticated: boolean }>(request('GET', '/api/v1/auth/state')),
|
authState: () => json<{ setUp: boolean; authenticated: boolean }>(request('GET', '/api/v1/auth/state')),
|
||||||
setup: (code: string, password: string) => request('POST', '/api/v1/auth/setup', { json: { code, password } }),
|
setup: (password: string) => request('POST', '/api/v1/auth/setup', { json: { password } }),
|
||||||
login: (password: string) => request('POST', '/api/v1/auth/login', { json: { password } }),
|
login: (password: string) => request('POST', '/api/v1/auth/login', { json: { password } }),
|
||||||
logout: () => request('POST', '/api/v1/auth/logout'),
|
logout: () => request('POST', '/api/v1/auth/logout'),
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user