first commit

This commit is contained in:
leca 2025-01-23 01:12:17 +03:00
parent 651afd6e36
commit 1b09267e47
27 changed files with 4274 additions and 0 deletions

4
.gitignore vendored
View File

@ -130,3 +130,7 @@ dist
.yarn/install-state.gz
.pnp.*
.env
temp
data
inviteTokens.txt

18
.vscode/c_cpp_properties.json vendored Normal file
View File

@ -0,0 +1,18 @@
{
"configurations": [
{
"name": "linux-gcc-x64",
"includePath": [
"${workspaceFolder}/**"
],
"compilerPath": "/usr/bin/gcc",
"cStandard": "${default}",
"cppStandard": "${default}",
"intelliSenseMode": "linux-gcc-x64",
"compilerArgs": [
""
]
}
],
"version": 4
}

24
.vscode/launch.json vendored Normal file
View File

@ -0,0 +1,24 @@
{
"version": "0.2.0",
"configurations": [
{
"name": "C/C++ Runner: Debug Session",
"type": "cppdbg",
"request": "launch",
"args": [],
"stopAtEntry": false,
"externalConsole": false,
"cwd": "/home/leca/projects/js/minecraft-launcher-registration/public/css",
"program": "/home/leca/projects/js/minecraft-launcher-registration/public/css/build/Debug/outDebug",
"MIMode": "gdb",
"miDebuggerPath": "gdb",
"setupCommands": [
{
"description": "Enable pretty-printing for gdb",
"text": "-enable-pretty-printing",
"ignoreFailures": true
}
]
}
]
}

59
.vscode/settings.json vendored Normal file
View File

@ -0,0 +1,59 @@
{
"C_Cpp_Runner.cCompilerPath": "gcc",
"C_Cpp_Runner.cppCompilerPath": "g++",
"C_Cpp_Runner.debuggerPath": "gdb",
"C_Cpp_Runner.cStandard": "",
"C_Cpp_Runner.cppStandard": "",
"C_Cpp_Runner.msvcBatchPath": "C:/Program Files/Microsoft Visual Studio/VR_NR/Community/VC/Auxiliary/Build/vcvarsall.bat",
"C_Cpp_Runner.useMsvc": false,
"C_Cpp_Runner.warnings": [
"-Wall",
"-Wextra",
"-Wpedantic",
"-Wshadow",
"-Wformat=2",
"-Wcast-align",
"-Wconversion",
"-Wsign-conversion",
"-Wnull-dereference"
],
"C_Cpp_Runner.msvcWarnings": [
"/W4",
"/permissive-",
"/w14242",
"/w14287",
"/w14296",
"/w14311",
"/w14826",
"/w44062",
"/w44242",
"/w14905",
"/w14906",
"/w14263",
"/w44265",
"/w14928"
],
"C_Cpp_Runner.enableWarnings": true,
"C_Cpp_Runner.warningsAsError": false,
"C_Cpp_Runner.compilerArgs": [],
"C_Cpp_Runner.linkerArgs": [],
"C_Cpp_Runner.includePaths": [],
"C_Cpp_Runner.includeSearch": [
"*",
"**/*"
],
"C_Cpp_Runner.excludeSearch": [
"**/build",
"**/build/**",
"**/.*",
"**/.*/**",
"**/.vscode",
"**/.vscode/**"
],
"C_Cpp_Runner.useAddressSanitizer": false,
"C_Cpp_Runner.useUndefinedSanitizer": false,
"C_Cpp_Runner.useLeakSanitizer": false,
"C_Cpp_Runner.showCompilationTime": false,
"C_Cpp_Runner.useLinkTimeOptimization": false,
"C_Cpp_Runner.msvcSecureNoWarnings": false
}

9
Dockerfile Normal file
View File

@ -0,0 +1,9 @@
FROM node:22-bullseye
WORKDIR /opt/mcserver
COPY . .
RUN npm i
EXPOSE 3000
ENTRYPOINT ["node", "./src/index.js"]

65
db_schema.psql Normal file
View File

