Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c3af3caa5b |
@@ -22,10 +22,10 @@ go run ./cmd/zhanlu-proxy
|
|||||||
http://127.0.0.1:8080
|
http://127.0.0.1:8080
|
||||||
```
|
```
|
||||||
|
|
||||||
打开登录页:
|
打开首页会自动跳转到管理登录页:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
http://127.0.0.1:8080/login
|
http://127.0.0.1:8080/
|
||||||
```
|
```
|
||||||
|
|
||||||
## systemd 服务示例
|
## systemd 服务示例
|
||||||
@@ -52,6 +52,7 @@ ZHANLU_UPSTREAM_PATH=/api/acepilot/zhanlu/aiDeveloper/chat
|
|||||||
ZHANLU_MODELS=glm47,minimax-m25
|
ZHANLU_MODELS=glm47,minimax-m25
|
||||||
ZHANLU_DEFAULT_MODEL=minimax-m25
|
ZHANLU_DEFAULT_MODEL=minimax-m25
|
||||||
ZHANLU_UPSTREAM_TIMEOUT=300s
|
ZHANLU_UPSTREAM_TIMEOUT=300s
|
||||||
|
ZHANLU_LOGIN_PASSWORD=change-this-login-password
|
||||||
OPENAI_COMPAT_API_KEY=change-this-local-secret
|
OPENAI_COMPAT_API_KEY=change-this-local-secret
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -97,6 +98,8 @@ journalctl -u zhanlu-proxy -f
|
|||||||
|
|
||||||
打开 `/login` 后输入手机号并点击“获取验证码”。实现按插件默认登录分支工作:
|
打开 `/login` 后输入手机号并点击“获取验证码”。实现按插件默认登录分支工作:
|
||||||
|
|
||||||
|
如果设置了 `ZHANLU_LOGIN_PASSWORD`,`/login` 只负责管理密码登录。密码正确后服务会设置 HttpOnly 会话 Cookie,并跳转到 `/admin/login`。`/admin/login` 才是手机号验证码登录湛卢的页面,之后才能查看凭据状态、获取短信验证码、保存凭据或使用备用 SSO 管理接口。
|
||||||
|
|
||||||
- 生成 16 位一次性 `secret`。
|
- 生成 16 位一次性 `secret`。
|
||||||
- 使用插件内置 RSA 公钥加密手机号和 `secret`。
|
- 使用插件内置 RSA 公钥加密手机号和 `secret`。
|
||||||
- 调用公网接口 `/api/query/acepilot-h5/manager/code/getAuthCode` 发送验证码。
|
- 调用公网接口 `/api/query/acepilot-h5/manager/code/getAuthCode` 发送验证码。
|
||||||
@@ -191,6 +194,7 @@ curl http://127.0.0.1:8080/v1/models `
|
|||||||
| `ZHANLU_DEFAULT_MODEL` | `minimax-m25` | 请求未传 `model` 时使用的默认模型 |
|
| `ZHANLU_DEFAULT_MODEL` | `minimax-m25` | 请求未传 `model` 时使用的默认模型 |
|
||||||
| `ZHANLU_UPSTREAM_TIMEOUT` | `300s` | 上游请求超时 |
|
| `ZHANLU_UPSTREAM_TIMEOUT` | `300s` | 上游请求超时 |
|
||||||
| `ZHANLU_STREAM_IDLE_TIMEOUT` | `300s` | 预留的流式空闲超时配置 |
|
| `ZHANLU_STREAM_IDLE_TIMEOUT` | `300s` | 预留的流式空闲超时配置 |
|
||||||
|
| `ZHANLU_LOGIN_PASSWORD` | 空 | `/login` 管理页面密码;设置后登录成功跳转到 `/admin/login` 管理湛卢凭据 |
|
||||||
| `ZHANLU_DEBUG` | `false` | 调试模式,错误信息更详细但会脱敏敏感 query |
|
| `ZHANLU_DEBUG` | `false` | 调试模式,错误信息更详细但会脱敏敏感 query |
|
||||||
| `OPENAI_COMPAT_API_KEY` | 空 | 本地 OpenAI 兼容接口鉴权 key |
|
| `OPENAI_COMPAT_API_KEY` | 空 | 本地 OpenAI 兼容接口鉴权 key |
|
||||||
|
|
||||||
@@ -207,6 +211,7 @@ curl http://127.0.0.1:8080/v1/models `
|
|||||||
|
|
||||||
- `credentials.json` 包含明文 `AccessKey`、`SecretKey`、`Token`,请不要提交到仓库。
|
- `credentials.json` 包含明文 `AccessKey`、`SecretKey`、`Token`,请不要提交到仓库。
|
||||||
- 默认保存在当前执行目录的 `credentials.json`。
|
- 默认保存在当前执行目录的 `credentials.json`。
|
||||||
|
- 建议设置 `ZHANLU_LOGIN_PASSWORD`,避免公网暴露的 `/admin/login` 被直接访问。
|
||||||
- 错误响应默认不会返回签名 URL,避免泄露 `AccessKey`、`authorization`、`Signature`。
|
- 错误响应默认不会返回签名 URL,避免泄露 `AccessKey`、`authorization`、`Signature`。
|
||||||
- `ZHANLU_DEBUG=true` 时会返回更详细错误,但仍会对敏感 query 参数脱敏。
|
- `ZHANLU_DEBUG=true` 时会返回更详细错误,但仍会对敏感 query 参数脱敏。
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ type Config struct {
|
|||||||
Models []string
|
Models []string
|
||||||
DefaultModel string
|
DefaultModel string
|
||||||
OpenAIAPIKey string
|
OpenAIAPIKey string
|
||||||
|
LoginPassword string
|
||||||
UpstreamTimeout time.Duration
|
UpstreamTimeout time.Duration
|
||||||
StreamIdleTimout time.Duration
|
StreamIdleTimout time.Duration
|
||||||
Debug bool
|
Debug bool
|
||||||
@@ -41,6 +42,7 @@ func Load() (Config, error) {
|
|||||||
PhonePublicKeyPEM: getenv("ZHANLU_PHONE_PUBLIC_KEY_PEM", defaultPhonePublicKeyPEM),
|
PhonePublicKeyPEM: getenv("ZHANLU_PHONE_PUBLIC_KEY_PEM", defaultPhonePublicKeyPEM),
|
||||||
DefaultModel: getenv("ZHANLU_DEFAULT_MODEL", "minimax-m25"),
|
DefaultModel: getenv("ZHANLU_DEFAULT_MODEL", "minimax-m25"),
|
||||||
OpenAIAPIKey: os.Getenv("OPENAI_COMPAT_API_KEY"),
|
OpenAIAPIKey: os.Getenv("OPENAI_COMPAT_API_KEY"),
|
||||||
|
LoginPassword: os.Getenv("ZHANLU_LOGIN_PASSWORD"),
|
||||||
UpstreamTimeout: durationEnv("ZHANLU_UPSTREAM_TIMEOUT", 300*time.Second),
|
UpstreamTimeout: durationEnv("ZHANLU_UPSTREAM_TIMEOUT", 300*time.Second),
|
||||||
StreamIdleTimout: durationEnv("ZHANLU_STREAM_IDLE_TIMEOUT", 300*time.Second),
|
StreamIdleTimout: durationEnv("ZHANLU_STREAM_IDLE_TIMEOUT", 300*time.Second),
|
||||||
Debug: strings.EqualFold(os.Getenv("ZHANLU_DEBUG"), "true"),
|
Debug: strings.EqualFold(os.Getenv("ZHANLU_DEBUG"), "true"),
|
||||||
|
|||||||
+157
-16
@@ -2,6 +2,9 @@ package server
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bufio"
|
"bufio"
|
||||||
|
crand "crypto/rand"
|
||||||
|
"crypto/subtle"
|
||||||
|
"encoding/hex"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -21,26 +24,34 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type Server struct {
|
type Server struct {
|
||||||
cfg config.Config
|
cfg config.Config
|
||||||
mux *http.ServeMux
|
mux *http.ServeMux
|
||||||
|
loginSession string
|
||||||
}
|
}
|
||||||
|
|
||||||
func New(cfg config.Config) http.Handler {
|
func New(cfg config.Config) http.Handler {
|
||||||
s := &Server{cfg: cfg, mux: http.NewServeMux()}
|
s := &Server{cfg: cfg, mux: http.NewServeMux()}
|
||||||
|
if cfg.LoginPassword != "" {
|
||||||
|
s.loginSession = randomSessionToken()
|
||||||
|
}
|
||||||
s.routes()
|
s.routes()
|
||||||
return s.mux
|
return s.mux
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) routes() {
|
func (s *Server) routes() {
|
||||||
|
s.mux.HandleFunc("GET /", s.index)
|
||||||
s.mux.HandleFunc("GET /healthz", s.healthz)
|
s.mux.HandleFunc("GET /healthz", s.healthz)
|
||||||
s.mux.HandleFunc("GET /login", s.loginPage)
|
s.mux.HandleFunc("GET /login", s.loginPage)
|
||||||
s.mux.HandleFunc("GET /auth/start", s.startSSO)
|
s.mux.HandleFunc("GET /admin/login", s.adminLoginPage)
|
||||||
s.mux.HandleFunc("GET /auth/callback", s.ssoCallback)
|
s.mux.HandleFunc("POST /api/login", s.passwordLogin)
|
||||||
s.mux.HandleFunc("POST /api/auth/code", s.requestPhoneCode)
|
s.mux.HandleFunc("POST /api/logout", s.passwordLogout)
|
||||||
s.mux.HandleFunc("POST /api/auth/login", s.loginWithPhoneCode)
|
s.mux.HandleFunc("GET /auth/start", s.withLoginSession(s.startSSO))
|
||||||
s.mux.HandleFunc("GET /api/credentials", s.getCredentials)
|
s.mux.HandleFunc("GET /auth/callback", s.withLoginSession(s.ssoCallback))
|
||||||
s.mux.HandleFunc("POST /api/credentials", s.saveCredentials)
|
s.mux.HandleFunc("POST /api/auth/code", s.withLoginSession(s.requestPhoneCode))
|
||||||
s.mux.HandleFunc("POST /api/sso/exchange", s.exchangeSSOCode)
|
s.mux.HandleFunc("POST /api/auth/login", s.withLoginSession(s.loginWithPhoneCode))
|
||||||
|
s.mux.HandleFunc("GET /api/credentials", s.withLoginSession(s.getCredentials))
|
||||||
|
s.mux.HandleFunc("POST /api/credentials", s.withLoginSession(s.saveCredentials))
|
||||||
|
s.mux.HandleFunc("POST /api/sso/exchange", s.withLoginSession(s.exchangeSSOCode))
|
||||||
s.mux.HandleFunc("GET /v1/models", s.withAPIKey(s.models))
|
s.mux.HandleFunc("GET /v1/models", s.withAPIKey(s.models))
|
||||||
s.mux.HandleFunc("POST /v1/chat/completions", s.withAPIKey(s.chatCompletions))
|
s.mux.HandleFunc("POST /v1/chat/completions", s.withAPIKey(s.chatCompletions))
|
||||||
}
|
}
|
||||||
@@ -49,6 +60,14 @@ func (s *Server) healthz(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
|
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Server) index(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path != "/" {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.Redirect(w, r, "/login", http.StatusFound)
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Server) withAPIKey(next http.HandlerFunc) http.HandlerFunc {
|
func (s *Server) withAPIKey(next http.HandlerFunc) http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
if s.cfg.OpenAIAPIKey != "" {
|
if s.cfg.OpenAIAPIKey != "" {
|
||||||
@@ -63,8 +82,48 @@ func (s *Server) withAPIKey(next http.HandlerFunc) http.HandlerFunc {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) loginPage(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) loginPage(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if s.hasLoginSession(r) {
|
||||||
|
http.Redirect(w, r, "/admin/login", http.StatusFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if s.cfg.LoginPassword == "" {
|
||||||
|
http.Redirect(w, r, "/admin/login", http.StatusFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
_ = loginTemplate.Execute(w, map[string]any{"CredentialsPath": s.cfg.CredentialsPath, "SSOBaseURL": s.cfg.SSOBaseURL})
|
_ = loginTemplate.Execute(w, map[string]any{"CredentialsPath": s.cfg.CredentialsPath, "SSOBaseURL": s.cfg.SSOBaseURL, "PasswordEnabled": true, "AdminMode": false})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) adminLoginPage(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !s.hasLoginSession(r) {
|
||||||
|
http.Redirect(w, r, "/login", http.StatusFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
_ = loginTemplate.Execute(w, map[string]any{"CredentialsPath": s.cfg.CredentialsPath, "SSOBaseURL": s.cfg.SSOBaseURL, "PasswordEnabled": s.cfg.LoginPassword != "", "AdminMode": true})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) passwordLogin(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var in struct {
|
||||||
|
Password string `json:"password"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
|
||||||
|
writeJSON(w, http.StatusBadRequest, map[string]any{"ok": false, "error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !s.validLoginPassword(in.Password) {
|
||||||
|
writeJSON(w, http.StatusUnauthorized, map[string]any{"ok": false, "error": "登录密码无效"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if s.cfg.LoginPassword != "" {
|
||||||
|
http.SetCookie(w, &http.Cookie{Name: loginSessionCookieName, Value: s.loginSession, Path: "/", HttpOnly: true, SameSite: http.SameSiteLaxMode, MaxAge: int((24 * time.Hour).Seconds())})
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) passwordLogout(w http.ResponseWriter, r *http.Request) {
|
||||||
|
http.SetCookie(w, &http.Cookie{Name: loginSessionCookieName, Value: "", Path: "/", HttpOnly: true, SameSite: http.SameSiteLaxMode, MaxAge: -1})
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) startSSO(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) startSSO(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -223,6 +282,44 @@ func (s *Server) loginWithPhoneCode(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "path": s.cfg.CredentialsPath, "access_key": mask(creds.AccessKey)})
|
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "path": s.cfg.CredentialsPath, "access_key": mask(creds.AccessKey)})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Server) validLoginPassword(password string) bool {
|
||||||
|
if s.cfg.LoginPassword == "" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return subtle.ConstantTimeCompare([]byte(password), []byte(s.cfg.LoginPassword)) == 1
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) withLoginSession(next http.HandlerFunc) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !s.hasLoginSession(r) {
|
||||||
|
writeJSON(w, http.StatusUnauthorized, map[string]any{"ok": false, "error": "请先登录管理页面"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
next(w, r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) hasLoginSession(r *http.Request) bool {
|
||||||
|
if s.cfg.LoginPassword == "" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
c, err := r.Cookie(loginSessionCookieName)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return subtle.ConstantTimeCompare([]byte(c.Value), []byte(s.loginSession)) == 1
|
||||||
|
}
|
||||||
|
|
||||||
|
func randomSessionToken() string {
|
||||||
|
b := make([]byte, 32)
|
||||||
|
if _, err := crand.Read(b); err != nil {
|
||||||
|
return randomRequestID()
|
||||||
|
}
|
||||||
|
return hex.EncodeToString(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
const loginSessionCookieName = "zhanlu_proxy_session"
|
||||||
|
|
||||||
type phoneAPIResponse struct {
|
type phoneAPIResponse struct {
|
||||||
State string `json:"state"`
|
State string `json:"state"`
|
||||||
ErrorMessage string `json:"errorMessage"`
|
ErrorMessage string `json:"errorMessage"`
|
||||||
@@ -689,6 +786,15 @@ var loginTemplate = template.Must(template.New("login").Parse(`<!doctype html>
|
|||||||
<div class="muted">保存位置:<br><code>{{.CredentialsPath}}</code></div>
|
<div class="muted">保存位置:<br><code>{{.CredentialsPath}}</code></div>
|
||||||
</section>
|
</section>
|
||||||
<section class="login-card">
|
<section class="login-card">
|
||||||
|
{{if not .AdminMode}}
|
||||||
|
<h2>管理登录</h2>
|
||||||
|
<p>请输入服务环境变量 <code>ZHANLU_LOGIN_PASSWORD</code> 配置的管理密码。</p>
|
||||||
|
<form id="password-form">
|
||||||
|
<label>登录密码<input name="password" type="password" autocomplete="current-password" placeholder="请输入服务访问密码" required></label>
|
||||||
|
<button class="login-button" type="submit">进入登录管理</button>
|
||||||
|
</form>
|
||||||
|
<div class="status" id="status">需要登录后才能管理湛卢凭据。</div>
|
||||||
|
{{else}}
|
||||||
<h2>手机号验证码登录</h2>
|
<h2>手机号验证码登录</h2>
|
||||||
<p>手机号和一次性 secret 会按插件逻辑用 RSA 加密后提交到移动云公网接口。</p>
|
<p>手机号和一次性 secret 会按插件逻辑用 RSA 加密后提交到移动云公网接口。</p>
|
||||||
<form id="phone-form">
|
<form id="phone-form">
|
||||||
@@ -702,13 +808,16 @@ var loginTemplate = template.Must(template.New("login").Parse(`<!doctype html>
|
|||||||
<button class="login-button" type="submit">登录并保存凭据</button>
|
<button class="login-button" type="submit">登录并保存凭据</button>
|
||||||
</form>
|
</form>
|
||||||
<div class="status" id="status">正在检查登录状态...</div>
|
<div class="status" id="status">正在检查登录状态...</div>
|
||||||
<div class="muted">凭据保存到 JSON;验证码本身不会保存。</div>
|
<div class="muted">凭据保存到 JSON;验证码本身不会保存。{{if .PasswordEnabled}} <button id="logout-button" type="button">退出管理登录</button>{{end}}</div>
|
||||||
|
{{end}}
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
<script>
|
<script>
|
||||||
const statusEl = document.getElementById('status');
|
const statusEl = document.getElementById('status');
|
||||||
const form = document.getElementById('phone-form');
|
const form = document.getElementById('phone-form');
|
||||||
|
const passwordForm = document.getElementById('password-form');
|
||||||
const codeButton = document.getElementById('code-button');
|
const codeButton = document.getElementById('code-button');
|
||||||
|
const logoutButton = document.getElementById('logout-button');
|
||||||
let secret = '';
|
let secret = '';
|
||||||
let countdown = 0;
|
let countdown = 0;
|
||||||
let countdownTimer = null;
|
let countdownTimer = null;
|
||||||
@@ -733,11 +842,43 @@ var loginTemplate = template.Must(template.New("login").Parse(`<!doctype html>
|
|||||||
}, 1000);
|
}, 1000);
|
||||||
}
|
}
|
||||||
|
|
||||||
fetch('/api/credentials').then(r => r.json()).then(data => {
|
if (passwordForm) {
|
||||||
statusEl.textContent = data.configured ? ('已登录:' + (data.access_key || '')) : '当前未登录';
|
passwordForm.addEventListener('submit', async (event) => {
|
||||||
});
|
event.preventDefault();
|
||||||
|
const password = passwordForm.password.value;
|
||||||
|
if (!password) {
|
||||||
|
setStatus('请输入登录密码');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setStatus('正在登录...');
|
||||||
|
const res = await fetch('/api/login', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ password })
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (!res.ok || !data.ok) {
|
||||||
|
setStatus(data.error || '登录失败');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
window.location.href = '/admin/login';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
codeButton.addEventListener('click', async () => {
|
if (logoutButton) {
|
||||||
|
logoutButton.addEventListener('click', async () => {
|
||||||
|
await fetch('/api/logout', { method: 'POST' });
|
||||||
|
window.location.href = '/login';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (form) {
|
||||||
|
fetch('/api/credentials').then(r => r.json()).then(data => {
|
||||||
|
statusEl.textContent = data.configured ? ('已登录:' + (data.access_key || '')) : '当前未登录';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
codeButton && codeButton.addEventListener('click', async () => {
|
||||||
const telephone = form.telephone.value.trim();
|
const telephone = form.telephone.value.trim();
|
||||||
if (!/^1[3-9]\d{9}$/.test(telephone)) {
|
if (!/^1[3-9]\d{9}$/.test(telephone)) {
|
||||||
setStatus('请输入有效的 11 位手机号');
|
setStatus('请输入有效的 11 位手机号');
|
||||||
@@ -761,7 +902,7 @@ var loginTemplate = template.Must(template.New("login").Parse(`<!doctype html>
|
|||||||
startCountdown();
|
startCountdown();
|
||||||
});
|
});
|
||||||
|
|
||||||
form.addEventListener('submit', async (event) => {
|
form && form.addEventListener('submit', async (event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
const telephone = form.telephone.value.trim();
|
const telephone = form.telephone.value.trim();
|
||||||
const code = form.code.value.trim();
|
const code = form.code.value.trim();
|
||||||
|
|||||||
Reference in New Issue
Block a user