Release the project
This commit is contained in:
commit
e20b055642
16 changed files with 1350 additions and 0 deletions
163
.gitignore
vendored
Normal file
163
.gitignore
vendored
Normal file
|
@ -0,0 +1,163 @@
|
|||
# Byte-compiled / optimized / DLL files
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
|
||||
# C extensions
|
||||
*.so
|
||||
|
||||
# Distribution / packaging
|
||||
.Python
|
||||
build/
|
||||
develop-eggs/
|
||||
dist/
|
||||
downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
lib/
|
||||
lib64/
|
||||
parts/
|
||||
sdist/
|
||||
var/
|
||||
wheels/
|
||||
share/python-wheels/
|
||||
*.egg-info/
|
||||
.installed.cfg
|
||||
*.egg
|
||||
MANIFEST
|
||||
|
||||
# PyInstaller
|
||||
# Usually these files are written by a python script from a template
|
||||
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
||||
*.manifest
|
||||
*.spec
|
||||
|
||||
# Installer logs
|
||||
pip-log.txt
|
||||
pip-delete-this-directory.txt
|
||||
|
||||
# Unit test / coverage reports
|
||||
htmlcov/
|
||||
.tox/
|
||||
.nox/
|
||||
.coverage
|
||||
.coverage.*
|
||||
.cache
|
||||
nosetests.xml
|
||||
coverage.xml
|
||||
*.cover
|
||||
*.py,cover
|
||||
.hypothesis/
|
||||
.pytest_cache/
|
||||
cover/
|
||||
|
||||
# Translations
|
||||
*.mo
|
||||
*.pot
|
||||
|
||||
# Django stuff:
|
||||
*.log
|
||||
local_settings.py
|
||||
db.sqlite3
|
||||
db.sqlite3-journal
|
||||
|
||||
# Flask stuff:
|
||||
instance/
|
||||
.webassets-cache
|
||||
|
||||
# Scrapy stuff:
|
||||
.scrapy
|
||||
|
||||
# Sphinx documentation
|
||||
docs/_build/
|
||||
|
||||
# PyBuilder
|
||||
.pybuilder/
|
||||
target/
|
||||
|
||||
# Jupyter Notebook
|
||||
.ipynb_checkpoints
|
||||
|
||||
# IPython
|
||||
profile_default/
|
||||
ipython_config.py
|
||||
|
||||
# pyenv
|
||||
# For a library or package, you might want to ignore these files since the code is
|
||||
# intended to run in multiple environments; otherwise, check them in:
|
||||
# .python-version
|
||||
|
||||
# pipenv
|
||||
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
|
||||
# However, in case of collaboration, if having platform-specific dependencies or dependencies
|
||||
# having no cross-platform support, pipenv may install dependencies that don't work, or not
|
||||
# install all needed dependencies.
|
||||
#Pipfile.lock
|
||||
|
||||
# poetry
|
||||
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
|
||||
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
||||
# commonly ignored for libraries.
|
||||
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
|
||||
#poetry.lock
|
||||
|
||||
# pdm
|
||||
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
|
||||
#pdm.lock
|
||||
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
|
||||
# in version control.
|
||||
# https://pdm.fming.dev/#use-with-ide
|
||||
.pdm.toml
|
||||
|
||||
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
|
||||
__pypackages__/
|
||||
|
||||
# Celery stuff
|
||||
celerybeat-schedule
|
||||
celerybeat.pid
|
||||
|
||||
# SageMath parsed files
|
||||
*.sage.py
|
||||
|
||||
# Environments
|
||||
.env
|
||||
.venv
|
||||
env/
|
||||
venv/
|
||||
ENV/
|
||||
env.bak/
|
||||
venv.bak/
|
||||
|
||||
# Spyder project settings
|
||||
.spyderproject
|
||||
.spyproject
|
||||
|
||||
# Rope project settings
|
||||
.ropeproject
|
||||
|
||||
# mkdocs documentation
|
||||
/site
|
||||
|
||||
# mypy
|
||||
.mypy_cache/
|
||||
.dmypy.json
|
||||
dmypy.json
|
||||
|
||||
# Pyre type checker
|
||||
.pyre/
|
||||
|
||||
# pytype static type analyzer
|
||||
.pytype/
|
||||
|
||||
# Cython debug symbols
|
||||
cython_debug/
|
||||
|
||||
# PyCharm
|
||||
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
|
||||
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
|
||||
# and can be added to the global gitignore or merged into this file. For a more nuclear
|
||||
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
||||
#.idea/
|
||||
|
||||
config.json
|
||||
*.db
|
16
.pre-commit-config.yaml
Normal file
16
.pre-commit-config.yaml
Normal file
|
@ -0,0 +1,16 @@
|
|||
repos:
|
||||
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||
rev: v4.5.0
|
||||
hooks:
|
||||
- id: trailing-whitespace
|
||||
- id: end-of-file-fixer
|
||||
- id: check-yaml
|
||||
- repo: https://github.com/psf/black
|
||||
rev: 23.10.1
|
||||
hooks:
|
||||
- id: black
|
||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||
rev: v0.1.4
|
||||
hooks:
|
||||
- id: ruff
|
||||
args: [--fix, --exit-non-zero-on-fix]
|
21
LICENSE
Normal file
21
LICENSE
Normal file
|
@ -0,0 +1,21 @@
|
|||
MIT License
|
||||
|
||||
Copyright (c) 2023 BobMaster
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
19
README.md
Normal file
19
README.md
Normal file
|
@ -0,0 +1,19 @@
|
|||
# wangfu-register
|
||||
Edit `config.json` and templates/*.html(turnstile site keys)
|
||||
|
||||
## development
|
||||
Create virtual environment and install dependencies
|
||||
```
|
||||
python -m venv venv
|
||||
source venv/bin/activate
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
Debug the code
|
||||
```
|
||||
uvicorn src.main:app --reload
|
||||
```
|
||||
|
||||
## deployment
|
||||
```
|
||||
uvicorn src.main:app --host "127.0.0.1" --port 10010 --proxy-headers --forwarded-allow-ips "*"
|
||||
```
|
2
assets/jquery-3.7.1.min.js
vendored
Normal file
2
assets/jquery-3.7.1.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
BIN
assets/logo-removebg.png
Normal file
BIN
assets/logo-removebg.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 112 KiB |
1
assets/mdui.css
Normal file
1
assets/mdui.css
Normal file
File diff suppressed because one or more lines are too long
22
assets/mdui.global.js
Normal file
22
assets/mdui.global.js
Normal file
File diff suppressed because one or more lines are too long
8
config.json.example
Normal file
8
config.json.example
Normal file
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"turnstile_secret_key": "1x0000000000000000000000000000000AA",
|
||||
"keycloak_server_url": "xxxxxxxxx",
|
||||
"keycloak_admin_username": "xxxxxxx",
|
||||
"keycloak_admin_password": "xxxxxxxx",
|
||||
"keycloak_realm_name": "master",
|
||||
"open_registration": true
|
||||
}
|
39
nginx.conf
Normal file
39
nginx.conf
Normal file
|
@ -0,0 +1,39 @@
|
|||
server {
|
||||
listen 80;
|
||||
listen [::]:80;
|
||||
server_name "registry.csuwf.com";
|
||||
return 301 https://$server_name$request_uri;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 443 ssl;
|
||||
listen [::]:443 ssl;
|
||||
http2 on;
|
||||
http3 on;
|
||||
http3_hq on;
|
||||
add_header Alt-Svc 'h3=":443"; ma=86400';
|
||||
server_name "registry.csuwf.com";
|
||||
|
||||
ssl_certificate /etc/nginx/ssl/registry.csuwf.com.cert.pem;
|
||||
ssl_certificate_key /etc/nginx/ssl/registry.csuwf.com.key.pem;
|
||||
ssl_session_timeout 1d;
|
||||
ssl_session_cache shared:SSL:10m;
|
||||
ssl_session_tickets off;
|
||||
ssl_protocols TLSv1.2 TLSv1.3;
|
||||
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384;
|
||||
ssl_conf_command Ciphersuites TLS_AES_128_GCM_SHA256:TLS_AES_256_GCM_SHA384:TLS_CHACHA20_POLY1305_SHA256;
|
||||
ssl_ecdh_curve X25519:secp384r1;
|
||||
ssl_prefer_server_ciphers on;
|
||||
ssl_stapling on;
|
||||
ssl_stapling_verify on;
|
||||
ssl_trusted_certificate /etc/nginx/ssl/registry.csuwf.com.cert.pem;
|
||||
resolver 8.8.8.8 1.1.1.1;
|
||||
# ssl related config
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:10010;
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
}
|
6
requirements.txt
Normal file
6
requirements.txt
Normal file
|
@ -0,0 +1,6 @@
|
|||
fastapi
|
||||
uvicorn
|
||||
python-multipart
|
||||
jinja2
|
||||
email-validator
|
||||
python-keycloak
|
181
src/db.py
Normal file
181
src/db.py
Normal file
|
@ -0,0 +1,181 @@
|
|||
import sqlite3
|
||||
import sys
|
||||
import random
|
||||
from .log import getlogger
|
||||
|
||||
logger = getlogger()
|
||||
|
||||
|
||||
class DBManager:
|
||||
def __init__(self) -> None:
|
||||
try:
|
||||
self.conn = sqlite3.connect("user.db")
|
||||
self.cursor = self.conn.cursor()
|
||||
self.cursor.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
username TEXT NOT NULL,
|
||||
email TEXT NOT NULL,
|
||||
seed TEXT NOT NULL
|
||||
)
|
||||
"""
|
||||
)
|
||||
self.conn.commit()
|
||||
db_version = self.get_db_version()
|
||||
if db_version == 0:
|
||||
self.migrate_0to1()
|
||||
except Exception as e:
|
||||
logger.error(e)
|
||||
sys.exit(1)
|
||||
|
||||
def add_user(self, username, email):
|
||||
try:
|
||||
self.cursor.execute(
|
||||
"""
|
||||
INSERT INTO users (username, email, seed)
|
||||
VALUES (?, ?, ?)
|
||||
""",
|
||||
(username, email, hex(random.getrandbits(64))[2:]),
|
||||
)
|
||||
self.conn.commit()
|
||||
except Exception as e:
|
||||
logger.error(e)
|
||||
|
||||
def get_user_by_name(self, username):
|
||||
try:
|
||||
self.cursor.execute(
|
||||
"""
|
||||
SELECT * FROM users WHERE username = ?
|
||||
""",
|
||||
(username,),
|
||||
)
|
||||
return self.cursor.fetchone()
|
||||
except Exception as e:
|
||||
logger.error(e)
|
||||
|
||||
def get_user_by_email(self, email) -> str:
|
||||
try:
|
||||
self.cursor.execute(
|
||||
"""
|
||||
SELECT username FROM users WHERE email = ?
|
||||
""",
|
||||
(email,),
|
||||
)
|
||||
return self.cursor.fetchone()[0]
|
||||
except Exception as e:
|
||||
logger.error(e)
|
||||
|
||||
# if db version not exist, initialize and create meta table and set db_version to 0
|
||||
def get_db_version(self):
|
||||
try:
|
||||
self.cursor.execute(
|
||||
"""
|
||||
SELECT db_version FROM meta
|
||||
"""
|
||||
)
|
||||
return self.cursor.fetchone()[0]
|
||||
except Exception as e:
|
||||
logger.error(e)
|
||||
self.cursor.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS meta (
|
||||
db_version INTEGER NOT NULL
|
||||
)
|
||||
"""
|
||||
)
|
||||
logger.info("meta table created")
|
||||
self.cursor.execute(
|
||||
"""
|
||||
INSERT INTO meta (db_version) VALUES (0)
|
||||
"""
|
||||
)
|
||||
logger.info("db_version set to 0")
|
||||
self.conn.commit()
|
||||
return 0
|
||||
|
||||
def get_user_seed_by_username(self, username) -> str:
|
||||
try:
|
||||
self.cursor.execute(
|
||||
"""
|
||||
SELECT seed FROM users WHERE username = ?
|
||||
""",
|
||||
(username,),
|
||||
)
|
||||
return self.cursor.fetchone()[0]
|
||||
except Exception as e:
|
||||
logger.error(e)
|
||||
|
||||
def get_user_seed_by_email(self, email) -> str:
|
||||
try:
|
||||
self.cursor.execute(
|
||||
"""
|
||||
SELECT seed FROM users WHERE email = ?
|
||||
""",
|
||||
(email,),
|
||||
)
|
||||
return self.cursor.fetchone()[0]
|
||||
except Exception as e:
|
||||
logger.error(e)
|
||||
|
||||
def update_user_seed_by_username(self, username):
|
||||
try:
|
||||
self.cursor.execute(
|
||||
"""
|
||||
UPDATE users SET seed = ? WHERE username = ?
|
||||
""",
|
||||
(hex(random.getrandbits(64))[2:], username),
|
||||
)
|
||||
self.conn.commit()
|
||||
except Exception as e:
|
||||
logger.error(e)
|
||||
|
||||
def update_user_seed_by_email(self, email):
|
||||
try:
|
||||
self.cursor.execute(
|
||||
"""
|
||||
UPDATE users SET seed = ? WHERE email = ?
|
||||
""",
|
||||
(hex(random.getrandbits(64))[2:], email),
|
||||
)
|
||||
self.conn.commit()
|
||||
except Exception as e:
|
||||
logger.error(e)
|
||||
|
||||
# db migration from 0 to 1
|
||||
# add column seed and set random seed to existing users
|
||||
# seed: 16 hex numbers
|
||||
def migrate_0to1(self):
|
||||
try:
|
||||
self.cursor.execute(
|
||||
"""
|
||||
ALTER TABLE users ADD COLUMN seed TEXT
|
||||
"""
|
||||
)
|
||||
logger.info("seed column added")
|
||||
# iterate over all users and set random seed
|
||||
self.cursor.execute(
|
||||
"""
|
||||
SELECT * FROM users
|
||||
"""
|
||||
)
|
||||
users = self.cursor.fetchall()
|
||||
for user in users:
|
||||
self.cursor.execute(
|
||||
"""
|
||||
UPDATE users SET seed = ? WHERE id = ?
|
||||
""",
|
||||
(hex(random.getrandbits(64))[2:], user[0]),
|
||||
)
|
||||
self.conn.commit()
|
||||
logger.info("seed set for all users")
|
||||
# update db_version to 1
|
||||
self.cursor.execute(
|
||||
"""
|
||||
UPDATE meta SET db_version = 1
|
||||
"""
|
||||
)
|
||||
self.conn.commit()
|
||||
except Exception as e:
|
||||
logger.error(e)
|
||||
sys.exit(1)
|
40
src/log.py
Normal file
40
src/log.py
Normal file
|
@ -0,0 +1,40 @@
|
|||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
log_path = Path(os.path.dirname(__file__)).parent / "bot.log"
|
||||
|
||||
|
||||
def getlogger():
|
||||
# create a custom logger if no log handler
|
||||
logger = logging.getLogger(__name__)
|
||||
if not logger.hasHandlers():
|
||||
logger.setLevel(logging.INFO)
|
||||
# create handlers
|
||||
warn_handler = logging.StreamHandler()
|
||||
info_handler = logging.StreamHandler()
|
||||
error_handler = logging.FileHandler("bot.log", mode="a")
|
||||
warn_handler.setLevel(logging.WARNING)
|
||||
error_handler.setLevel(logging.ERROR)
|
||||
info_handler.setLevel(logging.INFO)
|
||||
|
||||
# create formatters
|
||||
warn_format = logging.Formatter(
|
||||
"%(asctime)s - %(funcName)s - %(levelname)s - %(message)s",
|
||||
)
|
||||
error_format = logging.Formatter(
|
||||
"%(asctime)s - %(name)s - %(funcName)s - %(levelname)s - %(message)s",
|
||||
)
|
||||
info_format = logging.Formatter("%(asctime)s - %(levelname)s - %(message)s")
|
||||
|
||||
# set formatter
|
||||
warn_handler.setFormatter(warn_format)
|
||||
error_handler.setFormatter(error_format)
|
||||
info_handler.setFormatter(info_format)
|
||||
|
||||
# add handlers to logger
|
||||
logger.addHandler(warn_handler)
|
||||
logger.addHandler(error_handler)
|
||||
logger.addHandler(info_handler)
|
||||
|
||||
return logger
|
307
src/main.py
Normal file
307
src/main.py
Normal file
|
@ -0,0 +1,307 @@
|
|||
import json
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.responses import HTMLResponse, PlainTextResponse, JSONResponse
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from pydantic import BaseModel
|
||||
from email_validator import validate_email, EmailNotValidError
|
||||
from keycloak import KeycloakAdmin, KeycloakGetError
|
||||
from keycloak import KeycloakOpenIDConnection
|
||||
from .log import getlogger
|
||||
from .db import DBManager
|
||||
import subprocess
|
||||
import requests
|
||||
from shlex import quote as shellquote
|
||||
|
||||
reserved_users = [
|
||||
"admin",
|
||||
"administrator",
|
||||
"root",
|
||||
"postmaster",
|
||||
"webmaster",
|
||||
"wf",
|
||||
"csu",
|
||||
"csuwf",
|
||||
]
|
||||
|
||||
reserved_emails = [
|
||||
"admin@csuwf.com",
|
||||
"administrator@csuwf.com",
|
||||
"root@csuwf.com",
|
||||
"postmaster@csuwf.com",
|
||||
"webmaster@csuwf.com",
|
||||
"wf@csuwf.com",
|
||||
"csu@csuwf.com",
|
||||
"csuwf@csuwf.com",
|
||||
"rocketchat-bot@csuwf.com",
|
||||
"nextcloud-bot@csuwf.com",
|
||||
"dmarc.report@csuwf.com",
|
||||
"keycloak@csuwf.com",
|
||||
]
|
||||
|
||||
logger = getlogger()
|
||||
dbmanager = DBManager()
|
||||
|
||||
|
||||
class Register_Request_Body(BaseModel):
|
||||
username: str
|
||||
email: str
|
||||
password: str
|
||||
token: str
|
||||
|
||||
|
||||
class Password_Reset_Request_Body(BaseModel):
|
||||
email: str
|
||||
password: str
|
||||
authcode: str
|
||||
token: str
|
||||
|
||||
|
||||
class checkemailexistRequest(BaseModel):
|
||||
email: str
|
||||
|
||||
|
||||
class checkusernameexistRequest(BaseModel):
|
||||
username: str
|
||||
|
||||
|
||||
app = FastAPI(openapi_url=None)
|
||||
app.mount(
|
||||
"/assets",
|
||||
StaticFiles(directory="assets"),
|
||||
name="assets",
|
||||
)
|
||||
|
||||
templates = Jinja2Templates(directory="templates")
|
||||
|
||||
with open("config.json", "r") as f:
|
||||
config = json.load(f)
|
||||
turnstile_secret_key = config["turnstile_secret_key"]
|
||||
keycloak_server_url = config["keycloak_server_url"]
|
||||
keycloak_admin_username = config["keycloak_admin_username"]
|
||||
keycloak_admin_password = config["keycloak_admin_password"]
|
||||
keycloak_realm_name = config["keycloak_realm_name"]
|
||||
open_registration = config["open_registration"]
|
||||
|
||||
keycloak_connection = KeycloakOpenIDConnection(
|
||||
server_url=keycloak_server_url,
|
||||
username=keycloak_admin_username,
|
||||
password=keycloak_admin_password,
|
||||
realm_name=keycloak_realm_name,
|
||||
verify=True,
|
||||
)
|
||||
|
||||
keycloak_admin = KeycloakAdmin(connection=keycloak_connection)
|
||||
|
||||
|
||||
@app.get("/", response_class=HTMLResponse)
|
||||
async def root(request: Request):
|
||||
return templates.TemplateResponse("index.html", {"request": request})
|
||||
|
||||
|
||||
@app.get("/update-password", response_class=HTMLResponse)
|
||||
async def update_password(request: Request):
|
||||
return templates.TemplateResponse("password-update.html", {"request": request})
|
||||
|
||||
|
||||
@app.post("/checkemailexist")
|
||||
async def checkemailexist(request: Request, request_body: checkemailexistRequest):
|
||||
request_body = request_body.model_dump()
|
||||
if request_body["email"] in reserved_emails:
|
||||
return JSONResponse(content={"exist": True}, status_code=200)
|
||||
if dbmanager.get_user_by_email(request_body["email"]):
|
||||
return JSONResponse(content={"exist": True}, status_code=200)
|
||||
else:
|
||||
return JSONResponse(content={"exist": False}, status_code=200)
|
||||
|
||||
|
||||
@app.post("/checkusernameexist")
|
||||
async def checkusernameexist(request: Request, request_body: checkusernameexistRequest):
|
||||
request_body = request_body.model_dump()
|
||||
if request_body["username"] in reserved_users:
|
||||
return JSONResponse(content={"exist": True}, status_code=200)
|
||||
if dbmanager.get_user_by_name(request_body["username"]):
|
||||
return JSONResponse(content={"exist": True}, status_code=200)
|
||||
else:
|
||||
return JSONResponse(content={"exist": False}, status_code=200)
|
||||
|
||||
|
||||
@app.post("/register", response_class=PlainTextResponse)
|
||||
async def register(request: Request, request_body: Register_Request_Body):
|
||||
try:
|
||||
# check if open register
|
||||
if not open_registration:
|
||||
return "注册功能已关闭,请联系管理员。"
|
||||
|
||||
request_body = request_body.model_dump()
|
||||
|
||||
# verify captcha
|
||||
captcha_verify_response = requests.post(
|
||||
"https://challenges.cloudflare.com/turnstile/v0/siteverify",
|
||||
data={
|
||||
"secret": turnstile_secret_key,
|
||||
"response": request_body["token"],
|
||||
"remoteip": request.client.host,
|
||||
},
|
||||
)
|
||||
captcha_verify_response_json = captcha_verify_response.json()
|
||||
if not captcha_verify_response_json["success"]:
|
||||
return "验证码校验出错,请刷新网页重试,如果问题依旧,请联系管理员。"
|
||||
|
||||
# validate email
|
||||
try:
|
||||
email = request_body["email"]
|
||||
emailinfo = validate_email(email, check_deliverability=False)
|
||||
email = emailinfo.normalized
|
||||
except EmailNotValidError as e:
|
||||
return "邮件地址出错: " + str(e)
|
||||
|
||||
# check if email is reserved
|
||||
if email in reserved_emails:
|
||||
return "该邮件地址为保留地址,请更换。"
|
||||
|
||||
# check if email exists
|
||||
if dbmanager.get_user_by_email(email):
|
||||
return "邮件地址已被注册。"
|
||||
|
||||
# check if username is reserved
|
||||
if request_body["username"] in reserved_users:
|
||||
return "该用户名为保留用户名,请更换。"
|
||||
|
||||
# check if username exists
|
||||
if dbmanager.get_user_by_name(request_body["username"]):
|
||||
return "用户名已被注册。"
|
||||
|
||||
# create user in keycloak
|
||||
try:
|
||||
keycloak_admin.create_user(
|
||||
{
|
||||
"email": email,
|
||||
"username": request_body["username"],
|
||||
"emailVerified": True,
|
||||
"enabled": True,
|
||||
"credentials": [
|
||||
{
|
||||
"type": "password",
|
||||
"value": request_body["password"],
|
||||
"temporary": False,
|
||||
}
|
||||
],
|
||||
"attributes": {
|
||||
"quota": "10G",
|
||||
},
|
||||
"groups": [
|
||||
"网服队员",
|
||||
],
|
||||
}
|
||||
)
|
||||
except KeycloakGetError:
|
||||
return "用户名已被注册,请更改。"
|
||||
|
||||
# use subprocess create user in docker-mailserver
|
||||
try:
|
||||
# docker exec -it dms setup email add <EMAIL ADDRESS> [<PASSWORD>]
|
||||
subprocess.run(
|
||||
[
|
||||
"docker",
|
||||
"exec",
|
||||
"-it",
|
||||
"dms",
|
||||
"setup",
|
||||
"email",
|
||||
"add",
|
||||
email,
|
||||
shellquote(request_body["password"]),
|
||||
],
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(e)
|
||||
return "出错了,请刷新网页重试,如果问题依旧,请联系管理员。"
|
||||
|
||||
except Exception as e:
|
||||
logger.error(e)
|
||||
return "出错了,请刷新网页重试,如果问题依旧,请联系管理员。"
|
||||
|
||||
dbmanager.add_user(request_body["username"], email)
|
||||
return "注册成功。"
|
||||
|
||||
|
||||
@app.post("/passwordreset", response_class=PlainTextResponse)
|
||||
async def passwordreset(request: Request, request_body: Password_Reset_Request_Body):
|
||||
try:
|
||||
request_body = request_body.model_dump()
|
||||
|
||||
# verify captcha
|
||||
captcha_verify_response = requests.post(
|
||||
"https://challenges.cloudflare.com/turnstile/v0/siteverify",
|
||||
data={
|
||||
"secret": turnstile_secret_key,
|
||||
"response": request_body["token"],
|
||||
"remoteip": request.client.host,
|
||||
},
|
||||
)
|
||||
captcha_verify_response_json = captcha_verify_response.json()
|
||||
if not captcha_verify_response_json["success"]:
|
||||
return "验证码校验出错,请刷新网页重试,如果问题依旧,请联系管理员。"
|
||||
|
||||
# validate email
|
||||
try:
|
||||
email = request_body["email"]
|
||||
emailinfo = validate_email(email, check_deliverability=False)
|
||||
email = emailinfo.normalized
|
||||
except EmailNotValidError as e:
|
||||
return "邮件地址出错: " + str(e)
|
||||
|
||||
# check if email exists
|
||||
if not dbmanager.get_user_by_email(email):
|
||||
return "邮件地址不存在。"
|
||||
|
||||
# validate authcode
|
||||
authcode = request_body["authcode"]
|
||||
real_authcode = dbmanager.get_user_seed_by_email(email)
|
||||
if authcode != real_authcode:
|
||||
return "校验码错误,请仔细核对或联系管理员"
|
||||
|
||||
# update user in keycloak
|
||||
try:
|
||||
# get username by email
|
||||
username = dbmanager.get_user_by_email(email)
|
||||
# get user_id by username
|
||||
user_id = keycloak_admin.get_user_id(username)
|
||||
keycloak_admin.set_user_password(
|
||||
user_id=user_id,
|
||||
password=request_body["password"],
|
||||
temporary=False,
|
||||
)
|
||||
|
||||
except KeycloakGetError as e:
|
||||
logger.error(e)
|
||||
return "Keycloak密码更新失败,请联系管理员"
|
||||
|
||||
# use subprocess to update user password in docker-mailserver
|
||||
try:
|
||||
# docker exec -it dms setup email add <EMAIL ADDRESS> [<PASSWORD>]
|
||||
subprocess.run(
|
||||
[
|
||||
"docker",
|
||||
"exec",
|
||||
"-it",
|
||||
"dms",
|
||||
"setup",
|
||||
"email",
|
||||
"update",
|
||||
email,
|
||||
shellquote(request_body["password"]),
|
||||
],
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(e)
|
||||
return "出错了,请刷新网页重试"
|
||||
|
||||
# generate new authcode
|
||||
dbmanager.update_user_seed_by_email(email)
|
||||
|
||||
return "密码修改成功"
|
||||
except Exception as e:
|
||||
logger.error(e)
|
||||
return "出错了,请刷新网页重试"
|
262
templates/index.html
Normal file
262
templates/index.html
Normal file
|
@ -0,0 +1,262 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta
|
||||
name="viewport"
|
||||
content="width=device-width, initial-scale=1, maximum-scale=1, shrink-to-fit=no"
|
||||
/>
|
||||
<meta name="renderer" content="webkit" />
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1" />
|
||||
<link
|
||||
rel="icon"
|
||||
type="image/png"
|
||||
href="{{ url_for('assets', path='logo-removebg.png') }}"
|
||||
/>
|
||||
|
||||
<link rel="stylesheet" href="{{ url_for('assets', path='/mdui.css') }}" />
|
||||
<script src="{{ url_for('assets', path='/mdui.global.js') }}"></script>
|
||||
<script src="{{ url_for('assets', path='/jquery-3.7.1.min.js') }}"></script>
|
||||
<script
|
||||
src="https://challenges.cloudflare.com/turnstile/v0/api.js?onload=_turnstileCb"
|
||||
async
|
||||
defer
|
||||
></script>
|
||||
<style>
|
||||
@media screen and (max-width: 480px) {
|
||||
.center {
|
||||
margin: auto;
|
||||
width: auto;
|
||||
padding: 10px;
|
||||
}
|
||||
}
|
||||
@media screen and (min-width: 480px) {
|
||||
.center {
|
||||
margin: auto;
|
||||
width: 480px;
|
||||
padding: 10px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
<title>网服注册服务</title>
|
||||
</head>
|
||||
<body>
|
||||
<main class="mdui-prose center">
|
||||
<mdui-avatar src="../assets/logo-removebg.png"></mdui-avatar>
|
||||
<h1 style="margin-bottom: 10px; margin-top: 5px">网服注册服务</h1>
|
||||
<mdui-text-field
|
||||
id="username"
|
||||
label="用户名"
|
||||
autofocus="true"
|
||||
style="margin-bottom: 10px"
|
||||
>
|
||||
<span slot="helper" style="color: red" id="username-helper"></span>
|
||||
</mdui-text-field>
|
||||
<mdui-text-field
|
||||
id="email"
|
||||
type="email"
|
||||
label="邮箱"
|
||||
placeholder="xxxx@csuwf.com"
|
||||
>
|
||||
<span slot="helper" style="color: red" id="email-helper"></span>
|
||||
</mdui-text-field>
|
||||
<mdui-text-field
|
||||
id="password"
|
||||
label="密码"
|
||||
toggle-password
|
||||
type="password"
|
||||
>
|
||||
<span slot="helper" style="color: red" id="password-helper"></span>
|
||||
</mdui-text-field>
|
||||
|
||||
<span
|
||||
><a href="./update-password" style="float: right; margin-top: 10px"
|
||||
>重置密码</a
|
||||
></span
|
||||
>
|
||||
|
||||
<div id="turnstile_widgit"></div>
|
||||
<mdui-button
|
||||
id="btn1"
|
||||
variant="tonal"
|
||||
style="border-radius: 5%"
|
||||
onclick="register()"
|
||||
>注册</mdui-button
|
||||
>
|
||||
</main>
|
||||
|
||||
<script>
|
||||
let token = "";
|
||||
function _turnstileCb() {
|
||||
console.log("_turnstileCb called");
|
||||
turnstile.render("#turnstile_widgit", {
|
||||
sitekey: "1x00000000000000000000AA",
|
||||
theme: "light",
|
||||
callback: function (t) {
|
||||
token = t;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
$.ajaxSetup({
|
||||
global: false,
|
||||
method: "POST",
|
||||
});
|
||||
|
||||
const registerBtn = document.getElementById("btn1");
|
||||
|
||||
const usernameElement = document.getElementById("username");
|
||||
usernameElement.addEventListener("change", () => {
|
||||
const usernameHelper = document.getElementById("username-helper");
|
||||
const username = usernameElement.value;
|
||||
if (username !== "") {
|
||||
const promise = $.ajax({
|
||||
url: "/checkusernameexist",
|
||||
method: "POST",
|
||||
contentType: "application/json",
|
||||
data: JSON.stringify({
|
||||
username: username,
|
||||
}),
|
||||
success: function (response) {
|
||||
if (response.exist) {
|
||||
usernameHelper.innerText = "用户名已被注册";
|
||||
} else {
|
||||
usernameHelper.innerText = "该用户名可用";
|
||||
}
|
||||
},
|
||||
});
|
||||
} else {
|
||||
usernameHelper.innerText = "";
|
||||
}
|
||||
});
|
||||
|
||||
const emailElement = document.getElementById("email");
|
||||
const emailRegex = /@csuwf\.com$/;
|
||||
|
||||
emailElement.addEventListener("change", () => {
|
||||
const emailHelper = document.getElementById("email-helper");
|
||||
const email = emailElement.value;
|
||||
if (email === "") {
|
||||
emailHelper.innerText = "";
|
||||
return;
|
||||
}
|
||||
if (email !== "" && !emailRegex.test(email)) {
|
||||
emailHelper.innerText = "邮箱必须以@csuwf.com结尾";
|
||||
} else {
|
||||
// emailHelper.innerText = "";
|
||||
const promise = $.ajax({
|
||||
url: "/checkemailexist",
|
||||
method: "POST",
|
||||
contentType: "application/json",
|
||||
data: JSON.stringify({
|
||||
email: email,
|
||||
}),
|
||||
success: function (response) {
|
||||
if (response.exist) {
|
||||
emailHelper.innerText = "邮箱已被注册";
|
||||
} else {
|
||||
emailHelper.innerText = "该邮箱可用";
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const passwordElement = document.getElementById("password");
|
||||
const passwordRegex = /[&\\;]/;
|
||||
passwordElement.addEventListener("change", () => {
|
||||
const password = passwordElement.value;
|
||||
const passwordHelper = document.getElementById("password-helper");
|
||||
if (password === "") {
|
||||
passwordHelper.innerText = "";
|
||||
return;
|
||||
}
|
||||
if (password !== "" && passwordRegex.test(password)) {
|
||||
passwordHelper.innerText = "密码不能包含(&)或分号(;)或反斜杠(\\)";
|
||||
} else {
|
||||
passwordHelper.innerText = "";
|
||||
}
|
||||
});
|
||||
|
||||
// 注册逻辑
|
||||
async function register() {
|
||||
if (usernameElement.value === "") {
|
||||
mdui.snackbar({
|
||||
message: "用户名不能为空",
|
||||
placement: "top",
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (emailElement.value === "") {
|
||||
mdui.snackbar({
|
||||
message: "邮箱不能为空",
|
||||
placement: "top",
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (passwordElement.value === "") {
|
||||
mdui.snackbar({
|
||||
message: "密码不能为空",
|
||||
placement: "top",
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (emailElement.value !== "" && !emailRegex.test(emailElement.value)) {
|
||||
mdui.snackbar({
|
||||
message: "邮箱必须以@csuwf.com结尾",
|
||||
placement: "top",
|
||||
});
|
||||
return;
|
||||
}
|
||||
// if (token === "") tell user to complete the captcha
|
||||
if (token === "") {
|
||||
mdui.snackbar({
|
||||
message: "请完成人机验证",
|
||||
placement: "top",
|
||||
});
|
||||
return;
|
||||
}
|
||||
registerBtn.setAttribute("loading", "");
|
||||
registerBtn.setAttribute("disabled", "");
|
||||
|
||||
try {
|
||||
const response = await fetch("/register", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
username: usernameElement.value,
|
||||
email: emailElement.value,
|
||||
password: passwordElement.value,
|
||||
token: token,
|
||||
}),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error("HTTP error " + response.status);
|
||||
}
|
||||
const result = await response.text();
|
||||
mdui.alert({
|
||||
headline: "注册结果",
|
||||
description: result,
|
||||
confirmText: "了解",
|
||||
onConfirm: () => {
|
||||
registerBtn.removeAttribute("loading");
|
||||
registerBtn.removeAttribute("disabled");
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
mdui.alert({
|
||||
headline: "注册结果",
|
||||
description: "出错了,请尝试刷新或联系管理员",
|
||||
confirmText: "了解",
|
||||
onConfirm: () => {
|
||||
registerBtn.removeAttribute("loading");
|
||||
registerBtn.removeAttribute("disabled");
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
263
templates/password-update.html
Normal file
263
templates/password-update.html
Normal file
|
@ -0,0 +1,263 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta
|
||||
name="viewport"
|
||||
content="width=device-width, initial-scale=1, maximum-scale=1, shrink-to-fit=no"
|
||||
/>
|
||||
<meta name="renderer" content="webkit" />
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1" />
|
||||
<link
|
||||
rel="icon"
|
||||
type="image/png"
|
||||
href="{{ url_for('assets', path='logo-removebg.png') }}"
|
||||
/>
|
||||
|
||||
<link rel="stylesheet" href="{{ url_for('assets', path='/mdui.css') }}" />
|
||||
<script src="{{ url_for('assets', path='/mdui.global.js') }}"></script>
|
||||
<script src="{{ url_for('assets', path='/jquery-3.7.1.min.js') }}"></script>
|
||||
<script
|
||||
src="https://challenges.cloudflare.com/turnstile/v0/api.js?onload=_turnstileCb"
|
||||
async
|
||||
defer
|
||||
></script>
|
||||
<style>
|
||||
@media screen and (max-width: 480px) {
|
||||
.center {
|
||||
margin: auto;
|
||||
width: auto;
|
||||
padding: 10px;
|
||||
}
|
||||
}
|
||||
@media screen and (min-width: 480px) {
|
||||
.center {
|
||||
margin: auto;
|
||||
width: 480px;
|
||||
padding: 10px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
<title>网服密码重置服务</title>
|
||||
</head>
|
||||
<body>
|
||||
<main class="mdui-prose center">
|
||||
<mdui-avatar src="../assets/logo-removebg.png"></mdui-avatar>
|
||||
<h1 style="margin-bottom: 10px; margin-top: 5px">网服密码重置服务</h1>
|
||||
|
||||
<mdui-text-field
|
||||
id="email"
|
||||
type="email"
|
||||
label="邮箱"
|
||||
autofocus="true"
|
||||
placeholder="xxxx@csuwf.com"
|
||||
>
|
||||
<span slot="helper" style="color: red" id="email-helper"></span>
|
||||
</mdui-text-field>
|
||||
|
||||
<mdui-text-field
|
||||
id="password"
|
||||
label="新密码"
|
||||
toggle-password
|
||||
type="password"
|
||||
>
|
||||
<span slot="helper" style="color: red" id="password-helper"></span>
|
||||
</mdui-text-field>
|
||||
|
||||
<mdui-text-field
|
||||
id="password2"
|
||||
label="确认新密码"
|
||||
toggle-password
|
||||
type="password"
|
||||
></mdui-text-field>
|
||||
|
||||
<mdui-text-field
|
||||
id="authcode"
|
||||
label="校验码"
|
||||
></mdui-text-field>
|
||||
|
||||
<div id="turnstile_widgit"></div>
|
||||
<mdui-button
|
||||
id="btn1"
|
||||
variant="tonal"
|
||||
style="border-radius: 5%"
|
||||
onclick="passwordReset()"
|
||||
>修改密码</mdui-button
|
||||
>
|
||||
</main>
|
||||
|
||||
<script>
|
||||
let token = "";
|
||||
function _turnstileCb() {
|
||||
console.log("_turnstileCb called");
|
||||
turnstile.render("#turnstile_widgit", {
|
||||
sitekey: "1x00000000000000000000AA",
|
||||
theme: "light",
|
||||
callback: function (t) {
|
||||
token = t;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
$.ajaxSetup({
|
||||
global: false,
|
||||
method: "POST",
|
||||
});
|
||||
|
||||
const resetBtn = document.getElementById("btn1");
|
||||
|
||||
const authcodeElement = document.getElementById("authcode");
|
||||
|
||||
const emailElement = document.getElementById("email");
|
||||
const emailRegex = /@csuwf\.com$/;
|
||||
|
||||
emailElement.addEventListener("change", () => {
|
||||
const emailHelper = document.getElementById("email-helper");
|
||||
const email = emailElement.value;
|
||||
if (email === "") {
|
||||
emailHelper.innerText = "";
|
||||
return;
|
||||
}
|
||||
if (email !== "" && !emailRegex.test(email)) {
|
||||
emailHelper.innerText = "网服的邮箱是以@csuwf.com结尾的哦!";
|
||||
} else {
|
||||
// emailHelper.innerText = "";
|
||||
const promise = $.ajax({
|
||||
url: "/checkemailexist",
|
||||
method: "POST",
|
||||
contentType: "application/json",
|
||||
data: JSON.stringify({
|
||||
email: email,
|
||||
}),
|
||||
success: function (response) {
|
||||
if (response.exist) {
|
||||
emailHelper.innerText = "";
|
||||
} else {
|
||||
emailHelper.innerText = "该邮箱不存在";
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const passwordElement = document.getElementById("password");
|
||||
const passwordRegex = /[&\\;]/;
|
||||
passwordElement.addEventListener("change", () => {
|
||||
const password = passwordElement.value;
|
||||
const passwordHelper = document.getElementById("password-helper");
|
||||
if (password === "") {
|
||||
passwordHelper.innerText = "";
|
||||
return;
|
||||
}
|
||||
if (password !== "" && passwordRegex.test(password)) {
|
||||
passwordHelper.innerText = "密码不能包含(&)或分号(;)或反斜杠(\\)";
|
||||
} else {
|
||||
passwordHelper.innerText = "";
|
||||
}
|
||||
});
|
||||
|
||||
const password2Element = document.getElementById("password2");
|
||||
password2Element.addEventListener("change", () => {
|
||||
const password = passwordElement.value;
|
||||
const password2 = password2Element.value;
|
||||
const passwordHelper = document.getElementById("password-helper");
|
||||
if (password2 === "") {
|
||||
passwordHelper.innerText = "";
|
||||
return;
|
||||
}
|
||||
if (password !== password2) {
|
||||
passwordHelper.innerText = "两次输入的密码不一致";
|
||||
} else {
|
||||
passwordHelper.innerText = "";
|
||||
}
|
||||
});
|
||||
|
||||
// 密码重置逻辑
|
||||
async function passwordReset() {
|
||||
if (emailElement.value === "") {
|
||||
mdui.snackbar({
|
||||
message: "邮箱不能为空",
|
||||
placement: "top",
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (passwordElement.value === "") {
|
||||
mdui.snackbar({
|
||||
message: "密码不能为空",
|
||||
placement: "top",
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (emailElement.value !== "" && !emailRegex.test(emailElement.value)) {
|
||||
mdui.snackbar({
|
||||
message: "邮箱必须以@csuwf.com结尾",
|
||||
placement: "top",
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (passwordElement.value !== password2Element.value) {
|
||||
mdui.snackbar({
|
||||
message: "两次输入的密码不一致",
|
||||
placement: "top",
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (authcodeElement.value === "") {
|
||||
mdui.snackbar({
|
||||
message: "校验码不能为空",
|
||||
placement: "top",
|
||||
});
|
||||
return;
|
||||
}
|
||||
// if (token === "") tell user to complete the captcha
|
||||
if (token === "") {
|
||||
mdui.snackbar({
|
||||
message: "请完成人机验证",
|
||||
placement: "top",
|
||||
});
|
||||
return;
|
||||
}
|
||||
resetBtn.setAttribute("loading", "");
|
||||
resetBtn.setAttribute("disabled", "");
|
||||
|
||||
try {
|
||||
const response = await fetch("/passwordreset", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
email: emailElement.value,
|
||||
password: passwordElement.value,
|
||||
authcode: authcodeElement.value,
|
||||
token: token,
|
||||
}),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error("HTTP error " + response.status);
|
||||
}
|
||||
const result = await response.text();
|
||||
mdui.alert({
|
||||
headline: "重置结果",
|
||||
description: result,
|
||||
confirmText: "了解",
|
||||
onConfirm: () => {
|
||||
resetBtn.removeAttribute("loading");
|
||||
resetBtn.removeAttribute("disabled");
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
mdui.alert({
|
||||
headline: "重置结果",
|
||||
description: "出错了,请重试或联系管理员",
|
||||
confirmText: "了解",
|
||||
onConfirm: () => {
|
||||
resetBtn.removeAttribute("loading");
|
||||
resetBtn.removeAttribute("disabled");
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
Loading…
Reference in a new issue