@ -0,0 +1,65 @@
-- This schema was adopted from the gravit launcher's wiki.
-- Create the uuid-ossp extension if it doesn't exist
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
-- Create the hwids table if it doesn't exist
CREATE TABLE IF NOT EXISTS hwids (
id serial8 NOT NULL PRIMARY KEY,
publickey bytea NULL,
hwdiskid varchar NULL,
baseboardserialnumber varchar NULL,
graphiccard varchar NULL,
displayid bytea NULL,
bitness int NULL,
totalmemory bigint NULL,
logicalprocessors int NULL,
physicalprocessors int NULL,
processormaxfreq bigint NULL,
battery boolean NULL,
banned boolean NULL
);
-- Create the users table if it doesn't exist
CREATE TABLE IF NOT EXISTS users (
uuid CHAR(36) UNIQUE DEFAULT NULL,
accessToken CHAR(32) DEFAULT NULL,
serverID VARCHAR(41) DEFAULT NULL,
hwidid BIGINT REFERENCES hwids(id) DEFAULT NULL,
username VARCHAR(32) UNIQUE DEFAULT NULL,
password CHAR(60) UNIQUE DEFAULT NULL,
can_have_cloak BOOLEAN DEFAULT false
);
-- Create the users_uuid_trigger_func function if it doesn't exist
CREATE OR REPLACE FUNCTION public.users_uuid_trigger_func()
RETURNS TRIGGER
AS
$function$
BEGIN
IF (new.uuid IS NULL) THEN
new.uuid = (SELECT uuid_generate_v4());
END IF;
return new;
END;
$function$ LANGUAGE plpgsql;
-- Create the users_uuid_trigger trigger if it doesn't exist
CREATE OR REPLACE TRIGGER users_uuid_trigger
BEFORE INSERT ON users
FOR EACH ROW
EXECUTE PROCEDURE public.users_uuid_trigger_func();
-- Update the users table to generate uuids for existing rows if necessary
UPDATE users SET uuid=(SELECT uuid_generate_v4()) WHERE uuid IS NULL;
-- Add the primary key constraint to the hwids table if it doesn't exist
--ALTER TABLE public.hwids ADD CONSTRAINT hwids_pk PRIMARY KEY (id);
-- Create the unique index on the publickey column of the hwids table if it doesn't exist
CREATE UNIQUE INDEX IF NOT EXISTS hwids_publickey_idx ON hwids (publickey);
-- Add the foreign key constraint to the users table if it doesn't exist
-- ALTER TABLE public.users ADD CONSTRAINT IF NOT EXISTS users_hwids_fk FOREIGN KEY (hwidid) REFERENCES public.hwids(id);

26
docker-compose.yml Normal file
View File

@ -0,0 +1,26 @@
services:
mcserver:
build: .
env_file:
- .env
ports:
- 8080:3000
depends_on:
- database
restart: on-failure
volumes:
- ./data/skins:/opt/skins
- ./data/cloaks:/opt/cloaks
database:
image: 'postgres:15'
ports:
- 5432:5432
volumes:
- ./data/db:/var/lib/postgresql/data
environment:
POSTGRES_USER: mcserver
POSTGRES_PASSWORD: GENERATE_A_STRONG_PASSWORD_HERE
POSTGRES_DB: mcserver
healthcheck:
test: ["CMD", "pg_isready", "-U", "mcserver"]

3423
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

37
package.json Normal file
View File

@ -0,0 +1,37 @@
{
"name": "minecraft-launcher-registration",
"version": "1.0.0",
"description": "Frontend and backend for my minecraft server's registration",
"keywords": [
"minecraft",
"launcher",
"registration"
],
"repository": {
"type": "git",
"url": "https://git.foxarmy.org/leca/minecraft-launcher-registration"
},
"license": "WTFPL",
"author": "leca",
"type": "module",
"main": "src/index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"start": "node src/index.js"
},
"dependencies": {
"bcrypt": "^5.1.1",
"cookie-parser": "^1.4.7",
"dotenv": "^16.4.7",
"express": "^4.21.2",
"express-session": "^1.18.1",
"jimp": "^1.6.0",
"jsonwebtoken": "^9.0.2",
"multer": "^1.4.5-lts.1",
"pg": "^8.13.1",
"pug": "^3.0.3"
},
"devDependencies": {
"nodemon": "^3.1.9"
}
}

72
public/css/auth.css Normal file
View File

