Skip to content
Projects
Groups
Snippets
Help
Loading...
Help
Submit feedback
Sign in / Register
Toggle navigation
S
sumeiqiao
Project
Project
Details
Activity
Releases
Cycle Analytics
Repository
Repository
Files
Commits
Branches
Tags
Contributors
Graph
Compare
Charts
Issues
0
Issues
0
List
Board
Labels
Milestones
Merge Requests
0
Merge Requests
0
CI / CD
CI / CD
Pipelines
Jobs
Schedules
Charts
Wiki
Wiki
Snippets
Snippets
Members
Members
Collapse sidebar
Close sidebar
Activity
Graph
Charts
Create a new issue
Jobs
Commits
Issue Boards
Open sidebar
xuchentao
sumeiqiao
Commits
a7af7cdf
Commit
a7af7cdf
authored
Jul 27, 2026
by
xuchentao
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
feat: add captcha to admin login
parent
5b53d77f
Pipeline
#415
passed with stages
in 17 seconds
Changes
4
Pipelines
1
Show whitespace changes
Inline
Side-by-side
Showing
4 changed files
with
116 additions
and
15 deletions
+116
-15
app.js
public/admin/app.js
+20
-3
style.css
public/admin/style.css
+3
-0
cms-api.ts
src/lib/cms-api.ts
+87
-11
index.astro
src/pages/admin/index.astro
+6
-1
No files found.
public/admin/app.js
View file @
a7af7cdf
...
...
@@ -22,6 +22,17 @@ async function api(path, options = {}) {
const
show
=
(
selector
)
=>
$
(
selector
).
classList
.
remove
(
"hidden"
);
const
hide
=
(
selector
)
=>
$
(
selector
).
classList
.
add
(
"hidden"
);
const
refreshCaptcha
=
()
=>
{
$
(
"#captcha-image"
).
src
=
`
${
API_BASE
}
/captcha?t=
${
Date
.
now
()}
`
;
$
(
"#captcha"
).
value
=
""
;
};
function
showLogin
()
{
show
(
"#login"
);
hide
(
"#app"
);
refreshCaptcha
();
$
(
"#password"
).
focus
();
}
async
function
boot
()
{
const
session
=
await
api
(
"/session"
).
catch
(()
=>
({
authed
:
false
}));
...
...
@@ -30,19 +41,25 @@ async function boot() {
await
loadCategories
();
loadArticles
();
}
else
{
show
(
"#login"
);
hide
(
"#app"
);
}
else
showLogin
();
}
$
(
"#captcha-image"
).
addEventListener
(
"click"
,
refreshCaptcha
);
$
(
"#login-form"
).
addEventListener
(
"submit"
,
async
(
event
)
=>
{
event
.
preventDefault
();
$
(
"#login-error"
).
textContent
=
""
;
try
{
await
api
(
"/session"
,
{
method
:
"POST"
,
body
:
{
password
:
$
(
"#password"
).
value
}
});
await
api
(
"/session"
,
{
method
:
"POST"
,
body
:
{
password
:
$
(
"#password"
).
value
,
captcha
:
$
(
"#captcha"
).
value
}
});
$
(
"#password"
).
value
=
""
;
$
(
"#captcha"
).
value
=
""
;
hide
(
"#login"
);
show
(
"#app"
);
await
loadCategories
();
loadArticles
();
}
catch
(
error
)
{
$
(
"#login-error"
).
textContent
=
error
.
message
;
}
}
catch
(
error
)
{
$
(
"#login-error"
).
textContent
=
error
.
message
;
refreshCaptcha
();
}
});
$
(
"#logout"
).
addEventListener
(
"click"
,
async
()
=>
{
await
api
(
"/session"
,
{
method
:
"DELETE"
});
location
.
reload
();
});
...
...
public/admin/style.css
View file @
a7af7cdf
...
...
@@ -28,6 +28,9 @@ button, .button { min-height: 40px; padding: 0 17px; display: inline-flex; align
.login-card
h1
{
margin
:
0
;
font-size
:
30px
;
}
.login-card
p
{
margin
:
-8px
0
4px
;
color
:
var
(
--muted
);
}
.login-card
label
,
.field-panel
label
,
.field-label
{
display
:
grid
;
gap
:
7px
;
color
:
var
(
--muted
);
font-size
:
13px
;
}
.captcha-row
{
display
:
flex
;
align-items
:
stretch
;
gap
:
10px
;
}
.captcha-row
input
{
min-width
:
0
;
flex
:
1
;
text-transform
:
uppercase
;
}
#captcha-image
{
width
:
132px
;
height
:
46px
;
flex
:
0
0
auto
;
cursor
:
pointer
;
background
:
var
(
--light
);
border
:
1px
solid
var
(
--border
);
border-radius
:
9px
;
}
input
,
textarea
,
select
{
width
:
100%
;
padding
:
11px
13px
;
color
:
var
(
--text
);
background
:
white
;
border
:
1px
solid
var
(
--border
);
border-radius
:
9px
;
}
input
:focus
,
textarea
:focus
,
select
:focus
{
outline
:
2px
solid
rgba
(
22
,
174
,
156
,
.14
);
border-color
:
var
(
--primary
);
}
.topbar
{
min-height
:
68px
;
padding
:
12px
28px
;
position
:
sticky
;
top
:
0
;
z-index
:
10
;
display
:
flex
;
align-items
:
center
;
justify-content
:
space-between
;
gap
:
22px
;
color
:
white
;
background
:
#101413
;
}
...
...
src/lib/cms-api.ts
View file @
a7af7cdf
...
...
@@ -25,7 +25,12 @@ import { buildSiteAtomic, tryAcquireSiteBuildLock, withSiteBuildLock } from "../
const
PASSWORD
=
process
.
env
.
CMS_PASSWORD
||
"admin"
;
const
SECRET
=
process
.
env
.
CMS_SECRET
||
crypto
.
randomBytes
(
32
).
toString
(
"hex"
);
const
API_KEY
=
process
.
env
.
CMS_API_KEY
||
""
;
const
loginAttempts
=
new
Map
<
string
,
{
count
:
number
;
until
:
number
}
>
();
const
CAPTCHA_TTL
=
5
*
60
*
1000
;
const
CAPTCHA_CHARS
=
"ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
;
const
MAX_LOGIN_FAILURES
=
5
;
const
LOGIN_FAILURE_WINDOW
=
10
*
60
*
1000
;
const
LOGIN_BLOCK_DURATION
=
10
*
60
*
1000
;
const
loginAttempts
=
new
Map
<
string
,
{
count
:
number
;
first
:
number
;
blockedUntil
:
number
}
>
();
type
BuildState
=
{
status
:
"idle"
|
"building"
|
"success"
|
"error"
;
ok
:
boolean
|
null
;
...
...
@@ -66,24 +71,75 @@ async function bodyOf(request: Request): Promise<Record<string, unknown>> {
try
{
return
await
request
.
json
();
}
catch
{
return
{};
}
}
function
cookieHeader
(
request
:
Request
,
value
:
string
,
maxAge
:
number
):
string
{
function
cookieHeader
(
request
:
Request
,
name
:
string
,
value
:
string
,
maxAge
:
number
):
string
{
const
forwarded
=
request
.
headers
.
get
(
"x-forwarded-proto"
);
const
secure
=
new
URL
(
request
.
url
).
protocol
===
"https:"
||
forwarded
===
"https"
;
return
`
cms_session
=
${
value
}
; HttpOnly; SameSite=Strict; Path=/; Max-Age=
${
maxAge
}${
secure
?
"; Secure"
:
""
}
`
;
return
`
${
name
}
=
${
value
}
; HttpOnly; SameSite=Strict; Path=/; Max-Age=
${
maxAge
}${
secure
?
"; Secure"
:
""
}
`
;
}
function
login
Allowed
(
ip
:
string
):
boolean
{
function
login
BlockedFor
(
ip
:
string
):
number
{
const
state
=
loginAttempts
.
get
(
ip
);
return
!
state
||
state
.
until
<
Date
.
now
();
return
state
?.
blockedUntil
&&
state
.
blockedUntil
>
Date
.
now
()
?
Math
.
ceil
((
state
.
blockedUntil
-
Date
.
now
())
/
1000
)
:
0
;
}
function
loginFailed
(
ip
:
string
):
void
{
const
state
=
loginAttempts
.
get
(
ip
)
||
{
count
:
0
,
until
:
0
};
const
now
=
Date
.
now
();
let
state
=
loginAttempts
.
get
(
ip
);
if
(
!
state
||
now
-
state
.
first
>
LOGIN_FAILURE_WINDOW
)
{
state
=
{
count
:
0
,
first
:
now
,
blockedUntil
:
0
};
}
state
.
count
+=
1
;
if
(
state
.
count
>=
5
)
state
.
until
=
Date
.
now
()
+
10
*
60
*
1000
;
if
(
state
.
count
>=
MAX_LOGIN_FAILURES
)
state
.
blockedUntil
=
now
+
LOGIN_BLOCK_DURATION
;
loginAttempts
.
set
(
ip
,
state
);
}
function
randomCaptcha
(
length
=
4
):
string
{
let
code
=
""
;
for
(
let
index
=
0
;
index
<
length
;
index
+=
1
)
{
code
+=
CAPTCHA_CHARS
[
crypto
.
randomInt
(
CAPTCHA_CHARS
.
length
)];
}
return
code
;
}
function
captchaMac
(
code
:
unknown
,
expiresAt
:
number
):
string
{
return
crypto
.
createHmac
(
"sha256"
,
SECRET
)
.
update
(
`
${
String
(
code
).
toUpperCase
()}
|
${
expiresAt
}
`
)
.
digest
(
"hex"
);
}
function
captchaValid
(
request
:
Request
,
answer
:
unknown
):
boolean
{
const
value
=
cookies
(
request
).
cms_captcha
;
if
(
!
value
||
!
answer
)
return
false
;
const
separator
=
value
.
lastIndexOf
(
"."
);
const
expiresAt
=
Number
(
value
.
slice
(
0
,
separator
));
const
mac
=
value
.
slice
(
separator
+
1
);
return
Boolean
(
expiresAt
&&
Date
.
now
()
<=
expiresAt
&&
safeEqual
(
mac
,
captchaMac
(
answer
,
expiresAt
)));
}
function
captchaSvg
(
code
:
string
):
string
{
const
width
=
132
;
const
height
=
46
;
const
colors
=
[
"#11897b"
,
"#0e727c"
,
"#16ae9c"
,
"#243532"
];
const
randomBetween
=
(
min
:
number
,
max
:
number
)
=>
crypto
.
randomInt
(
min
,
max
+
1
);
const
pick
=
<
T
>
(
items
:
T
[]):
T
=>
items
[
crypto
.
randomInt
(
items
.
length
)];
let
content
=
`<rect width="
${
width
}
" height="
${
height
}
" fill="#e4efea"/>`
;
for
(
let
index
=
0
;
index
<
4
;
index
+=
1
)
{
content
+=
`<line x1="
${
randomBetween
(
0
,
width
)}
" y1="
${
randomBetween
(
0
,
height
)}
" x2="
${
randomBetween
(
0
,
width
)}
" y2="
${
randomBetween
(
0
,
height
)}
" stroke="
${
pick
(
colors
)}
" stroke-width="1" opacity="0.45"/>`
;
}
[...
code
].
forEach
((
character
,
index
)
=>
{
const
x
=
16
+
index
*
28
+
randomBetween
(
-
3
,
3
);
const
y
=
31
+
randomBetween
(
-
4
,
4
);
const
rotation
=
randomBetween
(
-
26
,
26
);
content
+=
`<text x="
${
x
}
" y="
${
y
}
" font-family="Georgia,serif" font-weight="700" font-size="
${
randomBetween
(
24
,
30
)}
" fill="
${
pick
(
colors
)}
" transform="rotate(
${
rotation
}
${
x
}
${
y
}
)">
${
character
}
</text>`
;
});
for
(
let
index
=
0
;
index
<
26
;
index
+=
1
)
{
content
+=
`<circle cx="
${
randomBetween
(
0
,
width
)}
" cy="
${
randomBetween
(
0
,
height
)}
" r="1" fill="
${
pick
(
colors
)}
" opacity="0.5"/>`
;
}
return
`<svg xmlns="http://www.w3.org/2000/svg" width="
${
width
}
" height="
${
height
}
" viewBox="0 0
${
width
}
${
height
}
">
${
content
}
</svg>`
;
}
function
requireSlug
(
value
:
string
|
undefined
):
string
{
const
slug
=
safeSlug
(
value
);
if
(
!
slug
)
throw
new
StoreError
(
"网址标识不合法"
);
...
...
@@ -141,19 +197,39 @@ export async function handleCmsApi(request: Request, routeValue: string, clientA
const
method
=
request
.
method
.
toUpperCase
();
try
{
if
(
route
===
"captcha"
&&
method
===
"GET"
)
{
const
code
=
randomCaptcha
();
const
expiresAt
=
Date
.
now
()
+
CAPTCHA_TTL
;
return
new
Response
(
captchaSvg
(
code
),
{
headers
:
{
"Cache-Control"
:
"no-store"
,
"Content-Type"
:
"image/svg+xml; charset=utf-8"
,
"Set-Cookie"
:
cookieHeader
(
request
,
"cms_captcha"
,
`
${
expiresAt
}
.
${
captchaMac
(
code
,
expiresAt
)}
`
,
CAPTCHA_TTL
/
1000
),
},
});
}
if
(
route
===
"session"
)
{
if
(
method
===
"GET"
)
return
json
({
authed
:
authed
(
request
)
});
if
(
method
===
"POST"
)
{
if
(
!
loginAllowed
(
clientAddress
))
throw
new
StoreError
(
"登录尝试过于频繁,请稍后再试"
,
429
);
const
blockedFor
=
loginBlockedFor
(
clientAddress
);
if
(
blockedFor
)
return
json
({
error
:
`尝试过于频繁,请
${
Math
.
ceil
(
blockedFor
/
60
)}
分钟后再试`
},
429
);
const
body
=
await
bodyOf
(
request
);
const
clearCaptcha
=
cookieHeader
(
request
,
"cms_captcha"
,
""
,
0
);
if
(
!
captchaValid
(
request
,
body
.
captcha
))
{
return
json
({
error
:
"验证码错误或已过期"
},
400
,
{
"Set-Cookie"
:
clearCaptcha
});
}
if
(
!
safeEqual
(
body
.
password
||
""
,
PASSWORD
))
{
loginFailed
(
clientAddress
);
throw
new
StoreError
(
"管理密码错误"
,
401
);
return
json
({
error
:
"管理密码错误"
},
401
,
{
"Set-Cookie"
:
clearCaptcha
}
);
}
loginAttempts
.
delete
(
clientAddress
);
return
json
({
ok
:
true
},
200
,
{
"Set-Cookie"
:
cookieHeader
(
request
,
sessionToken
(),
86400
)
});
const
headers
=
new
Headers
();
headers
.
append
(
"Set-Cookie"
,
clearCaptcha
);
headers
.
append
(
"Set-Cookie"
,
cookieHeader
(
request
,
"cms_session"
,
sessionToken
(),
86400
));
return
json
({
ok
:
true
},
200
,
headers
);
}
if
(
method
===
"DELETE"
)
return
json
({
ok
:
true
},
200
,
{
"Set-Cookie"
:
cookieHeader
(
request
,
""
,
0
)
});
if
(
method
===
"DELETE"
)
return
json
({
ok
:
true
},
200
,
{
"Set-Cookie"
:
cookieHeader
(
request
,
"
cms_session"
,
"
"
,
0
)
});
}
if
(
!
authed
(
request
))
throw
new
StoreError
(
"请先登录文章后台"
,
401
);
...
...
src/pages/admin/index.astro
View file @
a7af7cdf
...
...
@@ -12,8 +12,13 @@
<form
id=
"login-form"
class=
"login-card"
>
<div
class=
"brand-mark"
>
塑美俏
</div>
<h1>
健康资讯后台
</h1>
<p>
输入管理密码,管理官网文章内容。
</p>
<p>
输入管理密码
和图片验证码
,管理官网文章内容。
</p>
<label>
管理密码
<input
id=
"password"
type=
"password"
autocomplete=
"current-password"
required
/></label>
<label>
图片验证码
</label>
<div
class=
"captcha-row"
>
<input
id=
"captcha"
autocomplete=
"off"
maxlength=
"4"
inputmode=
"text"
aria-label=
"图片验证码"
required
/>
<img
id=
"captcha-image"
alt=
"图片验证码,点击可更换"
title=
"看不清?点击换一张"
/>
</div>
<button
class=
"primary"
type=
"submit"
>
登录后台
</button>
<div
id=
"login-error"
class=
"error"
></div>
</form>
...
...
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment