mirror of
https://github.com/journey-ad/Moe-Counter.git
synced 2024-11-30 18:24:28 +01:00
feat: Add params validate
This commit is contained in:
parent
cc1881cc4f
commit
77cac2c504
@ -84,4 +84,4 @@ Glitch can use `.env` file, [documentation](https://help.glitch.com/hc/en-us/art
|
||||
|
||||
## License
|
||||
|
||||
[MIT License](./LICENSE)
|
||||
[MIT License](./LICENSE), excluding all themes
|
BIN
assets/img/back-to-top.png
Normal file
BIN
assets/img/back-to-top.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 37 KiB |
@ -34,6 +34,69 @@ h2, h3, h4, h5 {
|
||||
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) {
|
||||
iframe {
|
||||
display: none;
|
||||
|
104
index.js
104
index.js
@ -3,9 +3,12 @@
|
||||
const config = require("config-yml");
|
||||
const express = require("express");
|
||||
const compression = require("compression");
|
||||
const { z } = require("zod");
|
||||
|
||||
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();
|
||||
|
||||
@ -15,56 +18,65 @@ app.set("view engine", "pug");
|
||||
|
||||
app.get('/', (req, res) => {
|
||||
const site = config.app.site || `${req.protocol}://${req.get('host')}`
|
||||
res.render('index', { site })
|
||||
res.render('index', {
|
||||
site,
|
||||
themeList,
|
||||
})
|
||||
});
|
||||
|
||||
// get the image
|
||||
app.get(["/@:name", "/get/@:name"], async (req, res) => {
|
||||
const { name } = req.params;
|
||||
const { theme = "moebooru", padding = 7, pixelated = '1', darkmode = 'auto' } = req.query;
|
||||
const isPixelated = pixelated === '1';
|
||||
app.get(["/@:name", "/get/@:name"],
|
||||
ZodValid({
|
||||
params: z.object({
|
||||
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) {
|
||||
res.status(400).send("name 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");
|
||||
}
|
||||
|
||||
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
|
||||
app.get("/record/@:name", async (req, res) => {
|
||||
|
@ -18,6 +18,10 @@
|
||||
"image-size": "^0.8.3",
|
||||
"mime-types": "^2.1.27",
|
||||
"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
7
pnpm-lock.yaml
generated
@ -29,6 +29,9 @@ dependencies:
|
||||
pug:
|
||||
specifier: ^3.0.0
|
||||
version: 3.0.3
|
||||
zod:
|
||||
specifier: ^3.23.8
|
||||
version: 3.23.8
|
||||
|
||||
packages:
|
||||
|
||||
@ -1665,3 +1668,7 @@ packages:
|
||||
y18n: 3.2.2
|
||||
yargs-parser: 2.4.1
|
||||
dev: false
|
||||
|
||||
/zod@3.23.8:
|
||||
resolution: {integrity: sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g==}
|
||||
dev: false
|
||||
|
5
utils/index.js
Normal file
5
utils/index.js
Normal file
@ -0,0 +1,5 @@
|
||||
module.exports = {
|
||||
randomArray: (arr) => {
|
||||
return arr[Math.floor(Math.random() * arr.length)]
|
||||
},
|
||||
}
|
@ -14,10 +14,10 @@ fs.readdirSync(themePath).forEach(theme => {
|
||||
const imgList = fs.readdirSync(path.resolve(themePath, theme))
|
||||
imgList.forEach(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)
|
||||
|
||||
themeList[theme][name] = {
|
||||
themeList[theme][num] = {
|
||||
width,
|
||||
height,
|
||||
data: convertToDatauri(imgPath)
|
||||
@ -32,8 +32,12 @@ function convertToDatauri(path) {
|
||||
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'
|
||||
padding = parseInt(padding, 10)
|
||||
offset = parseInt(offset, 10)
|
||||
|
||||
// This is not the greatest way for generating an SVG but it'll do for now
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
@ -53,19 +59,23 @@ function getCountImage({ count, theme = 'moebooru', padding = 7, pixelated = tru
|
||||
}, '')
|
||||
|
||||
const parts = countArray.reduce((ret, cur) => {
|
||||
const { width } = themeList[theme][cur]
|
||||
let { width } = themeList[theme][cur]
|
||||
width *= scale
|
||||
|
||||
const image = `${ret}
|
||||
<use x="${x}" xlink:href="#${cur}" />`
|
||||
|
||||
x += width
|
||||
x += width + offset
|
||||
|
||||
return image
|
||||
}, '')
|
||||
|
||||
// Fix the last image offset
|
||||
x -= offset
|
||||
|
||||
const style = `
|
||||
svg {
|
||||
${pixelated ? 'image-rendering: pixelated;' : ''}
|
||||
${pixelated === '1' ? 'image-rendering: pixelated;' : ''}
|
||||
${darkmode === '1' ? '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 = {
|
||||
themeList,
|
||||
getCountImage
|
||||
}
|
||||
|
50
utils/zod.js
Normal file
50
utils/zod.js
Normal 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
|
||||
}
|
||||
}
|
147
views/index.pug
147
views/index.pug
@ -35,7 +35,7 @@ html
|
||||
h3 How to use
|
||||
p Set a unique id for your counter, replace
|
||||
code :name
|
||||
| in the url, that's all.
|
||||
| in the url, That's it!
|
||||
|
||||
h5 SVG address
|
||||
code #{site}/@:name
|
||||
@ -47,24 +47,15 @@ html
|
||||
code ![:name](#{site}/@:name)
|
||||
|
||||
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")')
|
||||
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>
|
||||
h5 asoul
|
||||
<img src="#{site}/@demo?theme=asoul" alt="A-SOUL" />
|
||||
h5 moebooru
|
||||
<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" />
|
||||
each theme in Object.keys(themeList)
|
||||
h5 #{theme}
|
||||
<img src="#{site}/@demo?theme=#{theme}" alt="#{theme}" />
|
||||
|
||||
h3 Credits
|
||||
ul
|
||||
@ -74,14 +65,13 @@ html
|
||||
a(href='https://space.bilibili.com/703007996', target='_blank', title='A-SOUL_Official') A-SOUL
|
||||
li
|
||||
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
|
||||
a(href='javascript:alert("!!! NSFW LINK !!!\\nPlease enter the url manually")') gelbooru.com
|
||||
| NSFW
|
||||
li
|
||||
a(href='https://icons8.com/icon/80355/star', target='_blank', rel='nofollow') Icons8
|
||||
span
|
||||
i And all booru site...
|
||||
|
||||
h3 Tool
|
||||
.tool
|
||||
@ -89,37 +79,58 @@ html
|
||||
thead
|
||||
tr
|
||||
th Param
|
||||
th Description
|
||||
th Value
|
||||
tbody
|
||||
tr
|
||||
td
|
||||
code name
|
||||
td Unique counter name
|
||||
td
|
||||
input#name(type='text', placeholder=':name')
|
||||
tr
|
||||
td
|
||||
code theme
|
||||
td Select a counter image theme, default is
|
||||
code moebooru
|
||||
td
|
||||
select#theme
|
||||
option(value='asoul') asoul
|
||||
option(value='moebooru') moebooru
|
||||
option(value='moebooru-h') moebooru-h
|
||||
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;')
|
||||
option(value="random", selected) * random
|
||||
each theme in Object.keys(themeList)
|
||||
<option value="#{theme}">#{theme}</option>
|
||||
tr
|
||||
td
|
||||
code padding
|
||||
td Set the minimum length, between 1-32, default is
|
||||
code 7
|
||||
td
|
||||
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
|
||||
td
|
||||
code darkmode
|
||||
td Enable dark mode, Enum 0/1/auto, default is
|
||||
code auto
|
||||
td
|
||||
select#darkmode(name="darkmode")
|
||||
option(value="auto", selected) auto
|
||||
@ -132,7 +143,13 @@ html
|
||||
code#code(style='visibility: hidden; display: inline-block; margin-bottom: 1em;')
|
||||
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'),
|
||||
img = document.getElementById('result'),
|
||||
code = document.getElementById('code')
|
||||
@ -140,15 +157,13 @@ html
|
||||
btn.addEventListener('click', throttle(function() {
|
||||
var $name = document.getElementById('name'),
|
||||
$theme = document.getElementById('theme'),
|
||||
$pixelated = document.getElementById('pixelated'),
|
||||
$padding = document.getElementById('padding'),
|
||||
$offset = document.getElementById('offset'),
|
||||
$scale = document.getElementById('scale'),
|
||||
$pixelated = document.getElementById('pixelated'),
|
||||
$darkmode = document.getElementById('darkmode')
|
||||
|
||||
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) {
|
||||
alert('Please input counter name.')
|
||||
@ -157,7 +172,19 @@ html
|
||||
|
||||
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.style.visibility = 'visible'
|
||||
|
||||
@ -220,6 +247,50 @@ html
|
||||
}
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
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
|
||||
script.
|
||||
(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);
|
||||
});
|
||||
})();
|
||||
|
Loading…
Reference in New Issue
Block a user