@ -0,0 +1,72 @@
* {
box-sizing: border-box;
}
body {
font-family: sans-serif;
height: 100vh;
margin: 0;
padding: 0;
}
header {
display: none;
}
.box {
background-color: rgba(0, 0, 0, 0.8);
border-radius: 10px;
box-shadow: 0 15px 25px rgba(0, 0, 0, 0.8);
margin: auto auto;
padding: 40px;
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
}
.box h2 {
margin: 0 0 30px 0;
padding: 0;
color: #fff;
text-align: center;
}
.box .inputBox label {
color: #fff;
}
.box .inputBox input {
background: transparent;
border: none;
border-bottom: 1px solid #fff;
color: #fff;
font-size: 18px;
letter-spacing: 2px;
margin-bottom: 30px;
outline: none;
padding: 10px 0;
width: 100%;
}
.box input[type="submit"], .box button[type="submit"], a.button {
font-family: sans-serif;
background: #03a9f4;
font-size: 11px;
border: none;
border-radius: 5px;
color: #fff;
cursor: pointer;
font-weight: 600;
padding: 10px 20px;
letter-spacing: 2px;
outline: none;
text-transform: uppercase;
text-decoration: none;
margin: 2px 10px 2px 0;
display: inline-block;
}
.box input[type="submit"]:hover, .box button[type="submit"]:hover, a.button:hover {
opacity: 0.8;
}

9
public/css/particles.css Normal file
View File

@ -0,0 +1,9 @@
#tsparticles {
position: fixed;
margin: 0;
padding: 0;
left: 0;
top: 0;
width: 100%;
height: 100%;
}

1
public/js/particles.js Normal file
View File

@ -0,0 +1 @@
tsParticles.loadJSON("tsparticles", "json/particles.json");

104
public/json/particles.json Normal file
View File

@ -0,0 +1,104 @@
{
"fpsLimit": 60,
"particles": {
"number": {
"value": 50
},
"shape": {
"type": "circle"
},
"opacity": {
"value": 0.5
},
"size": {
"value": 400,
"random": {
"enable": true,
"minimumValue": 200
}
},
"move": {
"enable": true,
"speed": 10,
"direction": "top",
"outMode": "destroy"
}
},
"interactivity": {
"detectsOn": "canvas",
"events": {
"resize": true
}
},
"detectRetina": true,
"background": {
"image": "",
"position": "50% 50%",
"repeat": "no-repeat",
"size": "cover"
},
"themes": [
{
"name": "light",
"default": {
"value": true,
"mode": "light"
},
"options": {
"background": {
"color": "#fff"
},
"particles": {
"color": {
"value": [
"#5bc0eb",
"#fde74c",
"#9bc53d",
"#e55934",
"#fa7921"
]
}
}
}
},
{
"name": "dark",
"default": {
"value": true,
"mode": "dark"
},
"options": {
"background": {
"color": "#000"
},
"particles": {
"color": {
"value": [
"#004f74",
"#5f5800",
"#245100",
"#7d0000",
"#810c00"
]
}
}
}
}
],
"emitters": {
"direction": "top",
"position": {
"x": 50,
"y": 120
},
"rate": {
"delay": 0.2,
"quantity": 2
},
"size": {
"width": 100,
"height": 0
}
}
}

9
sample.env Normal file
View File

@ -0,0 +1,9 @@
SECRET=GENERATE_A_STRONG_SECRET_HERE
DBUSER=mcserver
DBHOST=localhost
DBNAME=mcserver
DBPORT=5432
DBPASS=GENERATE_A_STRONG_PASSWORD_HERE
PORT=3000
REQUIRE_TOKEN=false
DELETE_TOKEN_ON_USE=true

95
src/controllers/user.js Normal file
View File

