feat: Add params validate

This commit is contained in:
roffy3051 2024-10-20 08:29:58 +08:00
parent cc1881cc4f
commit 77cac2c504
10 changed files with 316 additions and 93 deletions

View File

@ -84,4 +84,4 @@ Glitch can use `.env` file, [documentation](https://help.glitch.com/hc/en-us/art
## License ## License
[MIT License](./LICENSE) [MIT License](./LICENSE), excluding all themes

BIN
assets/img/back-to-top.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

View File

@ -34,6 +34,69 @@ h2, h3, h4, h5 {
margin-bottom: .6em; margin-bottom: .6em;
} }
.back-to-top {
position: fixed;
z-index: 2;
right: -108px;
bottom: 0;
width: 108px;
height: 150px;
background: url('./img/back-to-top.png?v=1') no-repeat 0 0; /* artwork from https://www.pixiv.net/artworks/83996495 */
background-size: 108px 450px;
opacity: 0.6;
transition: opacity 0.3s, right 0.8s;
cursor: pointer;
}
.back-to-top:hover {
background-position: 0 -150px;
opacity: 1;
}
.back-to-top.load {
right: 0;
}
.back-to-top.ani-leave {
background-position: 0 -150px;
animation: ani-leave 390ms ease-in-out;
animation-fill-mode: forwards;
}
.back-to-top.leaved {
pointer-events: none;
background: none;
transition: none;
}
.back-to-top.ending {
pointer-events: none;
}
.back-to-top.ending::after {
opacity: 1;
transition-delay: 0.35s;
}
.back-to-top::after {
content: '';
position: fixed;
z-index: 2;
right: 0;
bottom: 0;
width: 108px;
height: 150px;
background: url('./img/back-to-top.png?v=1') no-repeat 0 0;
background-size: 108px 450px;
background-position: 0 -300px;
transition: opacity 0.3s;
opacity: 0;
pointer-events: none;
}
@keyframes ani-leave {
0% {
transform: translateX(0);
}
100% {
transform: translateX(108px);
}
}
@media screen and (max-width: 900px) { @media screen and (max-width: 900px) {
iframe { iframe {
display: none; display: none;

104
index.js
View File

@ -3,9 +3,12 @@
const config = require("config-yml"); const config = require("config-yml");
const express = require("express"); const express = require("express");
const compression = require("compression"); const compression = require("compression");
const { z } = require("zod");
const db = require("./db"); const db = require("./db");
const themify = require("./utils/themify"); const { themeList, getCountImage } = require("./utils/themify");
const { ZodValid } = require("./utils/zod");
const { randomArray } = require("./utils");
const app = express(); const app = express();
@ -15,56 +18,65 @@ app.set("view engine", "pug");
app.get('/', (req, res) => { app.get('/', (req, res) => {
const site = config.app.site || `${req.protocol}://${req.get('host')}` const site = config.app.site || `${req.protocol}://${req.get('host')}`
res.render('index', { site }) res.render('index', {
site,
themeList,
})
}); });
// get the image // get the image
app.get(["/@:name", "/get/@:name"], async (req, res) => { app.get(["/@:name", "/get/@:name"],
const { name } = req.params; ZodValid({
const { theme = "moebooru", padding = 7, pixelated = '1', darkmode = 'auto' } = req.query; params: z.object({
const isPixelated = pixelated === '1'; name: z.string().max(32),
}),
query: z.object({
theme: z.string().default("moebooru"),
padding: z.coerce.number().min(0).max(32).default(7),
offset: z.coerce.number().min(-500).max(500).default(0),
scale: z.coerce.number().min(0.1).max(2).default(1),
pixelated: z.enum(["0", "1"]).default("1"),
darkmode: z.enum(["0", "1", "auto"]).default("auto")
})
}),
async (req, res) => {
const { name } = req.params;
let { theme = "moebooru", ...rest } = req.query;
if (name.length > 32) { // This helps with GitHub's image cache
res.status(400).send("name too long"); res.set({
return; "content-type": "image/svg+xml",
"cache-control": "max-age=0, no-cache, no-store, must-revalidate",
});
const data = await getCountByName(name);
if (name === "demo") {
res.set("cache-control", "max-age=31536000");
}
if (theme === "random") {
theme = randomArray(Object.keys(themeList));
}
// Send the generated SVG as the result
const renderSvg = getCountImage({
count: data.num,
theme,
...rest
});
res.send(renderSvg);
console.log(
data,
`theme: ${theme}`,
`ip: ${req.headers['x-forwarded-for'] || req.connection.remoteAddress}`,
`ref: ${req.get("Referrer") || null}`,
`ua: ${req.get("User-Agent") || null}`
);
} }
);
if (padding > 32) {
res.status(400).send("padding too long");
return;
}
// This helps with GitHub's image cache
res.set({
"content-type": "image/svg+xml",
"cache-control": "max-age=0, no-cache, no-store, must-revalidate",
});
const data = await getCountByName(name);
if (name === "demo") {
res.set("cache-control", "max-age=31536000");
}
// Send the generated SVG as the result
const renderSvg = themify.getCountImage({
count: data.num,
theme,
padding,
darkmode,
pixelated: isPixelated
});
res.send(renderSvg);
console.log(
data,
`theme: ${theme}`,
`ip: ${req.headers['x-forwarded-for'] || req.connection.remoteAddress}`,
`ref: ${req.get("Referrer") || null}`,
`ua: ${req.get("User-Agent") || null}`
);
});
// JSON record // JSON record
app.get("/record/@:name", async (req, res) => { app.get("/record/@:name", async (req, res) => {

View File

@ -18,6 +18,10 @@
"image-size": "^0.8.3", "image-size": "^0.8.3",
"mime-types": "^2.1.27", "mime-types": "^2.1.27",
"mongoose": "^5.9.28", "mongoose": "^5.9.28",
"pug": "^3.0.0" "pug": "^3.0.0",
"zod": "^3.23.8"
},
"engines": {
"node": "16.x"
} }
} }

7
pnpm-lock.yaml generated
View File

@ -29,6 +29,9 @@ dependencies:
pug: pug:
specifier: ^3.0.0 specifier: ^3.0.0
version: 3.0.3 version: 3.0.3
zod:
specifier: ^3.23.8
version: 3.23.8
packages: packages:
@ -1665,3 +1668,7 @@ packages:
y18n: 3.2.2 y18n: 3.2.2
yargs-parser: 2.4.1 yargs-parser: 2.4.1
dev: false dev: false
/zod@3.23.8:
resolution: {integrity: sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g==}
dev: false

5
utils/index.js Normal file
View File

@ -0,0 +1,5 @@
module.exports = {
randomArray: (arr) => {
return arr[Math.floor(Math.random() * arr.length)]
},
}

View File

@ -14,10 +14,10 @@ fs.readdirSync(themePath).forEach(theme => {
const imgList = fs.readdirSync(path.resolve(themePath, theme)) const imgList = fs.readdirSync(path.resolve(themePath, theme))
imgList.forEach(img => { imgList.forEach(img => {
const imgPath = path.resolve(themePath, theme, img) const imgPath = path.resolve(themePath, theme, img)
const name = path.parse(img).name const num = path.parse(img).name
const { width, height } = sizeOf(imgPath) const { width, height } = sizeOf(imgPath)
themeList[theme][name] = { themeList[theme][num] = {
width, width,
height, height,
data: convertToDatauri(imgPath) data: convertToDatauri(imgPath)
@ -32,8 +32,12 @@ function convertToDatauri(path) {
return `data:${mime};base64,${base64}` return `data:${mime};base64,${base64}`
} }
function getCountImage({ count, theme = 'moebooru', padding = 7, pixelated = true, darkmode = 'auto' }) { function getCountImage(params) {
let { count, theme = 'moebooru', padding = 7, offset = 0, scale = 1, pixelated = '1', darkmode = 'auto' } = params
if (!(theme in themeList)) theme = 'moebooru' if (!(theme in themeList)) theme = 'moebooru'
padding = parseInt(padding, 10)
offset = parseInt(offset, 10)
// This is not the greatest way for generating an SVG but it'll do for now // This is not the greatest way for generating an SVG but it'll do for now
const countArray = count.toString().padStart(padding, '0').split('') const countArray = count.toString().padStart(padding, '0').split('')
@ -42,7 +46,9 @@ function getCountImage({ count, theme = 'moebooru', padding = 7, pixelated = tru
let x = 0, y = 0 let x = 0, y = 0
const defs = uniqueChar.reduce((ret, cur) => { const defs = uniqueChar.reduce((ret, cur) => {
const { width, height, data } = themeList[theme][cur] let { width, height, data } = themeList[theme][cur]
width *= scale
height *= scale
if (height > y) y = height if (height > y) y = height
@ -53,19 +59,23 @@ function getCountImage({ count, theme = 'moebooru', padding = 7, pixelated = tru
}, '') }, '')
const parts = countArray.reduce((ret, cur) => { const parts = countArray.reduce((ret, cur) => {
const { width } = themeList[theme][cur] let { width } = themeList[theme][cur]
width *= scale
const image = `${ret} const image = `${ret}
<use x="${x}" xlink:href="#${cur}" />` <use x="${x}" xlink:href="#${cur}" />`
x += width x += width + offset
return image return image
}, '') }, '')
// Fix the last image offset
x -= offset
const style = ` const style = `
svg { svg {
${pixelated ? 'image-rendering: pixelated;' : ''} ${pixelated === '1' ? 'image-rendering: pixelated;' : ''}
${darkmode === '1' ? 'filter: brightness(.6);' : ''} ${darkmode === '1' ? 'filter: brightness(.6);' : ''}
} }
${darkmode === 'auto' ? `@media (prefers-color-scheme: dark) { svg { filter: brightness(.6); } }` : ''} ${darkmode === 'auto' ? `@media (prefers-color-scheme: dark) { svg { filter: brightness(.6); } }` : ''}
@ -85,5 +95,6 @@ function getCountImage({ count, theme = 'moebooru', padding = 7, pixelated = tru
} }
module.exports = { module.exports = {
themeList,
getCountImage getCountImage
} }

50
utils/zod.js Normal file
View File

@ -0,0 +1,50 @@
function parseError(error) {
const err = JSON.parse(error)[0];
return {
code: 400,
message: `The field \`${err.path[0]}\` is invalid. ${err.message}`,
}
}
module.exports = {
ZodValid: ({ headers, params, query, body }) => {
const handler = (req, res, next) => {
if (headers) {
const result = headers.safeParse(req.headers);
if (!result.success) {
res.status(400).send(parseError(result.error));
return;
}
}
if (params) {
const result = params.safeParse(req.params);
if (!result.success) {
res.status(400).send(parseError(result.error));
return;
}
}
if (query) {
const result = query.safeParse(req.query);
if (!result.success) {
res.status(400).send(parseError(result.error));
return;
}
}
if (body) {
const result = body.safeParse(req.body);
if (!result.success) {
res.status(400).send(parseError(result.error));
return;
}
}
next();
}
return handler
}
}

View File

@ -35,7 +35,7 @@ html
h3 How to use h3 How to use
p Set a unique id for your counter, replace p Set a unique id for your counter, replace
code :name code :name
| in the url, that's all. | in the url, That's it!
h5 SVG address h5 SVG address
code #{site}/@:name code #{site}/@:name
@ -47,24 +47,15 @@ html
code ![:name](#{site}/@:name) code ![:name](#{site}/@:name)
h5 e.g. h5 e.g.
<img src="#{site}/@index" alt="Moe Count!" /> <img src="#{site}/@index" alt="Moe Counter!" />
details#themes details#themes(style='margin-top: 2em;')
summary#more_theme(onclick='_evt_push("click", "normal", "more_theme")') summary#more_theme(onclick='_evt_push("click", "normal", "more_theme")')
h3(style='display: inline-block; margin: 0; cursor: pointer;') More theme✨ h3(style='display: inline-block; margin: 0; cursor: pointer;') More theme✨
p(style='margin: 0;') Just use the query parameters <code>theme</code>, like this: <code>#{site}/@:name?theme=moebooru</code> p(style='margin: 0;') Just use the query parameters <code>theme</code>, like this: <code>#{site}/@:name?theme=moebooru</code>
h5 asoul each theme in Object.keys(themeList)
<img src="#{site}/@demo?theme=asoul" alt="A-SOUL" /> h5 #{theme}
h5 moebooru <img src="#{site}/@demo?theme=#{theme}" alt="#{theme}" />
<img src="#{site}/@demo?theme=moebooru" alt="Moebooru" />
h5 moebooru-h
<img src="#{site}/@demo?theme=moebooru-h" alt="Moebooru-Hentai" />
h5 rule34
<img src="#{site}/@demo?theme=rule34" alt="Rule34" />
h5 gelbooru
<img src="#{site}/@demo?theme=gelbooru" alt="Gelbooru" />
h5 gelbooru-h
<img src="#{site}/@demo?theme=gelbooru-h" alt="Gelbooru-Hentai" />
h3 Credits h3 Credits
ul ul
@ -74,14 +65,13 @@ html
a(href='https://space.bilibili.com/703007996', target='_blank', title='A-SOUL_Official') A-SOUL a(href='https://space.bilibili.com/703007996', target='_blank', title='A-SOUL_Official') A-SOUL
li li
a(href='https://github.com/moebooru/moebooru', target='_blank', rel='nofollow') moebooru a(href='https://github.com/moebooru/moebooru', target='_blank', rel='nofollow') moebooru
li
a(href='javascript:alert("!!! NSFW LINK !!!\\nPlease enter the url manually")') rule34.xxx
| NSFW
li li
a(href='javascript:alert("!!! NSFW LINK !!!\\nPlease enter the url manually")') gelbooru.com a(href='javascript:alert("!!! NSFW LINK !!!\\nPlease enter the url manually")') gelbooru.com
| NSFW | NSFW
li li
a(href='https://icons8.com/icon/80355/star', target='_blank', rel='nofollow') Icons8 a(href='https://icons8.com/icon/80355/star', target='_blank', rel='nofollow') Icons8
span
i And all booru site...
h3 Tool h3 Tool
.tool .tool
@ -89,37 +79,58 @@ html
thead thead
tr tr
th Param th Param
th Description
th Value th Value
tbody tbody
tr tr
td td
code name code name
td Unique counter name
td td
input#name(type='text', placeholder=':name') input#name(type='text', placeholder=':name')
tr tr
td td
code theme code theme
td Select a counter image theme, default is
code moebooru
td td
select#theme select#theme
option(value='asoul') asoul option(value="random", selected) * random
option(value='moebooru') moebooru each theme in Object.keys(themeList)
option(value='moebooru-h') moebooru-h <option value="#{theme}">#{theme}</option>
option(value='rule34') rule34
option(value='gelbooru') gelbooru
option(value='gelbooru-h') gelbooru-h
tr
td
code pixelated
td
input#pixelated(type='checkbox', checked, style='margin: .5rem .75rem;')
tr tr
td td
code padding code padding
td Set the minimum length, between 1-32, default is
code 7
td td
input#padding(type='number', value='7', min='1', max='32', step='1', oninput='this.value = this.value.replace(/[^0-9]/g, "")') input#padding(type='number', value='7', min='1', max='32', step='1', oninput='this.value = this.value.replace(/[^0-9]/g, "")')
tr
td
code offset
td Set the offset pixel value, between -500-500, default is
code 0
td
input#offset(type='number', value='0', min='-500', max='500', step='1', oninput='this.value = this.value.replace(/[^0-9|\-]/g, "")')
tr
td
code scale
td Set the image scale, between 0.1-2, default is
code 1
td
input#scale(type='number', value='1', min='0.1', max='2', step='0.1', oninput='this.value = this.value.replace(/[^0-9|\.]/g, "")')
tr
td
code pixelated
td Enable pixelated mode, Enum 0/1, default is
code 1
td
input#pixelated(type='checkbox', checked, style='margin: .5rem .75rem;')
tr tr
td td
code darkmode code darkmode
td Enable dark mode, Enum 0/1/auto, default is
code auto
td td
select#darkmode(name="darkmode") select#darkmode(name="darkmode")
option(value="auto", selected) auto option(value="auto", selected) auto
@ -132,7 +143,13 @@ html
code#code(style='visibility: hidden; display: inline-block; margin-bottom: 1em;') code#code(style='visibility: hidden; display: inline-block; margin-bottom: 1em;')
img#result(style='display: block;') img#result(style='display: block;')
script. p(style='margin-top: 2em;')
a(href='https://github.com/journey-ad/Moe-Counter', target='_blank', onclick='_evt_push("click", "normal", "go_github")') source code
div.back-to-top
script.
(function () {
var btn = document.getElementById('get'), var btn = document.getElementById('get'),
img = document.getElementById('result'), img = document.getElementById('result'),
code = document.getElementById('code') code = document.getElementById('code')
@ -140,15 +157,13 @@ html
btn.addEventListener('click', throttle(function() { btn.addEventListener('click', throttle(function() {
var $name = document.getElementById('name'), var $name = document.getElementById('name'),
$theme = document.getElementById('theme'), $theme = document.getElementById('theme'),
$pixelated = document.getElementById('pixelated'),
$padding = document.getElementById('padding'), $padding = document.getElementById('padding'),
$offset = document.getElementById('offset'),
$scale = document.getElementById('scale'),
$pixelated = document.getElementById('pixelated'),
$darkmode = document.getElementById('darkmode') $darkmode = document.getElementById('darkmode')
var name = $name.value ? $name.value.trim() : '' var name = $name.value ? $name.value.trim() : ''
var theme = $theme.value || 'moebooru'
var pixelated = $pixelated.checked ? '1' : '0'
var padding = $padding.value || '7'
var darkmode = $darkmode.value || 'auto'
if(!name) { if(!name) {
alert('Please input counter name.') alert('Please input counter name.')
@ -157,7 +172,19 @@ html
party.confetti(this, { count: party.variation.range(20, 40) }); party.confetti(this, { count: party.variation.range(20, 40) });
img.src = `#{site}/@${name}?theme=${theme}&pixelated=${pixelated}&padding=${padding}&darkmode=${darkmode}` var params = {
name: $name.value ? $name.value.trim() : '',
theme: $theme.value || 'moebooru',
padding: $padding.value || '7',
offset: $offset.value || '0',
scale: $scale.value || '1',
pixelated: $pixelated.checked ? '1' : '0',
darkmode: $darkmode.value || 'auto',
}
var query = new URLSearchParams(params).toString()
img.src = `#{site}/@${name}?${query}`
code.textContent = img.src code.textContent = img.src
code.style.visibility = 'visible' code.style.visibility = 'visible'
@ -220,6 +247,50 @@ html
} }
} }
} }
})();
p(style='margin-top: 2em;') script.
a(href='https://github.com/journey-ad/Moe-Counter', target='_blank', onclick='_evt_push("click", "normal", "go_github")') source code (function () {
var isShow = false, lock = false;
var btn = document.querySelector('.back-to-top');
window.addEventListener('scroll', function () {
if (lock) return;
if (document.body.scrollTop >= 1000) {
if (!isShow) btn.classList.add('load');
isShow = true;
} else {
if (isShow) {
btn.classList.remove('load');
isShow = false;
}
}
});
btn.addEventListener('click', function () {
lock = true;
btn.classList.add('ani-leave');
window.scrollTo({ top: 0, behavior: 'smooth' });
setTimeout(function () {
btn.classList.remove('ani-leave');
btn.classList.add('leaved');
}, 390);
setTimeout(function () {
btn.classList.add('ending');
}, 120);
setTimeout(function () {
btn.classList.remove('load');
}, 1500);
setTimeout(function () {
lock = false;
isShow = false;
btn.classList.remove('leaved', 'ending');
}, 2000);
});
})();