@ -0,0 +1,95 @@
import bcrypt from 'bcrypt';
import jwt from 'jsonwebtoken';
import dotenv from 'dotenv';
import path from 'path';
import { Jimp } from 'jimp';
import fs from 'fs';
import UserService from "../services/user.js";
import utils from '../utils.js';
dotenv.config({path: ".env"});
class UserController {
async register(req, res) {
const {username, password, passwordConfirm} = req.body;
if (password != passwordConfirm) return res.status(400).send("Passwords do not match");
let hashedPassword = await bcrypt.hash(password, 8);
await UserService.register(username, hashedPassword);
if (process.env.REQUIRE_TOKEN == "true" && process.env.DELETE_TOKEN_ON_USE == "true") {
utils.removeFromFile('./inviteTokens.txt', req.body.inviteToken);
}
req.session.jwt = jwt.sign({ username }, process.env.SECRET, {expiresIn: "1y"});
return res.redirect("/index");
}
async login(req, res) {
const {username, password} = req.body;
const storedPassword = await UserService.getPassword(username);
if (!bcrypt.compareSync(password, storedPassword)) {
return res.status(403).send("Password is not correct");
}
req.session.jwt = jwt.sign({ username }, process.env.SECRET, {expiresIn: "1y"});
return res.redirect("/index");
}
async logout(req, res) {
req.session.destroy();
return res.redirect("/login");
}
async uploadSkin(req, res) {
const token = req.session.jwt;
const decoded = jwt.decode(token);
const tempPath = req.file.path;
const targetPath = `/opt/skins/${decoded.username}.png`;
if (path.extname(req.file.originalname).toLowerCase() !== ".png") {
return res.status(400).send("Only .png files are allowed!");
}
const image = await Jimp.read(tempPath);
if (image.bitmap.width != 64 || image.bitmap.height != 64) {
fs.unlinkSync(targetPath);
return res.status(400).send('This does not look like a minecraft skin.');
}
fs.renameSync(tempPath, targetPath, err => {
if (err) return res.status(500).send("Ooops! Something went wrong! Please, report to the developer.");
});
return res.status(200).send("Skin uploaded!");
}
async uploadCape(req, res) {
const token = req.session.jwt;
const decoded = jwt.decode(token);
const tempPath = req.file.path;
const targetPath = `/opt/cloaks/${decoded.username}.png`;
if (path.extname(req.file.originalname).toLowerCase() !== ".png") {
return res.status(400).send("Only .png files are allowed!");
}
const image = await Jimp.read(tempPath);
if ((image.bitmap.width != 64 || image.bitmap.height != 32) && (image.bitmap.width != 128 || image.bitmap.height != 64)) {
fs.unlinkSync(tempPath);
return res.status(400).send('This does not look like a minecraft cape.');
}
fs.renameSync(tempPath, targetPath, err => {
if (err) return res.status(500).send("Ooops! Something went wrong! Please, report to the developer.");
});
return res.status(200).send("Cape uploaded!");
}
}
export default new UserController();

22
src/db.js Normal file
View File

@ -0,0 +1,22 @@
import pg from 'pg';
import fs from 'fs';
import dotenv from 'dotenv';
dotenv.config({path: ".env"});
const { Pool } = pg;
console.log("Connecting to PostgreSQL database");
const pool = new Pool({
user: process.env.DBUSER,
host: process.env.DBHOST,
database: process.env.DBNAME,
password: process.env.DBPASS,
port: process.env.DBPORT
});
pool.query(fs.readFileSync('./db_schema.psql').toString());
export default pool;

32
src/index.js Normal file
View File

@ -0,0 +1,32 @@
import express from 'express';
import dotenv from 'dotenv';
import session from 'express-session';
import cookieParser from 'cookie-parser';
import path from 'path';
import ApiRouter from './routers/api.js';
import UserRouter from './routers/user.js';
const app = express();
dotenv.config({path: ".env"});
app.use(session({
secret: process.env.SECRET,
resave: true,
saveUninitialized: false,
cookie: { maxAge: 1000 * 60 * 60 * 24 }
}));
app.use(express.static(path.join('./public')));
app.use(express.urlencoded({extended: false}));
app.use(express.json());
app.use(cookieParser());
app.set('view engine', 'pug');
app.use('/api', ApiRouter);
app.use('/', UserRouter);
app.listen(process.env.PORT, () => {
console.log("App has been started!");
});

32
src/middlewares/auth.js Normal file
View File

@ -0,0 +1,32 @@
import fs from 'fs';
import dotenv from 'dotenv';
import jwt from 'jsonwebtoken';
dotenv.config({path: ".env"});
const authenticate = async (req, res, next) => {
const token = req.session.jwt;
if (!token || !jwt.verify(token, process.env.SECRET)) {
req.session.destroy();
return res.redirect("/login");
}
next();
};
const validateInviteToken = async (req, res, next) => {
if (process.env.REQUIRE_TOKEN != "true") return next();
const { inviteToken } = req.body;
fs.appendFileSync('./inviteTokens.txt', '');
const inviteTokens = fs.readFileSync('./inviteTokens.txt').toString().split('\n');
let tokenValid = false;
inviteTokens.forEach((token) => {
if (token == inviteToken) tokenValid = true;
});
if (!tokenValid) return res.status(400).send("Token is not valid");
next();
};
export default {authenticate, validateInviteToken};

View File

@ -0,0 +1,22 @@
import UserService from '../services/user.js';
const userDoesNotExist = async (req, res, next) => {
const { username } = req.body;
if (await UserService.exists(username)) {
return res.status(401).send("Such user exists!");
}
next();
};
const userExist = async (req, res, next) => {
const { username } = req.body;
if (!(await UserService.exists(username))) {
return res.status(401).send("Such user does not exists!");
}
next();
}
export default {userDoesNotExist, userExist};

View File

@ -0,0 +1,31 @@
import dotenv from 'dotenv';
dotenv.config({path: ".env"});
const requireUsername = async (req, res, next) => {
const { username } = req.body;
if (!username) return res.status(400).send("Username is requires");
next();
};
const requirePassword = async (req, res, next) => {
const { password } = req.body;
if (!password) return res.status(400).send("Password is required");
next();
};
const requireInviteToken = async (req, res, next) => {
const { inviteToken } = req.body;
if (!inviteToken && process.env.REQUIRE_TOKEN == "true") return res.status(400).send("Invite token is required");
next();
};
const requireFile = async (req, res, next) => {
if (!req.file) return res.status(400).send("Please, select a file!");
next();
}
export default {requireUsername, requirePassword, requireInviteToken, requireFile};

18
src/routers/api.js Normal file
View File

@ -0,0 +1,18 @@
import { Router } from 'express';
import requiredParameters from '../middlewares/requiredParameters.js';
import existance from '../middlewares/existance.js';
import auth from '../middlewares/auth.js';
import utils from '../utils.js';
import UserController from '../controllers/user.js';
const ApiRouter = new Router();
ApiRouter.post('/register', requiredParameters.requireUsername, requiredParameters.requirePassword, auth.validateInviteToken, existance.userDoesNotExist, UserController.register);
ApiRouter.post('/login', requiredParameters.requireUsername, requiredParameters.requirePassword, existance.userExist, UserController.login);
ApiRouter.get('/logout', auth.authenticate, UserController.logout);
ApiRouter.post('/uploadSkin', auth.authenticate, utils.upload.single('file'), requiredParameters.requireFile, UserController.uploadSkin);
ApiRouter.post('/uploadCape', auth.authenticate, utils.upload.single('file'), requiredParameters.requireFile, UserController.uploadCape);
export default ApiRouter;

40
src/routers/user.js Normal file
View File

@ -0,0 +1,40 @@
import { Router } from 'express';
import jwt from 'jsonwebtoken';
import dotenv from 'dotenv';
import auth from '../middlewares/auth.js';
import UserService from '../services/user.js';
dotenv.config({path: ".env"});
const UserRouter = new Router();
UserRouter.get('/register', async (req, res) => {
if (req.session.jwt && jwt.verify(req.session.jwt, process.env.SECRET))
return res.redirect("/index");
return res.render("register.pug", {
require_token: process.env.REQUIRE_TOKEN == "true"? true : false
});
});
UserRouter.get(['/', '/login'], async (req, res) => {
if(req.session.jwt && jwt.verify(req.session.jwt, process.env.SECRET))
return res.redirect("/index");
return res.render("login.pug");
});
UserRouter.get('/index', auth.authenticate, async (req, res) => {
if (!req.session.jwt || !jwt.verify(req.session.jwt, process.env.SECRET))
return res.redirect("/login");
const decoded = jwt.decode(req.session.jwt);
return res.render('index.pug', {
username: decoded.username,
can_have_cloak: await UserService.canHaveCloak(decoded.username)
});
})
export default UserRouter;

21
src/services/user.js Normal file
View File

@ -0,0 +1,21 @@
import db from '../db.js';
class UserService {
async register(username, password) {
await db.query("INSERT INTO users (username, password) VALUES ($1, $2)", [username, password]);
}
async exists(username) {
return (await db.query("SELECT * FROM users WHERE username = $1", [username])).rowCount > 0;
}
async getPassword(username) {
return (await db.query("SELECT password FROM users WHERE username = $1", [username])).rows[0].password;
}
async canHaveCloak(username) {
return (await db.query("SELECT can_have_cloak FROM users WHERE username = $1", [username])).rows[0].can_have_cloak;
}
};
export default new UserService();

24
src/utils.js Normal file
View File

@ -0,0 +1,24 @@
import multer from "multer";
import fs from 'fs';
const upload = multer({
dest: "./temp",
fileSize: 3072
});
const removeFromFile = (filename, toRemove) => {
let content = fs.readFileSync(filename).toString();
let dataArray = content.split('\n');
let lastIndex = -1;
for (let i = 0; i < dataArray.length; i ++) {
if (dataArray[i].includes(toRemove)) {
lastIndex = i;
break;
}
}
dataArray.splice(lastIndex, 1);
const updatedData = dataArray.join('\n');
fs.writeFileSync(filename, updatedData);
}
export default {upload, removeFromFile};

16
views/index.pug Normal file
View File

@ -0,0 +1,16 @@
html
head
title Личный кабинет
body
h1 Личный кабинет
p Имя пользователя: #{username}
p Скин:
form(method="post" enctype="multipart/form-data" action="/api/uploadskin")
input(type="file" name="file")
input(type="submit" value="Загрузить")
if can_have_cloak
p Плащ:
form(method="post" enctype="multipart/form-data" action="/api/uploadCape")
input(type="file", name="file")
input(type="submit", value="Загрузить")
a(href="/api/logout") Выйти

29
views/login.pug Normal file
View File

@ -0,0 +1,29 @@
html
head
title Вход
meta(charset="utf-8")
meta(name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no")
meta(name="description" content="")
link(href="css/particles.css" rel="stylesheet")
link(href="css/auth.css" rel="stylesheet")
body
div(id="tsparticles")
main(class="box")
h2 Вход
form(method="post", action="/api/login")
div(class="inputBox")
label(for="username") Ник
input(type="text" name="username" id="username" placeholder="ваш ник на сервере" required=true)
div(class="inputBox")
label(for="password") Пароль
input(type="password" name="password" id="password" placeholder="ваш пароль" required=true)
div
button(type="submit" name="" style="float: left;") Войти
a(class="button" href="register" style="float: left;") Регистрация
script(src="https://cdn.jsdelivr.net/npm/tsparticles@1.34.1/tsparticles.min.js" integrity="sha256-D6LXCdCl4meErhc25yXnxIFUtwR96gPo+GtLYv89VZo=" crossorigin="anonymous")
script(type="text/javascript" src="js/particles.js")

32
views/register.pug Normal file
View File

@ -0,0 +1,32 @@
html
head
meta(charset="utf-8")
meta(name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no")
meta(name="description" content="")
link(href="css/particles.css" rel="stylesheet")
link(href="css/auth.css" rel="stylesheet")
title Регистрация
body
div(id="tsparticles")
main(class="box")
h2 Регистрация
form(action="/api/register" method="POST")
div(class="inputBox")
label(for="username") Ник
input(type="text" name="username" id="username" placeholder="ваш ник на сервере" required)
div(class="inputBox")
label(for="password") Пароль
input(type="password" name="password" id="password" placeholder="ваш пароль" required)
div(class="inputBox")
label(for="passwordConfirm") Повтор пароля
input(type="password" name="passwordConfirm" id="passwordConfirm" placeholder="повторите пароль" required)
if require_token
div(class="inputBox")
label(for="inviteToken") Токен приглашения
input(type="text" name="inviteToken" id="inviteToken" placeholder="код приглашения" required)
button(type="submit" name="" style="float: left;") Зарегистрироваться
a(class="button" href="login" style="float: left;") Войти
script(src="https://cdn.jsdelivr.net/npm/tsparticles@1.34.1/tsparticles.min.js" integrity="sha256-D6LXCdCl4meErhc25yXnxIFUtwR96gPo+GtLYv89VZo=" crossorigin="anonymous")
script(type="text/javascript" src="js/particles.js")