diff --git a/backend/app.js b/backend/app.js new file mode 100644 index 0000000..8f5d343 --- /dev/null +++ b/backend/app.js @@ -0,0 +1,96 @@ +const express = require('express') +const bodyParser = require('body-parser') +const cookieParser = require('cookie-parser') +const crypto = require('crypto') +const fs = require('fs') +const util = require('util') +const bot = require('./apps/bot') + +const app = express() + +app.use(bodyParser.json()) +app.use(cookieParser()) + +BigInt.prototype.toJSON = function () { + return Number(this) +} + +app.use((req, res, next) => { + if(!(req.body instanceof Object)) + return next() + + const escapeHtml = str => str.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"').replace(/'/g, ''') + Object + .keys(req.body || {}) + .filter(key => typeof(req.body[key]) == 'string' && key != 'password') + .map(key => req.body[key] = escapeHtml(req.body[key])) + + next() +}) + +// cors +app.use((req, res, next) => { + res.set({ + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE', + 'Access-Control-Allow-Headers': 'Accept,Accept-Language,Content-Language,Content-Type,Authorization,Cookie,X-Requested-With,Origin,Host', + 'Access-Control-Allow-Credentials': true + }) + + return req.method == 'OPTIONS' ? res.status(200).json({success: true}) : next() +}) + +app.post('(/api/admin/customer/login|/api/miniapp/user/login)', (req, res, next) => { + const data = Object.assign({}, req.query) + delete data.hash + const hash = req.query?.hash + + const BOT_TOKEN = '7236504417:AAGVaodw3cRwGlf-jAhwnYb51OHaXcgpW8k' + const dataCheckString = Object.keys(data).sort().map((key) => `${key}=${data[key]}`).join("\n") + const secretKey = crypto.createHmac("sha256", "WebAppData").update(BOT_TOKEN).digest() + const hmac = crypto.createHmac("sha256", secretKey).update(dataCheckString).digest("hex") + + const timeDiff = Date.now() / 1000 - data.auth_date + + if (hmac !== req.query.hash) // || timeDiff > 10) + throw Error('ACCESS_DENIED::401') + + const user = JSON.parse(req.query.user) + res.locals.telegram_id = user.id + res.locals.start_param = req.query.start_param + + if (!res.locals.telegram_id) + throw Error('ACCESS_DENIED::500') + + next() +}) + + +app.use('/api/admin', require('./apps/admin')) +app.use('/api/miniapp', require('./apps/miniapp')) + +app.use((err, req, res, next) => { + console.error(`Error for ${req.path}: ${err}`) + + let message, code + //if (err.code == 'SQLITE_ERROR' || err.code == 'SQLITE_CONSTRAINT_CHECK') { + // message = 'DATABASE_ERROR' + //code = err.code == 'SQLITE_CONSTRAINT_CHECK' ? 400 : 500 + //} else { + [message, code = 500] = err.message.split('::') + //} + + res.status(res.statusCode == 200 ? 500 : res.statusCode).json({success: false, error: { message, code}}) +}) + +app.use(express.static('public')) + +const PORT = process.env.PORT || 3000 +app.listen(PORT, async () => { + console.log(`Listening at port ${PORT}`) + bot.start( + process.env.API_ID || 26746106, + process.env.API_HASH || '29e5f83c04e635fa583721473a6003b5', + process.env.BOT_TOKEN || '7236504417:AAGVaodw3cRwGlf-jAhwnYb51OHaXcgpW8k' + ) +}) \ No newline at end of file diff --git a/backend/apps/admin.js b/backend/apps/admin.js new file mode 100644 index 0000000..854a091 --- /dev/null +++ b/backend/apps/admin.js @@ -0,0 +1,496 @@ +const crypto = require('crypto') +const express = require('express') +const multer = require('multer') +const db = require('../include/db') +const bot = require('./bot') +const fs = require('fs') +const cookieParser = require('cookie-parser') + +const app = express.Router() +const upload = multer({ + storage: multer.memoryStorage(), + limits: { + fileSize: 1_000_000 // 1mb + } +}) + +const sessions = {} + +app.use((req, res, next) => { + if (req.path == '/customer/login' || + req.path == '/customer/register' || + req.path == '/customer/activate') + return next() + + const asid = req.query.asid || req.cookies.asid + req.session = sessions[asid] + if (!req.session) + throw Error('ACCESS_DENIED::401') + + res.locals.customer_id = req.session.customer_id + next() +}) + +// CUSTOMER +app.post('/customer/login', (req, res, next) => { + res.locals.email = req.body?.email + res.locals.password = req.body?.password + + let customer_id = db + .prepare(` + select id + from customers + where is_active = 1 and ( + email is not null and email = :email and password is not null and password = :password or + email is null and password is null and telegram_user_id = :telegram_id + ) + `) + .pluck(true) + .get(res.locals) + + if (!customer_id && !res.locals.email && !res.locals.password) { + customer_id = db + .prepare(`insert into customers (telegram_user_id, is_active) values (:telegram_id, 1) returning id`) + .safeIntegers(true) + .pluck(true) + .get(res.locals) + } + + if (!customer_id) + throw Error('AUTH_ERROR::401') + + res.locals.customer_id = customer_id + db + .prepare(`update customers set telegram_user_id = :telegram_id where id = :customer_id and email is not null`) + .run(res.locals) + + const asid = crypto.randomBytes(64).toString('hex') + req.session = sessions[asid] = {asid, customer_id } + res.setHeader('Set-Cookie', [`asid=${asid};httpOnly;path=/api/admin`]) + + res.status(200).json({success: true}) +}) + +app.get('/customer/logout', (req, res, next) => { + delete sessions[req.session.asid] + res.setHeader('Set-Cookie', [`asid=; expired; httpOnly`]) + res.status(200).json({success: true}) +}) + +app.post('/customer/register', (req, res, next) => { + const email = String(req.body.email).trim() + const password = String(req.body.password).trim() + + const validateEmail = email => String(email).toLowerCase().match(/^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/) + if (!validateEmail(email)) + throw Error('INCORRECT_EMAIL::400') + + if (!password) + throw Error('EMPTY_PASSWORD::400') + + const row = db + .prepare('select id from customers where email = :email') + .run({email}) + if (row) + throw Error('DUPLICATE_EMAIL::400') + + const key = crypto.randomBytes(32).toString('hex') + const info = db + .prepare('insert into customers (email, password, activation_key) values (:email, :password, :key)') + .run({email, password, key}) + + // To-Do: SEND MAIL + console.log(`http://127.0.0.1:3000/api/customer/activate?key=${key}`) + res.status(200).json({success: true, data: key}) +}) + +app.get('/customer/activate', (req, res, next) => { + const row = db + .prepare('update customers set is_active = 1 where activation_key = :key returning id') + .get({key: req.query.key}) + + if (!row || !row.id) + throw Error('BAD_ACTIVATION_KEY::400') + + res.status(200).json({success: true}) +}) + +app.get('/customer/profile', (req, res, next) => { + const row = db + .prepare(` + select id, name, email, plan, coalesce(json_balance, '{}') json_balance, coalesce(json_company, '{}') json_company, upload_group_id + from customers + where id = :customer_id and is_active = 1 + `) + .get(res.locals) + + if (row?.upload_group_id) { + row.upload_group = db + .prepare(`select id, name, telegram_id from groups where id = :group_id and project_id is null`) + .safeIntegers(true) + .get({ group_id: row.upload_group_id}) + delete row.upload_group_id + } + + for (const key in row) { + if (key.startsWith('json_')) { + row[key.substr(5)] = JSON.parse(row[key]) + delete row[key] + } + } + + res.status(200).json({success: true, data: row}) +}) + +app.put('/customer/profile', (req, res, next) => { + if (req.body.company instanceof Object) + req.body.json_company = JSON.stringify(req.body.company) + + const info = db + .prepareUpdate( + 'customers', + ['name', 'password', 'json_company'], + req.body, + ['id']) + .run(Object.assign({}, req.body, {id: req.session.customer_id})) + + if (info.changes == 0) + throw Error('NOT_FOUND::404') + + res.status(200).json({success: true}) +}) + +// PROJECT +app.get('/project', (req, res, next) => { + const where = req.query.id ? ' and id = ' + parseInt(req.query.id) : '' + + const rows = db + .prepare(` + select id, name, description, logo + from projects + where customer_id = :customer_id ${where} and is_deleted <> 1 + order by name + `) + .all(res.locals) + + if (where && rows.length == 0) + throw Error('NOT_FOUND::404') + + res.status(200).json({success: true, data: where ? rows[0] : rows}) +}) + +app.get('/project/:pid(\\d+)', (req, res, next) => { + res.redirect(req.baseUrl + `/project?id=${req.params.pid}`) +}) + +app.post('/project', (req, res, next) => { + res.locals.name = req.body?.name + res.locals.description = req.body?.description + res.locals.logo = req.body?.logo + + const id = db + .prepare(` + insert into projects (customer_id, name, description, logo) + values (:customer_id, :name, :description, :logo) + returning id + `) + .pluck(true) + .get(res.locals) + + res.status(200).json({success: true, data: id}) +}) + +app.put('/project/:pid(\\d+)', (req, res, next) => { + res.locals.id = req.params.pid + res.locals.name = req.body?.name + res.locals.description = req.body?.description + res.locals.logo = req.body?.logo + + const info = db + .prepareUpdate( + 'projects', + ['name', 'description', 'logo'], + res.locals, + ['id', 'customer_id']) + .run(res.locals) + + if (info.changes == 0) + throw Error('NOT_FOUND::404') + + res.status(200).json({success: true}) +}) + +app.delete('/project/:pid(\\d+)', async (req, res, next) => { + res.locals.id = req.params.pid + + const info = db + .prepare('update projects set id_deleted = 1 where id = :id and customer_id = :customer_id') + .run(res.locals) + + if (info.changes == 0) + throw Error('NOT_FOUND::404') + + const groupIds = db + .prepare(`select id from groups where project_id = :id`) + .pluck(true) + .all(res.locals) + + for (const groupId of groupIds) { + await bot.sendMessage(groupId, 'Проект удален') + await bot.leaveGroup(groupId) + } + + db.prepare(`updates groups set project_id = null where id in (${ groupIds.join(', ')})`).run() + + res.status(200).json({success: true}) +}) + +app.use ('/project/:pid(\\d+)/*', (req, res, next) => { + res.locals.project_id = parseInt(req.params.pid) + + const row = db + .prepare('select 1 from projects where id = :project_id and customer_id = :customer_id and is_deleted <> 1') + .get(res.locals) + + if (!row) + throw Error('ACCESS_DENIED::401') + + next() +}) + +// USER +app.get('/project/:pid(\\d+)/user', (req, res, next) => { + const where = req.query.id ? ' and id = ' + parseInt(req.query.id) : '' + + const rows = db + .prepare(` + select u.id, u.telegram_id, u.firstname, u.lastname, u.username, u.photo, + ud.fullname, ud.role, ud.department, ud.is_blocked + from users u + left join user_details ud on u.id = ud.user_id and ud.project_id = :project_id + where id in ( + select user_id + from group_users + where group_id in (select id from groups where project_id = :project_id) + ) ${where} + `) + .safeIntegers(true) + .all(res.locals) + + if (where && rows.length == 0) + throw Error('NOT_FOUND::404') + + res.status(200).json({success: true, data: where ? rows[0] : rows}) +}) + +app.get('/project/:pid(\\d+)/user/:uid(\\d+)', (req, res, next) => { + res.redirect(req.baseUrl + `/project/${req.params.pid}/user?id=${req.params.uid}`) +}) + +app.put('/project/:pid(\\d+)/user/:uid(\\d+)', (req, res, next) => { + res.locals.user_id = parseInt(req.params.uid) + + res.locals.fullname = req.body?.fullname + res.locals.role = req.body?.role + res.locals.department = req.body?.department + res.locals.is_blocked = req.body?.is_blocked + + const info = db + .prepareUpdate('user_details', + ['fullname', 'role', 'department', 'is_blocked'], + res.locals, + ['user_id', 'project_id'] + ) + .all(res.locals) + + if (info.changes == 0) + throw Error('NOT_FOUND::404') + + res.status(200).json({success: true}) +}) + +app.get('/project/:pid(\\d+)/token', (req, res, next) => { + res.locals.time = Math.floor(Date.now() / 1000) + + const key = db + .prepare('select generate_key(id, :time) from projects where id = :project_id and customer_id = :customer_id') + .pluck(true) + .get(res.locals) + + if (!key) + throw Error('NOT_FOUND::404') + + res.status(200).json({success: true, data: key}) +}) + +// COMPANY +app.get('/project/:pid(\\d+)/company', (req, res, next) => { + const where = req.query.id ? ' and id = ' + parseInt(req.query.id) : '' + + const rows = db + .prepare(` + select id, name, email, phone, description, logo, + (select json_group_array(user_id) from company_users where company_id = c.id) users + from companies c + where project_id = :project_id ${where} + order by name + `) + .all(res.locals) + + rows.forEach(row => row.users = JSON.parse(row.users || '[]')) + + if (where && rows.length == 0) + throw Error('NOT_FOUND::404') + + res.status(200).json({success: true, data: where ? rows[0] : rows}) +}) + +app.get('/project/:pid(\\d+)/company/:cid(\\d+)', (req, res, next) => { + res.redirect(req.baseUrl + `/project/${req.params.pid}/company?id=${req.params.cid}`) +}) + +app.post('/project/:pid(\\d+)/company', (req, res, next) => { + res.locals.name = req.body?.name + res.locals.email = req.body?.email + res.locals.phone = req.body?.phone + res.locals.site = req.body?.site + res.locals.description = req.body?.description + res.locals.logo = req.body?.logo + + const id = db + .prepare(` + insert into companies (project_id, name, email, phone, site, description, logo) + values (:project_id, :name, :email, :phone, :site, :description, :logo) + returning id + `) + .pluck(res.locals) + .get(res.locals) + + res.status(200).json({success: true, data: id}) +}) + +app.put('/project/:pid(\\d+)/company/:cid(\\d+)', (req, res, next) => { + res.locals.id = parseInt(req.params.cid) + res.locals.name = req.body?.name + res.locals.email = req.body?.email + res.locals.phone = req.body?.phone + res.locals.site = req.body?.site + res.locals.description = req.body?.description + + const info = db + .prepareUpdate( + 'companies', + ['name', 'email', 'phone', 'site', 'description'], + res.locals, + ['id', 'project_id']) + .run(res.locals) + + if (info.changes == 0) + throw Error('NOT_FOUND::404') + + res.status(200).json({success: true}) +}) + +app.delete('/project/:pid(\\d+)/company/:cid(\\d+)', (req, res, next) => { + res.locals.company_id = parseInt(req.params.cid) + + const info = db + .prepare(`delete from companies where id = :company_id and project_id = :project_id`) + .run(res.locals) + + if (info.changes == 0) + throw Error('NOT_FOUND::404') + + res.status(200).json({success: true}) +}) + +app.get('/project/:pid(\\d+)/group', (req, res, next) => { + const where = req.query.id ? ' and id = ' + parseInt(req.query.id) : '' + + const rows = db + .prepare(` + select id, name, telegram_id, is_channel, user_count, bot_can_ban + from groups + where project_id = :project_id ${where} + `) + .all(res.locals) + + if (where && rows.length == 0) + throw Error('NOT_FOUND::404') + + res.status(200).json({success: true, data: where ? rows[0] : rows}) +}) + +app.get('/project/:pid(\\d+)/group/:gid(\\d+)', (req, res, next) => { + res.redirect(req.baseUrl + `/project/${req.params.pid}/group?id=${req.params.uid}`) +}) + +app.delete('/project/:pid(\\d+)/group/:gid(\\d+)', async (req, res, next) => { + res.locals.group_id = parseInt(req.params.gid) + const info = db + .prepare(`update groups set project_id = null where id = :group_id and project_id = :project_id`) + .run(res.locals) + + if (info.changes == 0) + throw Error('NOT_FOUND::404') + + await bot.sendMessage(res.locals.group_id, 'Группа удалена из проекта') + + res.status(200).json({success: true}) +}) + +app.put('/project/:pid(\\d+)/company/:cid(\\d+)/user', (req, res, next) => { + res.locals.company_id = parseInt(req.params.cid) + + // Проверка, что есть доступ к компании + const row = db + .prepare('select 1 from companies where id = :company_id and project_id = :project_id') + .get(res.locals) + + if (!row) + throw Error('NOT_FOUND::404') + + const user_ids = req.body instanceof Array ? [...new Set(req.body.map(e => parseInt(e)))] : [] + + // Проверка, что пользователи имеют доступ к проекту + let rows = db + .prepare(` + select user_id + from group_users + where group_id in (select id from groups where project_id = :project_id) + `) + .pluck(true) // .raw? + .get(res.locals) + + if (user_ids.some(user_id => !rows.contains(user_id))) + throw Error('INACCESSABLE_MEMBER::400') + + // Проверка, что пользователи не участвуют в других компаниях на проекте + rows = db + .prepare(` + select user_id + from company_users + where company_id in (select id from companies where id <> :company_id and project_id = :project_id) + `) + .pluck(true) // .raw? + .get(res.locals) + + if (user_ids.some(user_id => !rows.contains(user_id))) + throw Error('USED_MEMBER::400') + + + db + .prepare(`delete from company_users where company_id = :company_id`) + .run(res.locals) + + db + .prepare(` + insert into company_users (company_id, user_id) + select :company_id, value from json_each(:json_ids) + `) + .run(res.locals, {json_ids: JSON.stringify(user_ids)}) + + res.status(200).json({success: true}) +}) + +module.exports = app \ No newline at end of file diff --git a/backend/apps/bot.js b/backend/apps/bot.js new file mode 100644 index 0000000..963680c --- /dev/null +++ b/backend/apps/bot.js @@ -0,0 +1,654 @@ +const util = require('util') +const crypto = require('crypto') +const EventEmitter = require('events') +const fs = require('fs') +const db = require('../include/db') + +const { Api, TelegramClient } = require('telegram') +const { StringSession } = require('telegram/sessions') +const { NewMessage } = require('telegram/events') +const { Button } = require('telegram/tl/custom/button') +const { CustomFile } = require('telegram/client/uploads') + +// const session = new StringSession('1AgAOMTQ5LjE1NC4xNjcuNTABuxdIxmjimA0hmWpdrlZ4Fo7uoIGU4Bu9+G5QprS6zdtyeMfcssWEZp0doLRX/20MomQyF4Opsos0El0Ifj5aiNgg01z8khMLMeT98jS+1U/sh32p3GxZfxyXSxX1bD0NLRaXnqVyNNswYqRZPhboT28NMjDqwlz0nrW9rge+QMJDL7jIkXgSs+cmJBINiqsEI8jWjXmc8TU/17gngtjUHRf5kRM4y5gsNC4O8cF5lcHRx0G/U5ZVihTID8ItQ6EdEHjz6e4XErbVOJ81PfYkqEoPXVvkEmRM0/VbvCzFfixfas4Vzczfn98OHLd8P2MXcgokZ2rppvIV3fQXOHxJbA0=') +const session = new StringSession('') + +let client + +function registerUser (telegramId) { + db + .prepare(`insert or ignore into users (telegram_id) values (:telegram_id)`) + .safeIntegers(true) + .run({ telegram_id: telegramId }) + + return db + .prepare(`select id from users where telegram_id = :telegram_id`) + .safeIntegers(true) + .pluck(true) + .get({ telegram_id: telegramId }) +} + +function updateUser (userId, data) { + const info = db + .prepare(` + update users set firstname = :firstname, lastname = :lastname, username = :username, + access_hash = :access_hash, language_code = :language_code where id = :user_id + `) + .safeIntegers(true) + .run({ + user_id: userId, + firstname: data.firstName, + lastname: data.lastName, + username: data.username, + access_hash: data.accessHash.value, + language_code: data.langCode + }) + + return info.changes == 1 +} + +async function updateUserPhoto (userId, data) { + const photoId = data.photo?.photoId?.value + if (!photoId) + return + + const tgUserId = db + .prepare(`select telegram_id from users where id = :user_id and coalesce(photo_id, 0) <> :photo_id`) + .safeIntegers(true) + .pluck(true) + .get({user_id: userId, photo_id: photoId }) + + if (!tgUserId) + return + + const photo = await client.invoke(new Api.photos.GetUserPhotos({ + userId: new Api.PeerUser({ userId: tgUserId }), + maxId: photoId, + offset: -1, + limit: 1, + })); + + const file = await client.downloadFile(new Api.InputPhotoFileLocation({ + id: photoId, + accessHash: photo.photos[0]?.accessHash, + fileReference: Buffer.from('random'), + thumbSize: 'a', + }, {})) + + db + .prepare(`update users set photo_id = :photo_id, photo = :photo where id = :user_id`) + .safeIntegers(true) + .run({ user_id: userId, photo_id: photoId, photo: file.toString('base64') }) +} + +async function registerGroup (telegramId, isChannel) { + db + .prepare(`insert or ignore into groups (telegram_id, is_channel) values (:telegram_id, :is_channel)`) + .safeIntegers(true) + .run({ telegram_id: telegramId, is_channel: +isChannel }) + + const row = db + .prepare(`select id, name from groups where telegram_id = :telegram_id`) + .safeIntegers(true) + .get({telegram_id: telegramId}) + + if (!row?.name) { + const entity = isChannel ? { channelId: telegramId } : { chatId: telegramId } + const group = await client.getEntity(isChannel ? new Api.PeerChannel(entity) : new Api.PeerChat(entity)) + + db + .prepare(`update groups set name = :name where id = :group_id`) + .run({ group_id: row.id, name: group.title }) + } + + return row.id +} + +async function attachGroup(groupId, isChannel, projectId) { + const info = db + .prepare(`update groups set project_id = :project_id where id = :group_id and coalesce(project_id, 1) = 1`) + .run({ group_id: groupId, project_id: projectId }) + + if (info.changes == 1) { + const inputPeer = isChannel ? + new Api.InputPeerChannel({ channelId: tgGroupId }) : + new Api.InputPeerChat({ chatlId: tgGroupId }) + + const query = `select (select name from customers where id = p.customer_id) || ' >> ' || p.name from projects p where id = :project_id` + const message = db + .prepare(query) + .pluck(true) + .get({project_id: projectId}) + if (message) + await client.sendMessage(inputPeer, {message}) + } + + return info.changes == 1 +} + +async function onGroupAttach (tgGroupId, isChannel) { + const projectId = db + .prepare(`select project_id from groups where telegram_id = :telegram_id`) + .safeIntegers(true) + .pluck(true) + .get({ telegram_id: tgGroupId }) + + const entity = isChannel ? { channelId: tgGroupId } : { chatId: tgGroupId } + const inputPeer = await client.getEntity( isChannel ? + new Api.InputPeerChannel(entity) : + new Api.InputPeerChat(entity) + ) + + const resultBtn = await client.sendMessage(inputPeer, { + message: 'ReadyOrNot', + buttons: client.buildReplyMarkup([[Button.url('Открыть проект', 'https://t.me/ready_or_not_2025_bot/userapp?startapp=user_' + projectId)]]) + }) + + await client.invoke(new Api.messages.UpdatePinnedMessage({ + peer: inputPeer, + id: resultBtn.id, + unpin: false + })) + +//fs.appendFileSync('./1.log', '\n>' + tgGroupId + ':' + isChannel + '<\n') +} + +async function reloadGroupUsers(groupId, onlyReset) { + db + .prepare(`delete from group_users where group_id = :group_id`) + .run({ group_id: groupId }) + + if (onlyReset) + return + + const group = db + .prepare(`select telegram_id, is_channel, access_hash from groups where id = :group_id`) + .get({ group_id: groupId}) + + console.log (123, group) + + if (!group) + return + + const tgGroupId = group.telegram_id + const isChannel = group.is_channel + let accessHash = group.access_hash + + console.log ('HERE') + + db + .prepare(`update groups set access_hash = :access_hash where id = :group_id`) + .safeIntegers(true) + .run({ + group_id: groupId, + access_hash: accessHash, + }) + + const result = isChannel ? + await client.invoke(new Api.channels.GetParticipants({ + channel: new Api.PeerChannel({ channelId: tgGroupId }), + filter: new Api.ChannelParticipantsRecent(), + limit: 999999, + offset: 0 + })) : await client.invoke(new Api.messages.GetFullChat({ + chatId: tgGroupId, + })) + + const users = result.users.filter(user => !user.bot) + for (const user of users) { + const userId = registerUser(user.id.value, user) + + if (updateUser(userId, user)) { + await updateUserPhoto (userId, user) + + const query = `insert or ignore into group_users (group_id, user_id) values (:group_id, :user_id)` + db.prepare(query).run({ group_id: groupId, user_id: userId }) + } + } +} + +async function registerUpload(data) { + if (!data.projectId || !data.media) + return false + + const uploadGroup = db + .prepare(` + select id, telegram_id, project_id, is_channel, access_hash + from groups + where id = (select upload_group_id + from customers + where id = (select customer_id from projects where id = :project_id limit 1) + limit 1) + limit 1 + `) + .safeIntegers(true) + .get({project_id: data.projectId}) + + if (!uploadGroup || !uploadGroup.telegram_id || uploadGroup.id == data.originGroupId) + return false + + const tgUploadGroupId = uploadGroup.telegram_id + + const peer = uploadGroup.is_channel ? + new Api.PeerChannel({ channelId: tgUploadGroupId }) : + new Api.PeerChat({ chatlId: tgUploadGroupId }) + + let resultId = 0 + + try { + const result = await client.invoke(new Api.messages.SendMedia({ + peer, + media: data.media, + message: data.caption || '', + background: true, + silent: true + })) + + const update = result.updates.find(u => + (u.className == 'UpdateNewMessage' || u.className == 'UpdateNewChannelMessage') && + u.message.className == 'Message' && + (u.message.peerId.channelId?.value == tgUploadGroupId || u.message.peerId.chatId?.value == tgUploadGroupId) && + u.message.media) + + const udoc = update?.message?.media?.document + if (udoc) { + resultId = db + .prepare(` + insert into documents (project_id, origin_group_id, origin_message_id, group_id, message_id, + file_id, access_hash, filename, mime, caption, size, published_by, parent_type, parent_id) + values (:project_id, :origin_group_id, :origin_message_id, :group_id, :message_id, + :file_id, :access_hash, :filename, :mime, :caption, :size, :published_by, :parent_type, :parent_id) + returning id + `) + .safeIntegers(true) + .pluck(true) + .get({ + project_id: data.projectId, + origin_group_id: data.originGroupId, + origin_message_id: data.originMessageId, + group_id: uploadGroup.id, + message_id: update.message.id, + file_id: udoc.id.value, + filename: udoc.attributes.find(attr => attr.className == 'DocumentAttributeFilename')?.fileName, + access_hash: udoc.accessHash.value, + mime: udoc.mimeType, + caption: data.caption, + size: udoc.size.value, + published_by: data.publishedBy, + parent_type: data.parentType, + parent_id: data.parentId + }) + } + + } catch (err) { + fs.appendFileSync('./1.log', '\n\nERR:' + err.message + ':' + JSON.stringify (err.stack)+'\n\n') + console.error('Message.registerUpload: ' + err.message) + } + + return resultId +} + +async function onNewServiceMessage (msg, isChannel) { + const action = msg.action || {} + const tgGroupId = isChannel ? msg.peerId?.channelId?.value : msg.peerId?.chatId?.value + const groupId = await registerGroup(tgGroupId, isChannel) + + // Group/Channel rename + if (action.className == 'MessageActionChatEditTitle') { + const info = db + .prepare(` + update groups + set name = :name, is_channel = :is_channel, last_update_time = :last_update_time + where telegram_id = :telegram_id + `) + .safeIntegers(true) + .run({ + name: action.title, + is_channel: +isChannel, + last_update_time: Math.floor (Date.now() / 1000), + telegram_id: tgGroupId + }) + } + + // Chat to Channel + if (action.className == 'MessageActionChatMigrateTo') { + const info = db + .prepare(` + update groups + set telegram_id = :new_telegram_id, name = :name, is_channel = 1, last_update_time = :last_update_time + where telegram_id = :old_telegram_id + `) + .safeIntegers(true) + .run({ + name: action.title, + last_update_time: Date.now() / 1000, + old_telegram_id: tgGroupId, + new_telegram_id: action.channelId.value + }) + } + + // User/s un/register + if (action.className == 'MessageActionChatAddUser' || action.className == 'MessageActionChatDeleteUser' || + action.className == 'MessageActionChannelAddUser' || action.className == 'MessageActionChannelDeleteUser' + ) { + + const tgUserIds = [action.user, action.users, action.userId].flat().filter(Boolean).map(e => BigInt(e.value)) + const isAdd = action.className == 'MessageActionChatAddUser' || action.className == 'MessageActionChannelAddUser' + + if (tgUserIds.indexOf(bot.id) == -1) { + // Add/remove non-bot users + for (const tgUserId of tgUserIds) { + const userId = registerUser(tgUserId) + + if (isAdd) { + try { + const user = await client.getEntity(new Api.PeerUser({ userId: tgUserId })) + updateUser(userId, user) + await updateUserPhoto (userId, user) + } catch (err) { + console.error(msg.className + ', ' + userId + ': ' + err.message) + } + } + + const query = isAdd ? + `insert or ignore into group_users (group_id, user_id) values (:group_id, :user_id)` : + `delete from group_users where group_id = :group_id and user_id = :user_id` + db.prepare(query).run({ group_id: groupId, user_id: userId }) + } + } + } +} + +async function onNewMessage (msg, isChannel) { + const tgGroupId = isChannel ? msg.peerId?.channelId?.value : msg.peerId?.chatId?.value + const groupId = await registerGroup(tgGroupId, isChannel) + + // Document is detected + if (msg.media?.document) { + const doc = msg.media.document + + const projectId = db + .prepare(`select project_id from groups where telegram_id = :telegram_id`) + .safeIntegers(true) + .pluck(true) + .get({telegram_id: tgGroupId}) + + const media = new Api.InputMediaDocument({ + id: new Api.InputDocument({ + id: doc.id.value, + accessHash: doc.accessHash.value, + fileReference: doc.fileReference + }) + }) + + await registerUpload({ + projectId, + media, + caption: msg.message, + originGroupId: groupId, + originMessageId: msg.id, + parentType: 0, + publishedBy: registerUser (msg.fromId?.userId?.value) + }) + } + + if (msg.message?.startsWith('KEY-')) { + let projectName = db + .prepare(` + select name + from projects + where id in ( + select project_id from groups where id = :group_id + union + select id from projects where upload_group_id = :group_id) + `) + .pluck(true) + .get({ group_id: groupId }) + + if (projectName) + return await bot.sendMessage(groupId, 'Группа уже используется на проекте ' + projectName) + + const [_, time64, key] = msg.message.substr(3).split('-') + const now = Math.floor(Date.now() / 1000) + const time = Buffer.from(time64, 'base64') + + if (now - 3600 >= time && time >= now) + return await bot.sendMessage(groupId, 'Время действия ключа для привязки истекло') + + const projectId = db + .prepare(`select id from projects where generate_key(id, :time) = :key`) + .pluck(true) + .get({ key: msg.message.trim(), time }) + + if (projectId) { + await attachGroup(groupId, isChannel, projectId) + await onGroupAttach(tgGroupId, isChannel) + } + } + + if (msg.message?.startsWith('/start')) { + // Called by https://t.me/ready_or_not_2025_bot?startgroup= + if (/start@ready_or_not_2025_bot (-|)([\d]+)$/g.test(msg.message)) { + const tgUserId = msg.fromId?.userId?.value + const param = +msg.message.split(' ')[1] + + // Set upload group for customer + if (param < 0) { + const customerId = -param + + db + .prepare(` + update customers + set upload_group_id = :group_id + where id = :customer_id and telegram_user_id = :telegram_user_id + `) + .safeIntegers(true) + .run({ + group_id: groupId, + customer_id: customerId, + telegram_user_id: tgUserId + }) + } + + // Add group to project + if (param > 0) { + const projectId = param + + const customerId = db + .prepare(`select customer_id from projects where id = :project_id`) + .pluck(true) + .get({project_id: projectId}) + + db + .prepare(` + update groups + set project_id = :project_id + where id = :group_id and exists( + select 1 + from customers + where id = :customer_id and telegram_user_id = :telegram_user_id) + `) + .safeIntegers(true) + .run({ + project_id: projectId, + group_id: groupId, + customer_id: customerId, + telegram_user_id: tgUserId + }) + + await reloadGroupUsers(groupId, false) + await onGroupAttach(tgGroupId, isChannel) + } + } + } +} + +async function onNewUserMessage (msg) { + if (msg.message == '/start' && msg.peerId?.className == 'PeerUser') { + const tgUserId = msg.peerId?.userId?.value + const userId = registerUser(tgUserId) + + try { + const user = await client.getEntity(new Api.PeerUser({ userId: tgUserId })) + updateUser(userId, user) + await updateUserPhoto (userId, user) + + const inputPeer = new Api.InputPeerUser({userId: tgUserId, accessHash: user.accessHash.value}) + const resultBtn = await client.sendMessage(inputPeer, { + message: 'Сообщение от бота', + buttons: client.buildReplyMarkup([ + [Button.url('Админка', 'https://t.me/ready_or_not_2025_bot/userapp?startapp=admin')], + [Button.url('Пользователь', 'https://t.me/ready_or_not_2025_bot/userapp?startapp=user')] + ]) + }) + } catch (err) { + console.error(msg.className + ', ' + userId + ': ' + err.message) + } + } +} + +async function onUpdatePaticipant (update, isChannel) { + const tgGroupId = isChannel ? update.channelId?.value : update.chatlId?.value + if (!tgGroupId || update.userId?.value != bot.id) + return + + const groupId = await registerGroup (tgGroupId, isChannel) + + const isBan = update.prevParticipant && !update.newParticipant + const isAdd = (!update.prevParticipant || update.prevParticipant?.className == 'ChannelParticipantBanned') && update.newParticipant + + if (isBan || isAdd) + await reloadGroupUsers(groupId, isBan) + + if (isBan) { + db + .prepare(`update groups set project_id = null where id = :group_id`) + .run({group_id: groupId}) + } + + const botCanBan = update.newParticipant?.adminRights?.banUsers || 0 + db + .prepare(`update groups set bot_can_ban = :bot_can_ban where id = :group_id`) + .run({group_id: groupId, bot_can_ban: +botCanBan}) +} + +class Bot extends EventEmitter { + + async start (apiId, apiHash, botAuthToken) { + this.id = 7236504417n + + client = new TelegramClient(session, apiId, apiHash, {}) + + client.addEventHandler(async (update) => { + if (update.className == 'UpdateConnectionState') + return + + if (update.className == 'UpdateNewMessage' || update.className == 'UpdateNewChannelMessage') { + const msg = update?.message + const isChannel = update.className == 'UpdateNewChannelMessage' + + if (!msg) + return + + const result = msg.peerId?.className == 'PeerUser' ? await onNewUserMessage(msg) : + msg.className == 'MessageService' ? await onNewServiceMessage(msg, isChannel) : + await onNewMessage(msg, isChannel) + } + + if (update.className == 'UpdateChatParticipant' || update.className == 'UpdateChannelParticipant') + await onUpdatePaticipant(update, update.className == 'UpdateChannelParticipant') + }) + + await client.start({botAuthToken}) + } + + async uploadDocument(projectId, fileName, mime, data, parentType, parentId, publishedBy) { + const file = await client.uploadFile({ file: new CustomFile(fileName, data.length, '', data), workers: 1 }) + + const media = new Api.InputMediaUploadedDocument({ + file, + mimeType: mime, + attributes: [new Api.DocumentAttributeFilename({ fileName })] + }) + + return await registerUpload({ + projectId, + media, + parentType, + parentId, + publishedBy + }) + } + + async downloadDocument(projectId, documentId) { + const document = db + .prepare(` + select file_id, access_hash, '' thumbSize, filename, mime + from documents where id = :document_id and project_id = :project_id + `) + .safeIntegers(true) + .get({project_id: projectId, document_id: documentId}) + + if (!document) + return false + + const result = await client.downloadFile(new Api.InputDocumentFileLocation({ + id: document.file_id, + accessHash: document.access_hash, + fileReference: Buffer.from(document.filename), + thumbSize: '' + }, {})) + + return { + filename: document.filename, + mime: document.mime, + size: result.length, + data: result + } + } + + async reloadGroupUsers(groupId, onlyReset) { + return reloadGroupUsers(groupId, onlyReset) + } + + async sendMessage (groupId, message) { + const group = db + .prepare(`select telegram_id, is_channel from groups where id = :group_id`) + .get({ group_id: groupId}) + + if (!group) + return + + const entity = group.is_channel ? { channelId: group.telegram_id } : { chatId: group.telegram_id } + const inputPeer = await client.getEntity( group.is_channel ? + new Api.InputPeerChannel(entity) : + new Api.InputPeerChat(entity) + ) + + await client.sendMessage(inputPeer, {message}) + + const delay = ms => new Promise(resolve => setTimeout(resolve, ms)) + await delay(1000) + } + + async leaveGroup (groupId) { + const group = db + .prepare(`select telegram_id, is_channel from groups where id = :group_id`) + .get({ group_id: groupId}) + + if (!group) + return + + if (group.is_channel) { + const inputPeer = await client.getEntity(new Api.InputPeerChannel({ channelId: group.telegram_id })) + await client.invoke(new Api.channels.LeaveChannel({ channel: inputPeer })) + } else { + await client.invoke(new Api.messages.DeleteChatUser({ chatId: group.telegram_id, userId: this.id })) + } + } +} + +const bot = new Bot() + +module.exports = bot + diff --git a/backend/apps/miniapp.js b/backend/apps/miniapp.js new file mode 100644 index 0000000..7a72498 --- /dev/null +++ b/backend/apps/miniapp.js @@ -0,0 +1,643 @@ +const express = require('express') +const multer = require('multer') +const crypto = require('crypto') +const fs = require('fs') +const contentDisposition = require('content-disposition') + +const bot = require('./bot') +const db = require('../include/db') + +const app = express.Router() +const upload = multer({ + storage: multer.memoryStorage(), + limits: { + fileSize: 10_000_000 // 10mb + } +}) + +function hasAccess(project_id, user_id) { + return !!db + .prepare(` + select 1 + from group_users + where user_id = :user_id and + group_id in (select id from groups where project_id = :project_id) and + not exists(select 1 from user_details where user_id = :user_id and project_id = :project_id and is_blocked = 1) and + not exists(select 1 from projects where id = :project_id and is_deleted = 1) + `) + .get({project_id, user_id}) +} + +const sessions = {} + +app.use((req, res, next) => { + if (req.path == '/user/login') + return next() + + const sid = req.query.sid || req.cookies.sid + req.session = sessions[sid] + if (!req.session) + throw Error('ACCESS_DENIED::401') + + res.locals.user_id = req.session.user_id + next() +}) + + +app.post('/user/login', (req, res, next) => { + db + .prepare(`insert or ignore into users (telegram_id) values (:telegram_id)`) + .safeIntegers(true) + .run(res.locals) + + const user_id = db + .prepare(`select id from users where telegram_id = :telegram_id`) + .safeIntegers(true) + .pluck(true) + .get(res.locals) + + const sid = crypto.randomBytes(64).toString('hex') + req.session = sessions[sid] = {sid, user_id} + res.setHeader('Set-Cookie', [`sid=${sid};httpOnly;path=/`]) + res.locals.user_id = user_id + + res.status(200).json({success: true}) +}) + +app.get('/project', (req, res, next) => { + const where = req.query.id ? ' and p.id = ' + parseInt(req.query.id) : '' + + const rows = db + .prepare(` + select p.id, p.name, p.description, p.logo, + c.name customer_name, c.upload_group_id <> 0 has_upload + from projects p + inner join customers c on p.customer_id = c.id + where p.id in ( + select project_id + from groups + where id in (select group_id from group_users where user_id = :user_id) + ) and not exists(select 1 from user_details where user_id = :user_id and project_id = p.id and is_blocked = 1) + ${where} and is_deleted <> 1 + `) + .all(res.locals) + + if (where && rows.length == 0) + throw Error('NOT_FOUND::404') + + res.status(200).json({success: true, data: where ? rows[0] : rows}) +}) + +app.get('/project/:pid(\\d+)', (req, res, next) => { + res.redirect(req.baseUrl + `/project?id=${req.params.pid}`) +}) + +app.use('/project/:pid(\\d+)/*', (req, res, next) => { + res.locals.project_id = parseInt(req.params.pid) + + if (!hasAccess(res.locals.project_id, res.locals.user_id)) + throw Error('ACCESS_DENIED::401') + + const row = db + .prepare('select customer_id from projects where id = :project_id') + .get(res.locals) + + res.locals.customer_id = row.customer_id + + next() +}) + +app.get('/project/:pid(\\d+)/user', (req, res, next) => { + const where = req.query.id ? ' and u.id = ' + parseInt(req.query.id) : '' + + const users = db + .prepare(` + with actuals (user_id) as ( + select distinct user_id + from group_users + where group_id in (select id from groups where project_id = :project_id) + and group_id in (select group_id from group_users where user_id = :user_id) + ), + contributors (user_id) as ( + select created_by from tasks where project_id = :project_id + union + select assigned_to from tasks where project_id = :project_id + union + select created_by from meetings where project_id = :project_id + union + select published_by from documents where project_id = :project_id + ), + members (user_id, is_leave) as ( + select user_id, 0 is_leave from actuals + union all + select user_id, 1 is_leave from contributors where user_id not in (select user_id from actuals) + ) + select u.id, + u.telegram_id, + u.username, + u.firstname, + u.lastname, + u.photo, + u.json_phone_projects, + ud.fullname, + ud.role, + ud.department, + ud.is_blocked, + (select company_id + from company_users + where user_id = u.id and + company_id in (select id from companies where project_id = :project_id)) company_id, + m.is_leave + from users u + inner join members m on u.id = m.user_id + left join user_details ud on ud.user_id = u.id and ud.project_id = :project_id + where 1 = 1 ${where} + `) + .all(res.locals) + + const companies = db + .prepare('select id, name, email, phone, site, description from companies where project_id = :project_id') + .all(res.locals) + .reduce((companies, row) => { + companies[row.id] = row + return companies + }, {}) + + const mappings = {} + const company_id = users.find(m => m.id == res.locals.user_id).company_id + if (company_id) { + res.locals.company_id = company_id + + db + .prepare('select show_as_id, show_to_id from company_mappings where project_id = :project_id and company_id = :company_id') + .all(res.locals) + .forEach(row => mappings[row.show_to_id] = row.show_to_id) + } + + users.forEach(m => { + m.company = companies[mappings[m.company_id] || m.company_id] + delete m.company_id + }) + + users.forEach(m => { + const isHide = JSON.parse(m.json_phone_projects || []).indexOf(res.locals.project_id) == -1 + if (isHide) + delete m.phone + delete m.json_phone_projects + }) + + if (where && users.length == 0) + throw Error('NOT_FOUND::404') + + res.status(200).json({success: true, data: where ? users[0] : users}) +}) + +app.get('/project/:pid(\\d+)/user/reload', async (req, res, next) => { + const groupIds = db + .prepare(`select id from groups where project_id = :project_id`) + .all(res.locals) + .map(e => e.id) + + const sleep = ms => new Promise(resolve => setTimeout(resolve, ms)) + + for (const groupId of groupIds) { + await bot.reloadGroupUsers(groupId) + await sleep(1000) + } + + res.status(200).json({success: true}) +}) + +app.get('/project/:pid(\\d+)/group', (req, res, next) => { + const where = req.query.id ? ' and id = ' + parseInt(req.query.id) : '' + + const rows = db + .prepare(` + select id, name, telegram_id + from groups + where project_id = :project_id and id in (select group_id from group_users where user_id = :user_id) + ${where} + `) + .all(res.locals) + + if (where && rows.length == 0) + throw Error('NOT_FOUND::404') + + res.status(200).json({success: true, data: where ? rows[0] : rows}) +}) + +app.get('/project/:pid(\\d+)/group/:gid(\\d+)', (req, res, next) => { + res.redirect(req.baseUrl + `/project/${req.params.pid}/group?id=${req.params.gid}`) +}) + +// TASK +app.get('/project/:pid(\\d+)/task', (req, res, next) => { + const where = req.query.id ? ' and t.id = ' + parseInt(req.query.id) : '' + + const rows = db + .prepare(` + select id, name, created_by, assigned_to, priority, status, time_spent, create_date, plan_date, close_date, + (select json_group_array(user_id) from task_users where task_id = t.id) observers, + (select json_group_array(id) from documents where parent_type = 1 and parent_id = t.id) attachments + from tasks t + where project_id = :project_id and + (created_by = :user_id or assigned_to = :user_id or exists(select 1 from task_users where task_id = t.id and user_id = :user_id)) + ${where} + `) + .all(res.locals) + + rows.forEach(row => { + row.observers = JSON.parse(row.observers) + row.attachments = JSON.parse(row.attachments) + }) + + if (where && rows.length == 0) + throw Error('NOT_FOUND::404') + + res.status(200).json({success: true, data: where ? rows[0] : rows}) +}) + +app.get('/project/:pid(\\d+)/task/:tid(\\d+)', (req, res, next) => { + res.redirect(req.baseUrl + `/project/${req.params.pid}/task?id=${req.params.tid}`) +}) + +app.post('/project/:pid(\\d+)/task', (req, res, next) => { + res.locals.name = req.body?.name + res.locals.status = parseInt(req.body?.status) + res.locals.priority = parseInt(req.body?.priority) + res.locals.assigned_to = req.body?.assigned_to ? parseInt(req.body?.assigned_to) : undefined + res.locals.create_date = Math.floor(Date.now() / 1000) + res.locals.plan_date = req.body?.plan_date ? parseInt(req.body?.plan_date) : undefined + + if (res.locals.assigned_to && !hasAccess(res.locals.project_id, res.locals.assigned_to)) + throw Error('INCORRECT_ASSIGNED_TO::400') + + const id = db + .prepare(` + insert into tasks (project_id, name, created_by, assigned_to, priority, status, create_date, plan_date) + values (:project_id, :name, :user_id, :assigned_to, :priority, :status, :create_date, :plan_date) + returning id + `) + .pluck(true) + .get(res.locals) + + res.status(200).json({success: true, data: id}) +}) + +app.use('/project/:pid(\\d+)/task/:tid(\\d+)*', (req, res, next) => { + res.locals.task_id = req.params.tid + + const task = db + .prepare(` + select created_by, assigned_to + from tasks + where id = :task_id and project_id = :project_id + and (created_by = :user_id or assigned_to = :user_id or exists(select 1 from task_users where task_id = :task_id and user_id = :user_id)) + `) + .get(res.locals) + + if (!task) + throw Error('NOT_FOUND::404') + + res.locals.is_author = task.created_by == res.locals.user_id + res.locals.is_assigned = task.assigned_to == res.locals.user_id + + next() +}) + +app.put('/project/:pid(\\d+)/task/:tid(\\d+)', (req, res, next) => { + if (!res.locals.is_author && !res.locals.is_assigned) + throw Error('ACCESS_DENIED::401') + + res.locals.id = res.locals.task_id + res.locals.name = req.body?.name + res.locals.status = parseInt(req.body?.status) + res.locals.priority = parseInt(req.body?.priority) + res.locals.assigned_to = req.body?.assigned_to ? parseInt(req.body?.assigned_to) : undefined + res.locals.plan_date = req.body?.plan_date ? parseInt(req.body?.plan_date) : undefined + + const columns = res.locals.is_author ? ['name', 'assigned_to', 'priority', 'status', 'plan_date', 'time_spent'] : ['status', 'time_spent'] + const info = db + .prepareUpdate('tasks', columns, res.locals, ['id', 'project_id']) + .run(res.locals) + + if (info.changes == 0) + throw Error('NOT_FOUND::404') + + res.status(200).json({success: true}) +}) + +app.delete('/project/:pid(\\d+)/task/:tid(\\d+)', (req, res, next) => { + if (!res.locals.is_author) + throw Error('ACCESS_DENIED::401') + + const info = db + .prepare(`delete from tasks where id = :task_id and project_id = :project_id and created_by = :user_id`) + .run(res.locals) + + if (info.changes == 0) + throw Error('NOT_FOUND::404') + + res.status(200).json({success: true}) +}) + +app.put('/project/:pid(\\d+)/task/:tid(\\d+)/observer', (req, res, next) => { + if (!res.locals.is_author && !res.locals.is_assigned) + throw Error('ACCESS_DENIED::401') + + const user_ids = req.body instanceof Array ? [...new Set(req.body.map(e => parseInt(e)))] : [] + + // Проверка, что выбранные пользователи имеют доступ к проекту + let rows = db + .prepare(` + select user_id + from group_users + where group_id in (select id from groups where project_id = :project_id) + `) + .pluck(true) + .all(res.locals) + + if (user_ids.some(user_id => !rows.contains(user_id))) + throw Error('INACCESSABLE_USER::400') + + res.locals.json_ids = JSON.stringify(user_ids) + + db + .prepare(` + delete from task_users where task_id = :task_id; + insert into task_users (task_id, user_id) select :task_id, value from json_each(:json_ids) + `) + .run(res.locals) + + res.status(200).json({success: true}) +}) + +// MEETINGS +app.get('/project/:pid(\\d+)/meeting', (req, res, next) => { + const where = req.query.id ? ' and m.id = ' + parseInt(req.query.id) : '' + + const rows = db + .prepare(` + select id, name, description, created_by, meet_date, + (select json_group_array(user_id) from meeting_users where meeting_id = m.id) participants, + (select json_group_array(id) from documents where parent_type = 2 and parent_id = m.id) attachments + from meetings m + where project_id = :project_id and + (created_by = :user_id or exists(select 1 from meeting_users where meeting_id = m.id and user_id = :user_id)) + ${where} + `) + .all(res.locals) + + rows.forEach(row => { + row.participants = JSON.parse(row.participants) + row.attachments = JSON.parse(row.attachments) + }) + + if (where && rows.length == 0) + throw Error('NOT_FOUND::404') + + res.status(200).json({success: true, data: where ? rows[0] : rows}) +}) + +app.get('/project/:pid(\\d+)/meeting/:mid(\\d+)', (req, res, next) => { + res.redirect(req.baseUrl + `/project/${req.params.pid}/meeting?id=${req.params.mid}`) +}) + +app.post('/project/:pid(\\d+)/meeting', (req, res, next) => { + res.locals.name = req.body?.name + res.locals.description = req.body?.description + res.locals.meet_date = req.body?.meet_date ? parseInt(req.body?.meet_date) : undefined + + const id = db + .prepare(` + insert into meetings (project_id, name, description, created_by, meet_date) + values (:project_id, :name, :description, :user_id, :meet_date) + returning id + `) + .pluck(true) + .get(res.locals) + + res.status(200).json({success: true, data: id}) +}) + +app.use('/project/:pid(\\d+)/meeting/:mid(\\d+)*', (req, res, next) => { + res.locals.meeting_id = req.params.mid + + const meeting = db + .prepare(` + select created_by + from meetings + where id = :meeting_id and project_id = :project_id + and (created_by = :user_id or exists(select 1 from meeting_users where meeting_id = :meeting_id and user_id = :user_id)) + `) + .get(res.locals) + + if (!meeting) + throw Error('NOT_FOUND::404') + + res.locals.is_author = meeting.created_by == res.locals.user_id + + next() +}) + +app.put('/project/:pid(\\d+)/meeting/:mid(\\d+)', (req, res, next) => { + if (!res.locals.is_author) + throw Error('ACCESS_DENIED::401') + + res.locals.id = res.locals.meeting_id + res.locals.name = req.body?.name + res.locals.description = req.body?.description + res.locals.meet_date = req.body?.meet_date ? parseInt(req.body?.meet_date) : undefined + + const info = db + .prepareUpdate('meetings', ['name', 'description', 'meet_date'], res.locals, ['id', 'project_id']) + .run(res.locals) + + if (info.changes == 0) + throw Error('NOT_FOUND::404') + + res.status(200).json({success: true}) +}) + +app.delete('/project/:pid(\\d+)/meeting/:mid(\\d+)', (req, res, next) => { + if (!res.locals.is_author) + throw Error('ACCESS_DENIED::401') + + const info = db + .prepare(`delete from meetings where id = :meeting_id and project_id = :project_id and created_by = :user_id`) + .run(res.locals) + + if (info.changes == 0) + throw Error('NOT_FOUND::404') + + res.status(200).json({success: true}) +}) + +app.put('/project/:pid(\\d+)/meeting/:mid(\\d+)/participants', (req, res, next) => { + if (!res.locals.is_author) + throw Error('ACCESS_DENIED::401') + + const user_ids = req.body instanceof Array ? [...new Set(req.body.map(e => parseInt(e)))] : [] + + // Проверка, что выбранные пользователи имеют доступ к проекту + let rows = db + .prepare(` + select user_id + from group_users + where group_id in (select id from groups where project_id = :project_id) + `) + .pluck(true) // .raw? + .all(res.locals) + + if (user_ids.some(user_id => rows.indexOf(user_id)) == -1) + throw Error('INACCESSABLE_USER::400') + + db + .prepare(`delete from meeting_users where meeting_id = :meeting_id`) + .run(res.locals) + + res.locals.json_ids = JSON.stringify(user_ids) + db + .prepare(`insert into meeting_users (meeting_id, user_id) select :meeting_id, value from json_each(:json_ids)`) + .run(res.locals) + + res.status(200).json({success: true}) +}) + +// DOCUMENTS +app.get('/project/:pid(\\d+)/document', (req, res, next) => { + const ids = String(req.query.id).split(',').map(e => parseInt(e)).filter(e => e > 0) + const where = ids.length > 0 ? ' and id in (' + ids.join(', ') + ')' : '' + + // Документы + // 1. Из групп, которые есть в проекте и в которых участвует пользователь + // 2. Из задач проекта, где пользователь автор, ответсвенный или наблюдатель + // 3. Из встреч на проекте, где пользователь создатель или участник + // To-Do: отдавать готовую ссылку --> как минимум GROUP_ID надо заменить на tgGroupId + const rows = db + .prepare(` + select id, origin_group_id, origin_message_id, filename, mime, caption, size, published_by, parent_id, parent_type + from documents d + where project_id = :project_id ${where} and ( + origin_group_id in (select group_id from group_users where user_id = :user_id) + or + parent_type = 1 and parent_id in ( + select id + from tasks t + where project_id = :project_id and (created_by = :user_id or assigned_to = :user_id or exists(select 1 from task_users where task_id = t.id and user_id = :user_id)) + ) + or + parent_type = 2 and parent_id in ( + select id + from meetings m + where project_id = :project_id and (created_by = :user_id or exists(select 1 from meeting_users where meeting_id = m.id and user_id = :user_id)) + ) + ) + `) + .all(res.locals) + + if (where && rows.length == 0) + throw Error('NOT_FOUND::404') + + res.status(200).json({success: true, data: ids.length == 1 ? rows[0] : rows}) +}) + +app.post('/project/:pid(\\d+)/:type(task|meeting)/:id(\\d+)/attach', upload.any(), async (req, res, next) => { + const parentType = req.params.type == 'task' ? 1 : 2 + const parentId = req.params.id + + const ids = [] + for (const file of req.files) { + const id = await bot.uploadDocument(res.locals.project_id, file.originalname, file.mimetype, file.buffer, parentType, parentId, res.locals.user_id) + ids.push(id) + } + + res.redirect(req.baseUrl + `/project/${req.params.pid}/document?id=` + ids.join(',')) +}) + +app.use('/project/:pid(\\d+)/document/:did(\\d+)', (req, res, next) => { + res.locals.document_id = req.params.did + + const doc = db + .prepare(`select * from documents where id = :document_id and project_id = :project_id`) + .get(res.locals) + + if (!doc) + throw Error('NOT_FOUND::404') + + if (doc.parent_type == 0) { + res.locals.group_id = doc.group_id + const row = db + .prepare(`select 1 from group_users where group_id = :group_id and user_id = :user_id`) + .get(res.locals) + + if (row) { + res.locals.can_download = true + } + } else { + res.locals.parent_id = doc.parent_id + const parent = doc.parent_type == 1 ? 'task' : 'meeting' + + const row = db + .prepare(` + select 1 + from ${parent}s + where id = :parent_id and project_id = :project_id or + exists(select 1 from ${parent}_users where ${parent}_id = :parent_id and user_id = :user_id) + `) + .get(res.locals) + + if (row) { + res.locals.can_download = true + res.locals.can_delete = doc.published_by == res.locals.user_id + } + } + + next() +}) + +app.get('/project/:pid(\\d+)/document/:did(\\d+)', async (req, res, next) => { + if (!res.locals.can_download) + throw Error('NOT_FOUND::404') + + const file = await bot.downloadDocument(res.locals.project_id, res.locals.document_id) + res.writeHead(200, { + 'Content-Length': file.size, + 'Content-Type': file.mime, + 'Content-Disposition': contentDisposition(file.filename) + }) + + res.end(file.data) +}) + +app.delete('/project/:pid(\\d+)/document/:id(\\d+)', (req, res, next) => { + if (!res.locals.can_delete) + throw Error('NOT_FOUND::404') + + const doc = db + .prepare(`delete from documents where id = :id and project_id = :project_id`) + .run(res.locals) + + res.status(200).json({success: true}) +}) + +app.get('/settings', (req, res, next) => { + const row = db + .prepare(`select coalesce(json_settings, '{}') from users where id = :user_id`) + .pluck(true) + .get(res.locals) + + res.status(200).json({success: true, data: JSON.parse(row)}) +}) + +app.put('/settings', (req, res, next) => { + res.locals.json_settings = JSON.stringify(req.body || {}) + + const row = db + .prepare(`update users set json_settings = :json_settings where id = :user_id`) + .run(res.locals) + + res.status(200).json({success: true}) +}) + +module.exports = app \ No newline at end of file diff --git a/backend/data/db.sqlite b/backend/data/db.sqlite new file mode 100644 index 0000000..438da8f Binary files /dev/null and b/backend/data/db.sqlite differ diff --git a/backend/data/db.sqlite-shm b/backend/data/db.sqlite-shm new file mode 100644 index 0000000..3d2446d Binary files /dev/null and b/backend/data/db.sqlite-shm differ diff --git a/backend/data/db.sqlite-wal b/backend/data/db.sqlite-wal new file mode 100644 index 0000000..1d97cbf Binary files /dev/null and b/backend/data/db.sqlite-wal differ diff --git a/backend/data/init.sql b/backend/data/init.sql new file mode 100644 index 0000000..675cc28 --- /dev/null +++ b/backend/data/init.sql @@ -0,0 +1,166 @@ +pragma foreign_keys = off; + +create table if not exists customers ( + id integer primary key autoincrement, + name text check(name is null or trim(name) <> '' and length(name) < 256), + email text check(email is null or trim(email) <> '' and length(email) < 128), + password text check(password is null or length(password) > 7 and length(password) < 64), + telegram_user_id integer, + plan integer, + json_balance text default '{}', + activation_key text, + is_active integer default 0, + json_company text default '{}', + upload_group_id integer, + json_backup_server text default '{}', + json_backup_params text default '{}' +) strict; + +create table if not exists projects ( + id integer primary key autoincrement, + customer_id integer references customers(id) on delete cascade, + name text not null check(trim(name) <> '' and length(name) < 256), + description text check(description is null or length(description) < 4096), + logo text, + is_deleted integer default 0 +) strict; + +create table if not exists groups ( + id integer primary key autoincrement, + project_id integer references projects(id) on delete cascade, + name text, + telegram_id integer, + access_hash integer, + is_channel integer check(is_channel in (0, 1)) default 0, + bot_can_ban integer default 0, + user_count integer, + last_update_time integer +); +create unique index if not exists idx_groups_telegram_id on groups (telegram_id); + +create table if not exists users ( + id integer primary key autoincrement, + telegram_id integer, + access_hash integer, + firstname text, + lastname text, + username text, + photo_id integer, + photo text, + language_code text, + phone text, + json_phone_projects text default '[]', + json_settings text default '{}' +) strict; +create unique index if not exists idx_users_telegram_id on users (telegram_id); + +create table if not exists user_details ( + user_id integer references users(id) on delete cascade, + project_id integer references projects(id) on delete cascade, + fullname text, + role text, + department text, + is_blocked integer check(is_blocked in (0, 1)) default 0, + primary key (user_id, project_id) +) strict; + +create table if not exists tasks ( + id integer primary key autoincrement, + project_id integer references projects(id) on delete cascade, + name text not null check(trim(name) <> '' and length(name) < 4096), + created_by integer references users(id) on delete set null, + assigned_to integer references users(id) on delete set null, + closed_by integer references users(id) on delete set null, + priority integer check(priority in (0, 1, 2, 3, 4, 5)) default 0, + status integer check(status >= 1 and status <= 10) default 1, + time_spent integer check(time_spent is null or time_spent > 0 and time_spent < 44640), -- one month + create_date integer, + plan_date integer, + close_date integer +) strict; + +create table if not exists meetings ( + id integer primary key autoincrement, + project_id integer references projects(id) on delete cascade, + name text not null check(trim(name) <> '' and length(name) < 4096), + description text check(description is null or length(description) < 4096), + created_by integer references users(id) on delete set null, + meet_date integer +) strict; + +create table if not exists documents ( + id integer primary key autoincrement, + project_id integer references projects(id) on delete cascade, + origin_group_id integer references groups(id) on delete set null, + origin_message_id integer, + group_id integer references groups(id) on delete set null, + message_id integer, + file_id integer, + access_hash integer, + filename text not null check(length(filename) < 256), + mime text check(mime is null or length(mime) < 128), + caption text check(caption is null or length(caption) < 4096), + size integer, + published_by integer references users(id) on delete set null, + parent_type integer check(parent_type in (0, 1, 2)) default 0, + parent_id integer, + backup_state integer default 0 +) strict; + +create table if not exists companies ( + id integer primary key autoincrement, + project_id integer references projects(id) on delete cascade, + name text not null check(length(name) < 4096), + email text check(email is null or length(email) < 128), + phone text check(phone is null or length(phone) < 128), + site text check(site is null or length(site) < 128), + description text check(description is null or length(description) < 4096), + logo text +) strict; + +create table if not exists company_mappings ( + project_id integer references projects(id) on delete cascade, + company_id integer references companies(id) on delete cascade, + show_as_id integer references companies(id) on delete cascade, + show_to_id integer references companies(id) on delete cascade +) strict; + +create table if not exists task_users ( + task_id integer references tasks(id) on delete cascade, + user_id integer references users(id) on delete cascade, + primary key (task_id, user_id) +) without rowid; + +create table if not exists meeting_users ( + meeting_id integer references meetings(id) on delete cascade, + user_id integer references users(id) on delete cascade, + primary key (meeting_id, user_id) +) without rowid; + +create table if not exists company_users ( + company_id integer references companies(id) on delete cascade, + user_id integer references users(id) on delete cascade, + primary key (company_id, user_id) +) without rowid; + +create table if not exists group_users ( + group_id integer references groups(id) on delete cascade, + user_id integer references users(id) on delete cascade, + primary key (group_id, user_id) +) without rowid; + +pragma foreign_keys = on; + + +create trigger if not exists trg_groups_update after update +on groups +when NEW.project_id is null +begin + delete from group_users where group_id = NEW.id; +end; + + + + + + diff --git a/backend/include/db.js b/backend/include/db.js new file mode 100644 index 0000000..7d65f17 --- /dev/null +++ b/backend/include/db.js @@ -0,0 +1,57 @@ +const fs = require('fs') +const crypto = require('crypto') +const sqlite3 = require('better-sqlite3') + +const db = sqlite3(`./data/db.sqlite`) +db.pragma('journal_mode = WAL') + +db.exec(fs.readFileSync('./data/init.sql', 'utf8')) + +/* +db.exec(`attach database './data/backup.sqlite' as backup`) + +db.function('backup', (tblname, ...values) => { + db + .prepare(`insert into backup.${tblname} select ` + values.map(e => '?').join(', ')) + .run(values) +}) + +const backupQuery = db + .prepare(` + select group_concat(tbl || char(13) || trg, char(13) || char(13)) from ( + select 'create table if not exists backup.' || t.name || ' (' || group_concat(c.name || ' ' || c.type, ', ') || ', time integer);' tbl, + 'create trigger if not exists trg_' || t.name || '_delete after delete on ' || t.name || ' for each row begin ' || + ' select backup (' || t.name || ',' || group_concat('OLD.' || c.name, ', ') || ', strftime(''%s'', ''now'')); end;' trg + from sqlite_master t left join pragma_table_xinfo c on t.tbl_name = c.arg and c.schema = 'main' + where t.sql is not null and t.type = 'table' and t.name <> 'sqlite_sequence' + group by t.type, t.name order by t.type, t.name) + `) + .pluck(true) + .get() +db.exec(backupQuery) +*/ + +db.function('generate_key', (id, time) => { + return [ + 'KEY', + Buffer.from(time + '').toString('base64'), + crypto.createHash('md5').update(`sa${time}-${id}lt`).digest('hex') + ].join('-') +}) + +process.on('exit', () => db.close()) +process.on('SIGHUP', () => process.exit(128 + 1)) +process.on('SIGINT', () => process.exit(128 + 2)) +process.on('SIGTERM', () => process.exit(128 + 15)) + +db.prepareUpdate = function (table, columns, data, where) { + const dataColumns = columns.filter(col => col in data) + if (dataColumns.length == 0) + throw Error('SQLite Error: No data to update') + + return db.prepare(`update "${table}" ` + + `set ` + dataColumns.map(col => `"${col}" = :${col}`).join(', ') + + ` where ` + where.map(col => `"${col}" = :${col}`).join(' and ')) +} + +module.exports = db \ No newline at end of file diff --git a/backend/package-lock.json b/backend/package-lock.json new file mode 100644 index 0000000..d1bef8c --- /dev/null +++ b/backend/package-lock.json @@ -0,0 +1,1990 @@ +{ + "name": "telegram-bot", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "telegram-bot", + "version": "1.0.0", + "license": "ISC", + "dependencies": { + "better-sqlite3": "^11.8.0", + "body-parser": "^1.20.3", + "content-disposition": "^0.5.4", + "cookie-parser": "^1.4.7", + "express": "^4.21.2", + "express-session": "^1.18.1", + "multer": "^1.4.5-lts.1", + "nodemailer": "^6.9.16", + "telegram": "^2.26.16" + } + }, + "node_modules/@cryptography/aes": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/@cryptography/aes/-/aes-0.1.1.tgz", + "integrity": "sha512-PcYz4FDGblO6tM2kSC+VzhhK62vml6k6/YAkiWtyPvrgJVfnDRoHGDtKn5UiaRRUrvUTTocBpvc2rRgTCqxjsg==", + "license": "GPL-3.0-or-later" + }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/append-field": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz", + "integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==", + "license": "MIT" + }, + "node_modules/array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", + "license": "MIT" + }, + "node_modules/async-mutex": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/async-mutex/-/async-mutex-0.3.2.tgz", + "integrity": "sha512-HuTK7E7MT7jZEh1P9GtRW9+aTWiDWWi9InbZ5hjxrnRa39KS4BW04+xLBhYNS2aXhHUIKZSw3gj4Pn1pj+qGAA==", + "license": "MIT", + "dependencies": { + "tslib": "^2.3.1" + } + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/better-sqlite3": { + "version": "11.9.1", + "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-11.9.1.tgz", + "integrity": "sha512-Ba0KR+Fzxh2jDRhdg6TSH0SJGzb8C0aBY4hR8w8madIdIzzC6Y1+kx5qR6eS1Z+Gy20h6ZU28aeyg0z1VIrShQ==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "bindings": "^1.5.0", + "prebuild-install": "^7.1.1" + } + }, + "node_modules/big-integer": { + "version": "1.6.52", + "resolved": "https://registry.npmjs.org/big-integer/-/big-integer-1.6.52.tgz", + "integrity": "sha512-QxD8cf2eVqJOOz63z6JIN9BzvVs/dlySa5HGSBH5xtR8dPteIRQnBxxKqkNTiT6jbDTF6jAfrd4oMcND9RGbQg==", + "license": "Unlicense", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/bindings": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", + "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", + "license": "MIT", + "dependencies": { + "file-uri-to-path": "1.0.0" + } + }, + "node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "license": "MIT", + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, + "node_modules/bl/node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/body-parser": { + "version": "1.20.3", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.3.tgz", + "integrity": "sha512-7rAxByjUMqQ3/bHJy7D6OGXvx/MMc4IqBn/X0fcM1QUcAItpZrBEYhWGem+tzXH90c+G01ypMcYJBO9Y30203g==", + "license": "MIT", + "dependencies": { + "bytes": "3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "http-errors": "2.0.0", + "iconv-lite": "0.4.24", + "on-finished": "2.4.1", + "qs": "6.13.0", + "raw-body": "2.5.2", + "type-is": "~1.6.18", + "unpipe": "1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "license": "MIT" + }, + "node_modules/bufferutil": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/bufferutil/-/bufferutil-4.0.9.tgz", + "integrity": "sha512-WDtdLmJvAuNNPzByAYpRo2rF1Mmradw6gvWsQKf63476DDXmomT9zUiGypLcG4ibIM67vhAj8jJRdbmEws2Aqw==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "node-gyp-build": "^4.3.0" + }, + "engines": { + "node": ">=6.14.2" + } + }, + "node_modules/busboy": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", + "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==", + "dependencies": { + "streamsearch": "^1.1.0" + }, + "engines": { + "node": ">=10.16.0" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/chownr": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", + "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", + "license": "ISC" + }, + "node_modules/concat-stream": { + "version": "1.6.2", + "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-1.6.2.tgz", + "integrity": "sha512-27HBghJxjiZtIk3Ycvn/4kbJk/1uZuJFfuPEns6LaEvpvG1f0hTea8lilrouyo9mVc2GWdcEZ8OLoGmSADlrCw==", + "engines": [ + "node >= 0.8" + ], + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^2.2.2", + "typedarray": "^0.0.6" + } + }, + "node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-parser": { + "version": "1.4.7", + "resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.7.tgz", + "integrity": "sha512-nGUvgXnotP3BsjiLX2ypbQnWoGUPIIfHQNZkkC668ntrzGWEZVW70HDEB1qnNGMicPje6EttlIgzo51YSwNQGw==", + "license": "MIT", + "dependencies": { + "cookie": "0.7.2", + "cookie-signature": "1.0.6" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/cookie-signature": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", + "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==", + "license": "MIT" + }, + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", + "license": "MIT" + }, + "node_modules/d": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/d/-/d-1.0.2.tgz", + "integrity": "sha512-MOqHvMWF9/9MX6nza0KgvFH4HpMU0EF5uUDXqX/BtxtU8NfB0QzRtJ8Oe/6SuS4kbhyzVJwjd97EA4PKrzJ8bw==", + "license": "ISC", + "dependencies": { + "es5-ext": "^0.10.64", + "type": "^2.7.2" + }, + "engines": { + "node": ">=0.12" + } + }, + "node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/decompress-response": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "license": "MIT", + "dependencies": { + "mimic-response": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/deep-extend": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", + "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", + "license": "MIT", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "license": "MIT", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/detect-libc": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.3.tgz", + "integrity": "sha512-bwy0MGW55bG41VqxxypOsdSdGqLwXPI/focwgTYCFMbdUiBAxLg9CFzG08sz2aqzknwiX7Hkl0bQENjg8iLByw==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/dom-serializer": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-1.4.1.tgz", + "integrity": "sha512-VHwB3KfrcOOkelEG2ZOfxqLZdfkil8PtJi4P8N2MMXucZq2yLp75ClViUlOVwyoHEDjYU433Aq+5zWP61+RGag==", + "license": "MIT", + "dependencies": { + "domelementtype": "^2.0.1", + "domhandler": "^4.2.0", + "entities": "^2.0.0" + }, + "funding": { + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, + "node_modules/domelementtype": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", + "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "BSD-2-Clause" + }, + "node_modules/domhandler": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-4.3.1.tgz", + "integrity": "sha512-GrwoxYN+uWlzO8uhUXRl0P+kHE4GtVPfYzVLcUxPL7KNdHKj66vvlhiweIHqYYXWlw+T8iLMp42Lm67ghw4WMQ==", + "license": "BSD-2-Clause", + "dependencies": { + "domelementtype": "^2.2.0" + }, + "engines": { + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/domutils": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-2.8.0.tgz", + "integrity": "sha512-w96Cjofp72M5IIhpjgobBimYEfoPjx1Vx0BSX9P30WBdZW2WIKU0T1Bd0kz2eNZ9ikjKgHbEyKx8BB6H1L3h3A==", + "license": "BSD-2-Clause", + "dependencies": { + "dom-serializer": "^1.0.1", + "domelementtype": "^2.2.0", + "domhandler": "^4.2.0" + }, + "funding": { + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/end-of-stream": { + "version": "1.4.4", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.4.tgz", + "integrity": "sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q==", + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/entities": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-2.2.0.tgz", + "integrity": "sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A==", + "license": "BSD-2-Clause", + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es5-ext": { + "version": "0.10.64", + "resolved": "https://registry.npmjs.org/es5-ext/-/es5-ext-0.10.64.tgz", + "integrity": "sha512-p2snDhiLaXe6dahss1LddxqEm+SkuDvV8dnIQG0MWjyHpcMNfXKPE+/Cc0y+PhxJX3A4xGNeFCj5oc0BUh6deg==", + "hasInstallScript": true, + "license": "ISC", + "dependencies": { + "es6-iterator": "^2.0.3", + "es6-symbol": "^3.1.3", + "esniff": "^2.0.1", + "next-tick": "^1.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/es6-iterator": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/es6-iterator/-/es6-iterator-2.0.3.tgz", + "integrity": "sha512-zw4SRzoUkd+cl+ZoE15A9o1oQd920Bb0iOJMQkQhl3jNc03YqVjAhG7scf9C5KWRU/R13Orf588uCC6525o02g==", + "license": "MIT", + "dependencies": { + "d": "1", + "es5-ext": "^0.10.35", + "es6-symbol": "^3.1.1" + } + }, + "node_modules/es6-symbol": { + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/es6-symbol/-/es6-symbol-3.1.4.tgz", + "integrity": "sha512-U9bFFjX8tFiATgtkJ1zg25+KviIXpgRvRHS8sau3GfhVzThRQrOeksPeT0BWW2MNZs1OEWJ1DPXOQMn0KKRkvg==", + "license": "ISC", + "dependencies": { + "d": "^1.0.2", + "ext": "^1.7.0" + }, + "engines": { + "node": ">=0.12" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/esniff": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/esniff/-/esniff-2.0.1.tgz", + "integrity": "sha512-kTUIGKQ/mDPFoJ0oVfcmyJn4iBDRptjNVIzwIFR7tqWXdVI9xfA2RMwY/gbSpJG3lkdWNEjLap/NqVHZiJsdfg==", + "license": "ISC", + "dependencies": { + "d": "^1.0.1", + "es5-ext": "^0.10.62", + "event-emitter": "^0.3.5", + "type": "^2.7.2" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/event-emitter": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/event-emitter/-/event-emitter-0.3.5.tgz", + "integrity": "sha512-D9rRn9y7kLPnJ+hMq7S/nhvoKwwvVJahBi2BPmx3bvbsEdK3W9ii8cBSGjP+72/LnM4n6fo3+dkCX5FeTQruXA==", + "license": "MIT", + "dependencies": { + "d": "1", + "es5-ext": "~0.10.14" + } + }, + "node_modules/expand-template": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", + "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", + "license": "(MIT OR WTFPL)", + "engines": { + "node": ">=6" + } + }, + "node_modules/express": { + "version": "4.21.2", + "resolved": "https://registry.npmjs.org/express/-/express-4.21.2.tgz", + "integrity": "sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "1.20.3", + "content-disposition": "0.5.4", + "content-type": "~1.0.4", + "cookie": "0.7.1", + "cookie-signature": "1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "1.3.1", + "fresh": "0.5.2", + "http-errors": "2.0.0", + "merge-descriptors": "1.0.3", + "methods": "~1.1.2", + "on-finished": "2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "0.1.12", + "proxy-addr": "~2.0.7", + "qs": "6.13.0", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "0.19.0", + "serve-static": "1.16.2", + "setprototypeof": "1.2.0", + "statuses": "2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express-session": { + "version": "1.18.1", + "resolved": "https://registry.npmjs.org/express-session/-/express-session-1.18.1.tgz", + "integrity": "sha512-a5mtTqEaZvBCL9A9aqkrtfz+3SMDhOVUnjafjo+s7A9Txkq+SVX2DLvSp1Zrv4uCXa3lMSK3viWnh9Gg07PBUA==", + "license": "MIT", + "dependencies": { + "cookie": "0.7.2", + "cookie-signature": "1.0.7", + "debug": "2.6.9", + "depd": "~2.0.0", + "on-headers": "~1.0.2", + "parseurl": "~1.3.3", + "safe-buffer": "5.2.1", + "uid-safe": "~2.1.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/express-session/node_modules/cookie-signature": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", + "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", + "license": "MIT" + }, + "node_modules/express/node_modules/cookie": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.1.tgz", + "integrity": "sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ext": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/ext/-/ext-1.7.0.tgz", + "integrity": "sha512-6hxeJYaL110a9b5TEJSj0gojyHQAmA2ch5Os+ySCiA1QGdS697XWY1pzsrSjqA9LDEEgdB/KypIlR59RcLuHYw==", + "license": "ISC", + "dependencies": { + "type": "^2.7.2" + } + }, + "node_modules/file-uri-to-path": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", + "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", + "license": "MIT" + }, + "node_modules/finalhandler": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.1.tgz", + "integrity": "sha512-6BN9trH7bp3qvnrRyzsBz+g3lZxTNZTbVO2EV1CS0WIcDbawYVdYvGflME/9QP0h0pYlCDBCTjYa9nZzMDpyxQ==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "on-finished": "2.4.1", + "parseurl": "~1.3.3", + "statuses": "2.0.1", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fs-constants": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", + "license": "MIT" + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/github-from-package": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", + "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", + "license": "MIT" + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "license": "ISC" + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/htmlparser2": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-6.1.0.tgz", + "integrity": "sha512-gyyPk6rgonLFEDGoeRgQNaEUvdJ4ktTmmUh/h2t7s+M8oPpIPxgNACWa+6ESR57kXstwqPiCut0V8NRpcwgU7A==", + "funding": [ + "https://github.com/fb55/htmlparser2?sponsor=1", + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "MIT", + "dependencies": { + "domelementtype": "^2.0.1", + "domhandler": "^4.0.0", + "domutils": "^2.5.2", + "entities": "^2.0.0" + } + }, + "node_modules/http-errors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", + "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==", + "license": "MIT", + "dependencies": { + "depd": "2.0.0", + "inherits": "2.0.4", + "setprototypeof": "1.2.0", + "statuses": "2.0.1", + "toidentifier": "1.0.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "license": "ISC" + }, + "node_modules/ip-address": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-9.0.5.tgz", + "integrity": "sha512-zHtQzGojZXTwZTHQqra+ETKd4Sn3vgi7uBmlPoXVWZqYvuKmtI0l/VZTjqGmJY9x88GGOaZ9+G9ES8hC4T4X8g==", + "license": "MIT", + "dependencies": { + "jsbn": "1.1.0", + "sprintf-js": "^1.1.3" + }, + "engines": { + "node": ">= 12" + } + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-typedarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-typedarray/-/is-typedarray-1.0.0.tgz", + "integrity": "sha512-cyA56iCMHAh5CdzjJIa4aohJyeO1YbwLi3Jc35MmRU6poroFjIGZzUzupGiRPOjgHg9TLu43xbpwXk523fMxKA==", + "license": "MIT" + }, + "node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "license": "MIT" + }, + "node_modules/jsbn": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/jsbn/-/jsbn-1.1.0.tgz", + "integrity": "sha512-4bYVV3aAMtDTTu4+xsDYa6sy9GyJ69/amsu9sYF2zqjiEoZA5xJi3BrfX3uY+/IekIu7MwdObdbDWpoZdBv3/A==", + "license": "MIT" + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/merge-descriptors": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mimic-response": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/mkdirp": { + "version": "0.5.6", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz", + "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", + "license": "MIT", + "dependencies": { + "minimist": "^1.2.6" + }, + "bin": { + "mkdirp": "bin/cmd.js" + } + }, + "node_modules/mkdirp-classic": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", + "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", + "license": "MIT" + }, + "node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/multer": { + "version": "1.4.5-lts.2", + "resolved": "https://registry.npmjs.org/multer/-/multer-1.4.5-lts.2.tgz", + "integrity": "sha512-VzGiVigcG9zUAoCNU+xShztrlr1auZOlurXynNvO9GiWD1/mTBbUljOKY+qMeazBqXgRnjzeEgJI/wyjJUHg9A==", + "license": "MIT", + "dependencies": { + "append-field": "^1.0.0", + "busboy": "^1.0.0", + "concat-stream": "^1.5.2", + "mkdirp": "^0.5.4", + "object-assign": "^4.1.1", + "type-is": "^1.6.4", + "xtend": "^4.0.0" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/napi-build-utils": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", + "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==", + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/next-tick": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/next-tick/-/next-tick-1.1.0.tgz", + "integrity": "sha512-CXdUiJembsNjuToQvxayPZF9Vqht7hewsvy2sOWafLvi2awflj9mOC6bHIg50orX8IJvWKY9wYQ/zB2kogPslQ==", + "license": "ISC" + }, + "node_modules/node-abi": { + "version": "3.74.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.74.0.tgz", + "integrity": "sha512-c5XK0MjkGBrQPGYG24GBADZud0NCbznxNx0ZkS+ebUTrmV1qTDxPxSL8zEAPURXSbLRWVexxmP4986BziahL5w==", + "license": "MIT", + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/node-gyp-build": { + "version": "4.8.4", + "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz", + "integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==", + "license": "MIT", + "bin": { + "node-gyp-build": "bin.js", + "node-gyp-build-optional": "optional.js", + "node-gyp-build-test": "build-test.js" + } + }, + "node_modules/node-localstorage": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/node-localstorage/-/node-localstorage-2.2.1.tgz", + "integrity": "sha512-vv8fJuOUCCvSPjDjBLlMqYMHob4aGjkmrkaE42/mZr0VT+ZAU10jRF8oTnX9+pgU9/vYJ8P7YT3Vd6ajkmzSCw==", + "license": "MIT", + "dependencies": { + "write-file-atomic": "^1.1.4" + }, + "engines": { + "node": ">=0.12" + } + }, + "node_modules/nodemailer": { + "version": "6.10.0", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-6.10.0.tgz", + "integrity": "sha512-SQ3wZCExjeSatLE/HBaXS5vqUOQk6GtBdIIKxiFdmm01mOQZX/POJkO3SUX1wDiYcwUOJwT23scFSC9fY2H8IA==", + "license": "MIT-0", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/on-headers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.0.2.tgz", + "integrity": "sha512-pZAE+FJLoyITytdqK0U5s+FIpjN0JP3OzFi/u8Rx+EV5/W+JTWGXG8xFzevE7AjBfDqHv/8vL8qQsIhHnqRkrA==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/pako": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/pako/-/pako-2.1.0.tgz", + "integrity": "sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug==", + "license": "(MIT AND Zlib)" + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-browserify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz", + "integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==", + "license": "MIT" + }, + "node_modules/path-to-regexp": { + "version": "0.1.12", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz", + "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==", + "license": "MIT" + }, + "node_modules/prebuild-install": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", + "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", + "license": "MIT", + "dependencies": { + "detect-libc": "^2.0.0", + "expand-template": "^2.0.3", + "github-from-package": "0.0.0", + "minimist": "^1.2.3", + "mkdirp-classic": "^0.5.3", + "napi-build-utils": "^2.0.0", + "node-abi": "^3.3.0", + "pump": "^3.0.0", + "rc": "^1.2.7", + "simple-get": "^4.0.0", + "tar-fs": "^2.0.0", + "tunnel-agent": "^0.6.0" + }, + "bin": { + "prebuild-install": "bin.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "license": "MIT" + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/pump": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.2.tgz", + "integrity": "sha512-tUPXtzlGM8FE3P0ZL6DVs/3P58k9nk8/jZeQCurTJylQA8qFYzHFfhBJkuqyE0FifOsQ0uKWekiZ5g8wtr28cw==", + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "node_modules/qs": { + "version": "6.13.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz", + "integrity": "sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.0.6" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/random-bytes": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/random-bytes/-/random-bytes-1.0.0.tgz", + "integrity": "sha512-iv7LhNVO047HzYR3InF6pUcUsPQiHTM1Qal51DcGSuZFBil1aBBWG5eHPNek7bvILMaYJ/8RU1e8w1AMdHmLQQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.2.tgz", + "integrity": "sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==", + "license": "MIT", + "dependencies": { + "bytes": "3.1.2", + "http-errors": "2.0.0", + "iconv-lite": "0.4.24", + "unpipe": "1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/rc": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", + "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", + "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", + "dependencies": { + "deep-extend": "^0.6.0", + "ini": "~1.3.0", + "minimist": "^1.2.0", + "strip-json-comments": "~2.0.1" + }, + "bin": { + "rc": "cli.js" + } + }, + "node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/readable-stream/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/real-cancellable-promise": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/real-cancellable-promise/-/real-cancellable-promise-1.2.1.tgz", + "integrity": "sha512-JwhiWJTMMyzFYfpKsiSb8CyQktCi1MZ8ZBn3wXvq28qXDh8Y5dM7RYzgW3r6SV22JTEcof8pRsvDp4GxLmGIxg==", + "license": "MIT" + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.7.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.1.tgz", + "integrity": "sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/send": { + "version": "0.19.0", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.0.tgz", + "integrity": "sha512-dW41u5VfLXu8SJh5bwRmyYUbAoSB3c9uQh6L8h/KtsFREPWpbX1lrljJo186Jc4nmci/sGUZ9a0a0J2zgfq2hw==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "0.5.2", + "http-errors": "2.0.0", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "2.4.1", + "range-parser": "~1.2.1", + "statuses": "2.0.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/node_modules/encodeurl": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", + "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/send/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/serve-static": { + "version": "1.16.2", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.2.tgz", + "integrity": "sha512-VqpjJZKadQB/PEbEwvFdO43Ax5dFBZ2UECszz8bQ7pi7wt//PWe1P6MN7eCnjsatYtBT6EuiClbjSWP2WrIoTw==", + "license": "MIT", + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "0.19.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/simple-concat": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", + "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/simple-get": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", + "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "decompress-response": "^6.0.0", + "once": "^1.3.1", + "simple-concat": "^1.0.0" + } + }, + "node_modules/slide": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/slide/-/slide-1.1.6.tgz", + "integrity": "sha512-NwrtjCg+lZoqhFU8fOwl4ay2ei8PaqCBOUV3/ektPY9trO1yQ1oXEfmHAhKArUVUr/hOHvy5f6AdP17dCM0zMw==", + "license": "ISC", + "engines": { + "node": "*" + } + }, + "node_modules/smart-buffer": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", + "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==", + "license": "MIT", + "engines": { + "node": ">= 6.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/socks": { + "version": "2.8.4", + "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.4.tgz", + "integrity": "sha512-D3YaD0aRxR3mEcqnidIs7ReYJFVzWdd6fXJYUM8ixcQcJRGTka/b3saV0KflYhyVJXKhb947GndU35SxYNResQ==", + "license": "MIT", + "dependencies": { + "ip-address": "^9.0.5", + "smart-buffer": "^4.2.0" + }, + "engines": { + "node": ">= 10.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/sprintf-js": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz", + "integrity": "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==", + "license": "BSD-3-Clause" + }, + "node_modules/statuses": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", + "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/store2": { + "version": "2.14.4", + "resolved": "https://registry.npmjs.org/store2/-/store2-2.14.4.tgz", + "integrity": "sha512-srTItn1GOvyvOycgxjAnPA63FZNwy0PTyUBFMHRM+hVFltAeoh0LmNBz9SZqUS9mMqGk8rfyWyXn3GH5ReJ8Zw==", + "license": "MIT" + }, + "node_modules/streamsearch": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", + "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/string_decoder/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/strip-json-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tar-fs": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.2.tgz", + "integrity": "sha512-EsaAXwxmx8UB7FRKqeozqEPop69DXcmYwTQwXvyAPF352HJsPdkVhvTaDPYqfNgruveJIJy3TA2l+2zj8LJIJA==", + "license": "MIT", + "dependencies": { + "chownr": "^1.1.1", + "mkdirp-classic": "^0.5.2", + "pump": "^3.0.0", + "tar-stream": "^2.1.4" + } + }, + "node_modules/tar-stream": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "license": "MIT", + "dependencies": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/tar-stream/node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/telegram": { + "version": "2.26.22", + "resolved": "https://registry.npmjs.org/telegram/-/telegram-2.26.22.tgz", + "integrity": "sha512-EIj7Yrjiu0Yosa3FZ/7EyPg9s6UiTi/zDQrFmR/2Mg7pIUU+XjAit1n1u9OU9h2oRnRM5M+67/fxzQluZpaJJg==", + "license": "MIT", + "dependencies": { + "@cryptography/aes": "^0.1.1", + "async-mutex": "^0.3.0", + "big-integer": "^1.6.48", + "buffer": "^6.0.3", + "htmlparser2": "^6.1.0", + "mime": "^3.0.0", + "node-localstorage": "^2.2.1", + "pako": "^2.0.3", + "path-browserify": "^1.0.1", + "real-cancellable-promise": "^1.1.1", + "socks": "^2.6.2", + "store2": "^2.13.0", + "ts-custom-error": "^3.2.0", + "websocket": "^1.0.34" + }, + "optionalDependencies": { + "bufferutil": "^4.0.3", + "utf-8-validate": "^5.0.5" + } + }, + "node_modules/telegram/node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, + "node_modules/telegram/node_modules/mime": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-3.0.0.tgz", + "integrity": "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/ts-custom-error": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/ts-custom-error/-/ts-custom-error-3.3.1.tgz", + "integrity": "sha512-5OX1tzOjxWEgsr/YEUWSuPrQ00deKLh6D7OTWcvNHm12/7QPyRh8SYpyWvA4IZv8H/+GQWQEh/kwo95Q9OVW1A==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/tunnel-agent": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", + "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + }, + "engines": { + "node": "*" + } + }, + "node_modules/type": { + "version": "2.7.3", + "resolved": "https://registry.npmjs.org/type/-/type-2.7.3.tgz", + "integrity": "sha512-8j+1QmAbPvLZow5Qpi6NCaN8FB60p/6x8/vfNqOk/hC+HuvFZhL4+WfekuhQLiqFZXOgQdrs3B+XxEmCc6b3FQ==", + "license": "ISC" + }, + "node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/typedarray": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", + "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==", + "license": "MIT" + }, + "node_modules/typedarray-to-buffer": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/typedarray-to-buffer/-/typedarray-to-buffer-3.1.5.tgz", + "integrity": "sha512-zdu8XMNEDepKKR+XYOXAVPtWui0ly0NtohUscw+UmaHiAWT8hrV1rr//H6V+0DvJ3OQ19S979M0laLfX8rm82Q==", + "license": "MIT", + "dependencies": { + "is-typedarray": "^1.0.0" + } + }, + "node_modules/uid-safe": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/uid-safe/-/uid-safe-2.1.5.tgz", + "integrity": "sha512-KPHm4VL5dDXKz01UuEd88Df+KzynaohSL9fBh096KWAxSKZQDI2uBrVqtvRM4rwrIrRRKsdLNML/lnaaVSRioA==", + "license": "MIT", + "dependencies": { + "random-bytes": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/utf-8-validate": { + "version": "5.0.10", + "resolved": "https://registry.npmjs.org/utf-8-validate/-/utf-8-validate-5.0.10.tgz", + "integrity": "sha512-Z6czzLq4u8fPOyx7TU6X3dvUZVvoJmxSQ+IcrlmagKhilxlhZgxPK6C5Jqbkw1IDUmFTM+cz9QDnnLTwDz/2gQ==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "node-gyp-build": "^4.3.0" + }, + "engines": { + "node": ">=6.14.2" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/websocket": { + "version": "1.0.35", + "resolved": "https://registry.npmjs.org/websocket/-/websocket-1.0.35.tgz", + "integrity": "sha512-/REy6amwPZl44DDzvRCkaI1q1bIiQB0mEFQLUrhz3z2EK91cp3n72rAjUlrTP0zV22HJIUOVHQGPxhFRjxjt+Q==", + "license": "Apache-2.0", + "dependencies": { + "bufferutil": "^4.0.1", + "debug": "^2.2.0", + "es5-ext": "^0.10.63", + "typedarray-to-buffer": "^3.1.5", + "utf-8-validate": "^5.0.2", + "yaeti": "^0.0.6" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "node_modules/write-file-atomic": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-1.3.4.tgz", + "integrity": "sha512-SdrHoC/yVBPpV0Xq/mUZQIpW2sWXAShb/V4pomcJXh92RuaO+f3UTWItiR3Px+pLnV2PvC2/bfn5cwr5X6Vfxw==", + "license": "ISC", + "dependencies": { + "graceful-fs": "^4.1.11", + "imurmurhash": "^0.1.4", + "slide": "^1.1.5" + } + }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "license": "MIT", + "engines": { + "node": ">=0.4" + } + }, + "node_modules/yaeti": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/yaeti/-/yaeti-0.0.6.tgz", + "integrity": "sha512-MvQa//+KcZCUkBTIC9blM+CU9J2GzuTytsOUwf2lidtvkx/6gnEp1QvJv34t9vdjhFmha/mUiNDbN0D0mJWdug==", + "deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.", + "license": "MIT", + "engines": { + "node": ">=0.10.32" + } + } + } +} diff --git a/backend/package.json b/backend/package.json new file mode 100644 index 0000000..8c38a14 --- /dev/null +++ b/backend/package.json @@ -0,0 +1,25 @@ +{ + "name": "telegram-bot", + "version": "1.0.0", + "main": "app.js", + "directories": { + "doc": "docs" + }, + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "author": "", + "license": "ISC", + "description": "", + "dependencies": { + "better-sqlite3": "^11.8.0", + "body-parser": "^1.20.3", + "content-disposition": "^0.5.4", + "cookie-parser": "^1.4.7", + "express": "^4.21.2", + "express-session": "^1.18.1", + "multer": "^1.4.5-lts.1", + "nodemailer": "^6.9.16", + "telegram": "^2.26.16" + } +} diff --git a/backend/public/index.html b/backend/public/index.html new file mode 100644 index 0000000..254ff81 --- /dev/null +++ b/backend/public/index.html @@ -0,0 +1,337 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/backend/public/miniapp.html b/backend/public/miniapp.html new file mode 100644 index 0000000..7c786fb --- /dev/null +++ b/backend/public/miniapp.html @@ -0,0 +1,65 @@ + + + + +
+ + +
+ +
+
ПРОЕКТЫ
+
EMPTY
+
+ +
+
УЧАСТНИКИ
+
+
+ + + + + + \ No newline at end of file diff --git a/i18n-2.xlsm b/i18n-2.xlsm index 5bb50e0..8e5cbbb 100644 Binary files a/i18n-2.xlsm and b/i18n-2.xlsm differ diff --git a/index.html b/index.html index faed010..e7797b8 100644 --- a/index.html +++ b/index.html @@ -12,9 +12,8 @@ - - + @@ -23,7 +22,6 @@ - diff --git a/package-lock.json b/package-lock.json index 1fb1b62..cbf24a2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,7 +22,9 @@ "@eslint/js": "^9.14.0", "@intlify/unplugin-vue-i18n": "^2.0.0", "@quasar/app-vite": "^2.0.0", + "@twa-dev/types": "^8.0.2", "@types/node": "^20.17.30", + "@types/telegram-web-app": "^7.10.1", "@vue/devtools": "^7.7.2", "@vue/eslint-config-typescript": "^14.1.3", "autoprefixer": "^10.4.2", @@ -2069,6 +2071,13 @@ "node": ">=14.16" } }, + "node_modules/@twa-dev/types": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@twa-dev/types/-/types-8.0.2.tgz", + "integrity": "sha512-ICQ6n4NaUPPzV3/GzflVQS6Nnu5QX2vr9OlOG8ZkFf3rSJXzRKazrLAbZlVhCPPWkIW3MMuELPsE6tByrA49qA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/body-parser": { "version": "1.19.5", "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.5.tgz", @@ -2316,6 +2325,13 @@ "@types/send": "*" } }, + "node_modules/@types/telegram-web-app": { + "version": "7.10.1", + "resolved": "https://registry.npmjs.org/@types/telegram-web-app/-/telegram-web-app-7.10.1.tgz", + "integrity": "sha512-GKL659G6lnHRAt3Dt7L1Fa0dwvc+ZZQtJcYrJVJGwjvsbi/Rcp1qKs9pFwb34/KC04+J+TlQj4D7yKo10sBSEw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/yauzl": { "version": "2.10.3", "resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.3.tgz", diff --git a/package.json b/package.json index 9066f85..c3c4c86 100644 --- a/package.json +++ b/package.json @@ -27,7 +27,9 @@ "@eslint/js": "^9.14.0", "@intlify/unplugin-vue-i18n": "^2.0.0", "@quasar/app-vite": "^2.0.0", + "@twa-dev/types": "^8.0.2", "@types/node": "^20.17.30", + "@types/telegram-web-app": "^7.10.1", "@vue/devtools": "^7.7.2", "@vue/eslint-config-typescript": "^14.1.3", "autoprefixer": "^10.4.2", diff --git a/quasar.config.ts b/quasar.config.ts index cb37859..cfae256 100644 --- a/quasar.config.ts +++ b/quasar.config.ts @@ -3,7 +3,6 @@ import { defineConfig } from '#q-app/wrappers' import { fileURLToPath } from 'node:url' -import path from 'node:path' export default defineConfig((ctx) => { return { @@ -16,7 +15,9 @@ export default defineConfig((ctx) => { boot: [ 'i18n', 'axios', - 'global-components' + 'auth-init', + 'global-components', + 'telegram-boot' ], // https://v2.quasar.dev/quasar-cli-vite/quasar-config-file#css @@ -52,6 +53,8 @@ export default defineConfig((ctx) => { }, vueRouterMode: 'history', // available values: 'hash', 'history' + vueDevtools: true, // Должно быть true + devtool: 'source-map', // Для лучшей отладки // vueRouterBase, // vueDevtools, // vueOptionsAPI: false, @@ -101,13 +104,22 @@ export default defineConfig((ctx) => { // Full list of options: https://v2.quasar.dev/quasar-cli-vite/quasar-config-file#devserver devServer: { vueDevtools: true, + port: 9000, + proxy: { + '/api': { + target: 'http://localhost:3000', + changeOrigin: true + } + } // https: true, - open: true // opens browser window automatically + // open: true // opens browser window automatically }, // https://v2.quasar.dev/quasar-cli-vite/quasar-config-file#framework framework: { - config: {}, + config: { + }, + // iconSet: 'material-icons', // Quasar icon set // lang: 'en-US', // Quasar language pack @@ -120,7 +132,7 @@ export default defineConfig((ctx) => { // directives: [], // Quasar plugins - plugins: [] + plugins: [ 'Notify' ] }, // animations: 'all', // --- includes all animations diff --git a/src/App.vue b/src/App.vue index 0711768..2df1ee8 100644 --- a/src/App.vue +++ b/src/App.vue @@ -3,5 +3,24 @@ diff --git a/src/boot/auth-init.ts b/src/boot/auth-init.ts new file mode 100644 index 0000000..8ec1dd9 --- /dev/null +++ b/src/boot/auth-init.ts @@ -0,0 +1,6 @@ +import { useAuthStore } from 'stores/auth' + +export default async () => { + const authStore = useAuthStore() + await authStore.initialize() +} \ No newline at end of file diff --git a/src/boot/axios.ts b/src/boot/axios.ts index bf9b3ed..2272de7 100644 --- a/src/boot/axios.ts +++ b/src/boot/axios.ts @@ -1,5 +1,6 @@ -import { defineBoot } from '#q-app/wrappers'; -import axios, { type AxiosInstance } from 'axios'; +import { defineBoot } from '#q-app/wrappers' +import axios, { type AxiosInstance } from 'axios' +import { useAuthStore } from 'src/stores/auth' declare module 'vue' { interface ComponentCustomProperties { @@ -14,16 +15,32 @@ declare module 'vue' { // good idea to move this instance creation inside of the // "export default () => {}" function below (which runs individually // for each client) -const api = axios.create({ baseURL: 'https://api.example.com' }); +const api = axios.create({ + baseURL: '/', + withCredentials: true // Важно для работы с cookies +}) + +api.interceptors.response.use( + response => response, + async error => { + if (error.response?.status === 401) { + const authStore = useAuthStore() + await authStore.logout() + } + console.error(error) + return Promise.reject(new Error()) + } + +) export default defineBoot(({ app }) => { // for use inside Vue files (Options API) through this.$axios and this.$api - app.config.globalProperties.$axios = axios; + app.config.globalProperties.$axios = axios // ^ ^ ^ this will allow you to use this.$axios (for Vue Options API form) // so you won't necessarily have to import axios in each vue file - app.config.globalProperties.$api = api; + app.config.globalProperties.$api = api // ^ ^ ^ this will allow you to use this.$api (for Vue Options API form) // so you can easily perform requests against your app's API }); diff --git a/src/boot/helpers.ts b/src/boot/helpers.ts index ef16bae..286e65a 100644 --- a/src/boot/helpers.ts +++ b/src/boot/helpers.ts @@ -1,8 +1,34 @@ -export function isObjEqual(obj1: Type, obj2: Type): boolean { - return obj1 && obj2 && Object.keys(obj1).length === Object.keys(obj2).length && - (Object.keys(obj1) as (keyof typeof obj1)[]).every(key => { - return Object.prototype.hasOwnProperty.call(obj2, key) && obj1[key] === obj2[key] - }) +export function isObjEqual(a: object, b: object): boolean { + // Сравнение примитивов и null/undefined + if (a === b) return true + if (!a || !b) return false + if (Object.keys(a).length !== Object.keys(b).length) return false + + // Получаем все уникальные ключи из обоих объектов + const allKeys = new Set([ + ...Object.keys(a), + ...Object.keys(b) + ]) + + // Проверяем каждое свойство + for (const key of allKeys) { + const valA = a[key as keyof typeof a] + const valB = b[key as keyof typeof b] + + // Если одно из значений undefined - объекты разные + if (valA === undefined || valB === undefined) return false + + // Рекурсивное сравнение для вложенных объектов + if (typeof valA === 'object' && typeof valB === 'object') { + if (!isObjEqual(valA, valB)) return false + } + // Сравнение примитивов + else if (!Object.is(valA, valB)) { + return false + } + } + + return true } export function parseIntString (s: string | string[] | undefined) :number | null { diff --git a/src/boot/telegram-boot.ts b/src/boot/telegram-boot.ts new file mode 100644 index 0000000..960a4ea --- /dev/null +++ b/src/boot/telegram-boot.ts @@ -0,0 +1,18 @@ +import type { BootParams } from '@quasar/app' + +export default ({ app }: BootParams) => { + + // Инициализация Telegram WebApp + if (window.Telegram?.WebApp) { + const webApp = window.Telegram.WebApp + // Помечаем приложение как готовое + webApp.ready() + // window.Telegram.WebApp.requestFullscreen() + // Опционально: сохраняем объект в Vue-приложение для глобального доступа + webApp.SettingsButton.isVisible = true + // webApp.BackButton.isVisible = true + app.config.globalProperties.$tg = webApp + // Для TypeScript: объявляем тип для инжекции + app.provide('tg', webApp) + } +} \ No newline at end of file diff --git a/src/components/admin/accountHelper.vue b/src/components/admin/accountHelper.vue index 5856783..36e63da 100644 --- a/src/components/admin/accountHelper.vue +++ b/src/components/admin/accountHelper.vue @@ -15,9 +15,10 @@ - +
{{$t('account_helper__code_error')}}
@@ -28,10 +29,11 @@ :title="$t('account_helper__confirm_email')" :done="step > 2" > - {{$t('account_helper__confirm_email_messege')}} +
{{$t('account_helper__confirm_email_message')}}
@@ -47,7 +49,8 @@ @@ -73,7 +76,7 @@ }>() const step = ref(1) - const login = ref(props.email ? props.email : '') + const login = ref(props.email || '') const code = ref('') const password = ref('') diff --git a/src/components/admin/companyInfoBlock.vue b/src/components/admin/companyInfoBlock.vue index 3118c66..ebb4812 100644 --- a/src/components/admin/companyInfoBlock.vue +++ b/src/components/admin/companyInfoBlock.vue @@ -10,6 +10,7 @@ filled class = "q-mt-md w100" :label = "input.label ? $t(input.label) : void 0" + :rules="input.val === 'name' ? [rules[input.val]] : []" > diff --git a/src/css/app.scss b/src/css/app.scss index 0617b56..77bcac9 100644 --- a/src/css/app.scss +++ b/src/css/app.scss @@ -22,6 +22,13 @@ $base-height: 100; :root { --body-width: 600px; --top-raduis: 12px; + --logo-color-bg-white: grey; + --dynamic-font-size: 16px; +} + +#q-app { + padding-bottom: env(safe-area-inset-bottom, 0); + padding-bottom: constant(safe-area-inset-bottom, 0); // Для старых iOS } body { diff --git a/src/css/quasar.variables.scss b/src/css/quasar.variables.scss index a157345..7e49afe 100644 --- a/src/css/quasar.variables.scss +++ b/src/css/quasar.variables.scss @@ -26,3 +26,4 @@ $warning : #F2C037; $lightgrey : #DCDCDC; +$body-font-size: var(--dynamic-font-size) \ No newline at end of file diff --git a/src/i18n/en-US/index.ts b/src/i18n/en-US/index.ts index fd61eac..fda8ff3 100644 --- a/src/i18n/en-US/index.ts +++ b/src/i18n/en-US/index.ts @@ -1 +1 @@ -export default { EN: 'EN', RU: 'RU', continue: 'Continue', back: 'Back', month: 'month', months: 'months', login__email: 'E-mail', login__password: 'Password', login__forgot_password: 'Forgot Password?', login__sign_in: 'Log in', login__incorrect_login_data: 'User data not found. Edit your auth details before continuing', login__or_continue_as: 'or continue as', login__terms_of_use: 'Terms of use', login__accept_terms_of_use: 'I accept the', login__register: 'Create account', login__registration_message_ok: 'We sent message with instructions to your email', login__registration_message_error: 'Error', login__licensing_agreement: 'Licensing agreement', login__have_account: 'Already have an accont?', login__forgot_password_message: 'Enter your e-mail to recover your password. We will send instructions to the specified email address. If you havent received the email, check the Spam folder.', login__forgot_password_message_ok: 'We sent message with instructions to your email', login__forgot_password_message_error: 'Error', user__logout: 'Logout', projects__projects: 'Projects', projects__show_archive: 'Show archive', projects__hide_archive: 'Hide archive', projects__restore_archive_warning: 'Attention!', projects__restore_archive_warning_message: 'To restore a project from an archive, you must manually attach chats to it.', project__chats: 'Chats', project__persons: 'Persons', project__companies: 'Companies', project__edit: 'Edit', project__backup: 'Backup', project__archive: 'Archive', project__delete: 'Delete', project_chats__search: 'Search', project_chats__send_chat: 'Request for attach chat', project_chats__send_chat_description: 'Provide instructions to the chat admin', project_chats__attach_chat: 'Attach chat', project_chats__attach_chat_description: 'Requires chat administrator privileges', project_chat__delete_warning: 'Warning!', project_chat__delete_warning_message: 'Chat tracking will be discontinued. If necessary, the cat can be attached again.', project_card__add_project: 'Add project', project_card__project_name: 'Name', project_card__project_description: 'Desription', project_card__btn_accept: 'Accept', project_card__btn_back: 'Back', forgot_password__password_recovery: 'Password recovery', forgot_password__enter_email: 'Enter account e-mail', forgot_password__email: 'E-mail', forgot_password__confirm_email: 'Confirm e-mail', forgot_password__confirm_email_messege: 'Enter the Code from e-mail to continue recover your password. If you haven\'t received an e-mail with the Code, check the Spam folder.', forgot_password__code: 'Code', forgot_password__create_new_password: 'Set new password', forgot_password__password: 'Password', forgot_password__finish: 'Create', account__user_settings: 'User settings', account__your_company: 'Your company', account__change_auth: 'Change authorization method', account__change_auth_message_1: 'In case of corporate use, it is recommended to log in with a username and password.', account__change_auth_message_2: 'After creating a user, all data from the Telegram account will be transferred to the new account.', account__change_auth_btn: 'Create system account', account__change_auth_warning: 'WARNING!', account__change_auth_warning_message: 'Reverse data transfer is not possible.', account__chats: 'Chats', account__chats_active: 'Active', account__chats_archive: 'Archive', account__chats_free: 'Free', account__chats_total: 'Total', account__subscribe: 'Subscribe', account__subscribe_info: 'With a subscription, you can attach more chats. Archived chats are not counted.', account__subscribe_current_balance: 'Current balance', account__subscribe_about: 'about', account__subscribe_select_payment_1: 'You can pay for your subscription using ', account__subscribe_select_payment_2: 'Telegram stars', company__mask: 'Company cloacking', mask__title_table: 'Ignore cloaking', mask__title_table2: '(exclusion list)', mask__help_title: 'Cloacking', mask__help_message: 'It is possible to cloacking a company by representing its personnel as your own to companies other than those on the exclusion list.', company_info__title_card: 'Company card', company_info__name: 'Name', company_info__description: 'Description', company_info__persons: 'Persons', company_create__title_card: 'Add company', project_persons__search: 'Search', person_card__title: 'Person card', person_card__name: 'Name', person_card__company: 'Company name', person_card__department: 'Department', person_card__role: 'Role' } \ No newline at end of file +export default { EN: 'EN', RU: 'RU', continue: 'Continue', back: 'Back', month: 'month', months: 'months', slogan: 'Work together - it\'s magic!', login__email: 'E-mail', login__password: 'Password', login__forgot_password: 'Forgot Password?', login__sign_in: 'Log in', login__incorrect_login_data: 'User data not found. Edit your auth details before continuing', login__or_continue_as: 'or continue as', login__terms_of_use: 'Terms of use', login__accept_terms_of_use: 'I accept the', login__register: 'Create account', login__registration_message_error: 'Error', login__licensing_agreement: 'Licensing agreement', login__have_account: 'Already have an accont?', user__logout: 'Logout', projects__projects: 'Projects', projects__show_archive: 'Show archive', projects__hide_archive: 'Hide archive', projects__restore_archive_warning: 'Attention!', projects__restore_archive_warning_message: 'To restore a project from an archive, you must manually attach chats to it.', project__chats: 'Chats', project__persons: 'Persons', project__companies: 'Companies', project__edit: 'Edit', project__backup: 'Backup', project__archive: 'Archive', project__archive_warning: 'Are you sure?', project__archive_warning_message: 'Chat tracking in the project will be disabled after moving to the archive.', project__delete: 'Delete', project__delete_warning: 'Warning!', project__delete_warning_message: 'All project data will be removed. This action cannot be undone.', project_chats__search: 'Search', project_chats__send_chat: 'Request for attach chat', project_chats__send_chat_description: 'Provide instructions to the chat admin', project_chats__attach_chat: 'Attach chat', project_chats__attach_chat_description: 'Requires chat administrator privileges', project_chat__delete_warning: 'Warning!', project_chat__delete_warning_message: 'Chat tracking will be discontinued. If necessary, the cat can be attached again.', project_card__project_card: 'Project card', project_card__add_project: 'Add project', project_card__project_name: 'Name', project_card__project_description: 'Description', project_card__btn_accept: 'Accept', project_card__btn_back: 'Back', project_card__image_use_as_background_chats: 'logo as background for chats', project_card__error_name: 'Field is required', forgot_password__password_recovery: 'Password recovery', account_helper__enter_email: 'Enter account e-mail', account_helper__email: 'E-mail', account_helper__confirm_email: 'Confirm e-mail', account_helper__confirm_email_message: 'Enter the Code from e-mail to continue recover your password. If you haven\'t received an e-mail with the Code, check the Spam folder.', account_helper__code: 'Code', account_helper__code_error: 'Incorrect code. Ensure your e-mail is correct and try again.', account_helper__set_password: 'Set password', account_helper__password: 'Password', account_helper__finish: 'Finish', account_helper__finish_after_message: 'Done!', account__user_settings: 'User settings', account__your_company: 'Your company', account__change_auth: 'Change authorization method', account__change_auth_message_1: 'In case of corporate use, it is recommended to log in with a username and password.', account__change_auth_message_2: 'After creating a user, all data from the Telegram account will be transferred to the new account.', account__change_auth_btn: 'Create system account', account__change_auth_warning: 'WARNING!', account__change_auth_warning_message: 'Reverse data transfer is not possible.', account__chats: 'Chats', account__chats_active: 'Active', account__chats_archive: 'Archive', account__chats_free: 'Free', account__chats_total: 'Total', account__subscribe: 'Subscribe', account__subscribe_info: 'With a subscription, you can attach more chats. Archived chats are not counted.', account__subscribe_current_balance: 'Current balance', account__subscribe_about: 'about', account__subscribe_select_payment_1: 'You can pay for your subscription using ', account__subscribe_select_payment_2: 'Telegram stars', company__mask: 'Company cloacking', mask__title_table: 'Ignore cloaking', mask__title_table2: '(exclusion list)', mask__help_title: 'Cloacking', mask__help_message: 'It is possible to cloacking a company by representing its personnel as your own to companies other than those on the exclusion list.', company_info__title_card: 'Company card', company_info__name: 'Name', company_info__description: 'Description', company_info__persons: 'Persons', company_create__title_card: 'Add company', project_persons__search: 'Search', person_card__title: 'Person card', person_card__name: 'Name', person_card__company: 'Company name', person_card__department: 'Department', person_card__role: 'Role', settings__title: 'Settings', settings__language: 'Language', settings__font_size: 'Font size', terms__title: 'Terms of use' } \ No newline at end of file diff --git a/src/i18n/ru-RU/index.ts b/src/i18n/ru-RU/index.ts index 98aaeaa..2cdfa48 100644 --- a/src/i18n/ru-RU/index.ts +++ b/src/i18n/ru-RU/index.ts @@ -1 +1 @@ -export default { EN: 'EN', RU: 'RU', continue: 'Продолжить', back: 'Назад', month: 'мес.', months: 'мес.', login__email: 'Электронная почта', login__password: 'Пароль', login__forgot_password: 'Забыли пароль?', login__sign_in: 'Войти', login__incorrect_login_data: 'Пользователь с такими данными не найден. Отредактируйте введенные данные', login__or_continue_as: 'или продолжить', login__terms_of_use: 'Условия использования', login__accept_terms_of_use: 'Я принимаю', login__register: 'Зарегестрироваться', login__registration_message_ok: 'Мы отправили сообщение с инструкциями на указанную электронную почту', login__registration_message_error: 'Ошибка', login__licensing_agreement: 'Договор о лицензировании', login__have_account: 'Есть учетная запись', login__forgot_password_message: 'Введите e-mail, чтобы восстановить пароль. Мы пришлем инструкцию на указанную почту. Если вы не получили письмо, проверьте папку «Спам».', login__forgot_password_message_ok: 'Мы отправили сообщение с инструкциями на указанную электронную почту', login__forgot_password_message_error: 'Ошибка', user__logout: 'Выход', projects__projects: 'Проекты', projects__show_archive: 'Показать архив', projects__hide_archive: 'Скрыть архив', projects__restore_archive_warning: 'Внимание!', projects__restore_archive_warning_message: 'При восстановлении проекта из архива - присоединение чатов к проекту требуется осуществлять вручную.', project__chats: 'Чаты', project__persons: 'Люди', project__companies: 'Компании', project__edit: 'Редактировать', project__backup: 'Резервная копия', project__archive: 'В архив', project__delete: 'Удалить', project_chats__search: 'Поиск', project_chats__send_chat: 'Запрос на добавление чата', project_chats__send_chat_description: 'Отправить инструкцию администратору чата', project_chats__attach_chat: 'Добавить чат', project_chats__attach_chat_description: 'Необходимы права администратора чата', project_chat__delete_warning: 'Внимание!', project_chat__delete_warning_message: 'Отслеживание чата будет прекращено. При необходимости чат можно будет подключить снова.', project_card__add_project: 'Новый проект', project_card__project_name: 'Название', project_card__project_description: 'Описание', project_card__btn_accept: 'Подтвердить', project_card__btn_back: 'Назад', forgot_password__password_recovery: 'Восстановление пароля', forgot_password__enter_email: 'Введите электронную почту', forgot_password__email: 'Электронная почта', forgot_password__confirm_email: 'Подтверждение электронной почты', forgot_password__confirm_email_messege: 'Введите код из письма для продолжения восстановления пароля. Если не получили письмо с кодом - проверьте папку Спам', forgot_password__code: 'Код', forgot_password__create_new_password: 'Установка нового пароля', forgot_password__password: 'Пароль', forgot_password__finish: 'Создать', account__user_settings: 'Пользовательские настройки', account__your_company: 'Ваша компания', account__change_auth: 'Сменить способ авторизации', account__change_auth_message_1: 'В случае корпоративного использования рекомендуется входить в систему, указав логин и пароль.', account__change_auth_message_2: 'После создания пользователя все данные с учетной записи Telegram будут перенесены на новую учетную запись.', account__change_auth_btn: 'Создать пользователя', account__change_auth_warning: 'ВНИМАНИЕ!', account__change_auth_warning_message: 'Обратный перенос данных не возможен.', account__chats: 'Чаты', account__chats_active: 'Активные', account__chats_archive: 'Архивные', account__chats_free: 'Бесплатные', account__chats_total: 'Всего', account__subscribe: 'Подписка', account__subscribe_info: 'С помощью подписки можно подключить к бесплатным групповым чатам дополнительные. Архивные чаты не учитываются. ', account__subscribe_current_balance: 'Текущий баланс', account__subscribe_about: 'около', account__subscribe_select_payment_1: 'Вы можете оплатить подписку с помощью', account__subscribe_select_payment_2: 'Telegram stars', company__mask: 'Маскировка компаний', mask__title_table: 'Игнорирование маскировки', mask__title_table2: '(перечень исключений)', mask__help_title: 'Маскировка', mask__help_message: 'Возможно замаскировать компанию, представляя ее персонал как собственный для других компаний, кроме тех что есть в перечне исключений. ', company_info__title_card: 'Карточка компании', company_info__name: 'Название', company_info__description: 'Описание', company_info__persons: 'Сотрудники', company_create__title_card: 'Добавление компании', project_persons__search: 'Поиск', person_card__title: 'Карточка сотрудника', person_card__name: 'ФИО', person_card__company: 'Название компании', person_card__department: 'Подразделение', person_card__role: 'Функционал (должность)' } \ No newline at end of file +export default { EN: 'EN', RU: 'RU', continue: 'Продолжить', back: 'Назад', month: 'мес.', months: 'мес.', slogan: 'Работайте вместе - это волшебство!', login__email: 'Электронная почта', login__password: 'Пароль', login__forgot_password: 'Забыли пароль?', login__sign_in: 'Войти', login__incorrect_login_data: 'Пользователь с такими данными не найден. Отредактируйте введенные данные', login__or_continue_as: 'или продолжить', login__terms_of_use: 'Пользовательское соглашение', login__accept_terms_of_use: 'Я принимаю', login__register: 'Зарегестрироваться', login__registration_message_error: 'Ошибка', login__licensing_agreement: 'Договор о лицензировании', login__have_account: 'Есть учетная запись', user__logout: 'Выход', projects__projects: 'Проекты', projects__show_archive: 'Показать архив', projects__hide_archive: 'Скрыть архив', projects__restore_archive_warning: 'Внимание!', projects__restore_archive_warning_message: 'При восстановлении проекта из архива - присоединение чатов к проекту требуется осуществлять вручную.', project__chats: 'Чаты', project__persons: 'Люди', project__companies: 'Компании', project__edit: 'Редактировать', project__backup: 'Резервная копия', project__archive: 'В архив', project__archive_warning: 'Вы уверены?', project__archive_warning_message: 'После перемещения проекта в архив отслеживание чатов будет отключено.', project__delete: 'Удалить', project__delete_warning: 'Внимание!', project__delete_warning_message: 'Все данные проекта будут безвозвратно удалены.', project_chats__search: 'Поиск', project_chats__send_chat: 'Запрос на добавление чата', project_chats__send_chat_description: 'Отправить инструкцию администратору чата', project_chats__attach_chat: 'Добавить чат', project_chats__attach_chat_description: 'Необходимы права администратора чата', project_chat__delete_warning: 'Внимание!', project_chat__delete_warning_message: 'Отслеживание чата будет прекращено. При необходимости чат можно будет подключить снова.', project_card__project_card: 'Карточка компании', project_card__add_project: 'Новый проект', project_card__project_name: 'Название', project_card__project_description: 'Описание', project_card__btn_accept: 'Подтвердить', project_card__btn_back: 'Назад', project_card__image_use_as_background_chats: 'логотип в качестве фона для чатов', project_card__error_name: 'Поле обязательно к заполнению', forgot_password__password_recovery: 'Восстановление пароля', account_helper__enter_email: 'Введите электронную почту', account_helper__email: 'Электронная почта', account_helper__confirm_email: 'Подтверждение электронной почты', account_helper__confirm_email_message: 'Введите код из письма для продолжения восстановления пароля. Если не получили письмо с кодом - проверьте папку Спам', account_helper__code: 'Код', account_helper__code_error: 'Был введен неверный код. Проверьте адрес электронной почты и повторите попытку.', account_helper__set_password: 'Установка пароля', account_helper__password: 'Пароль', account_helper__finish: 'Отправить', account_helper__finish_after_message: 'Готово!', account__user_settings: 'Пользовательские настройки', account__your_company: 'Ваша компания', account__change_auth: 'Сменить способ авторизации', account__change_auth_message_1: 'В случае корпоративного использования рекомендуется входить в систему, указав логин и пароль.', account__change_auth_message_2: 'После создания пользователя все данные с учетной записи Telegram будут перенесены на новую учетную запись.', account__change_auth_btn: 'Создать пользователя', account__change_auth_warning: 'ВНИМАНИЕ!', account__change_auth_warning_message: 'Обратный перенос данных не возможен.', account__chats: 'Чаты', account__chats_active: 'Активные', account__chats_archive: 'Архивные', account__chats_free: 'Бесплатные', account__chats_total: 'Всего', account__subscribe: 'Подписка', account__subscribe_info: 'С помощью подписки можно подключить к бесплатным групповым чатам дополнительные. Архивные чаты не учитываются. ', account__subscribe_current_balance: 'Текущий баланс', account__subscribe_about: 'около', account__subscribe_select_payment_1: 'Вы можете оплатить подписку с помощью', account__subscribe_select_payment_2: 'Telegram stars', company__mask: 'Маскировка компаний', mask__title_table: 'Игнорирование маскировки', mask__title_table2: '(перечень исключений)', mask__help_title: 'Маскировка', mask__help_message: 'Возможно замаскировать компанию, представляя ее персонал как собственный для других компаний, кроме тех что есть в перечне исключений. ', company_info__title_card: 'Карточка компании', company_info__name: 'Название', company_info__description: 'Описание', company_info__persons: 'Сотрудники', company_create__title_card: 'Добавление компании', project_persons__search: 'Поиск', person_card__title: 'Карточка сотрудника', person_card__name: 'ФИО', person_card__company: 'Название компании', person_card__department: 'Подразделение', person_card__role: 'Функционал (должность)', settings__title: 'Настройки', settings__language: 'Язык', settings__font_size: 'Размер шрифта', terms__title: 'Пользовательское соглашение' } \ No newline at end of file diff --git a/src/index copy.ts b/src/index copy.ts deleted file mode 100644 index 066de2a..0000000 --- a/src/index copy.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { defineStore } from '#q-app/wrappers' -import { createPinia } from 'pinia' - -/* - * When adding new properties to stores, you should also - * extend the `PiniaCustomProperties` interface. - * @see https://pinia.vuejs.org/core-concepts/plugins.html#typing-new-store-properties - */ -declare module 'pinia' { - // eslint-disable-next-line @typescript-eslint/no-empty-object-type - export interface PiniaCustomProperties { - // add your custom properties here, if any - } -} - -/* - * If not building with SSR mode, you can - * directly export the Store instantiation; - * - * The function below can be async too; either use - * async/await or return a Promise which resolves - * with the Store instance. - */ - -export default defineStore((/* { ssrContext } */) => { - const pinia = createPinia() - - // You can add Pinia plugins here - // pinia.use(SomePiniaPlugin) - - return pinia -}) diff --git a/src/layouts/MainLayout.vue b/src/layouts/MainLayout.vue index 1a5af2a..40a112d 100644 --- a/src/layouts/MainLayout.vue +++ b/src/layouts/MainLayout.vue @@ -4,8 +4,22 @@ fit class="fit no-scroll bg-transparent" > - - + + import { ref } from 'vue' import meshBackground from '../components/admin/meshBackground.vue' + + const existDrawer = ref(true) function getCSSVar (varName: string) { const root = document.documentElement return getComputedStyle(root).getPropertyValue(varName).trim() @@ -30,6 +46,7 @@ function onResize () { const clientWidth = document.documentElement.clientWidth; drawerWidth.value = (clientWidth - bodyWidth)/2 + existDrawer.value = clientWidth > bodyWidth } diff --git a/src/pages/AccountPage.vue b/src/pages/AccountPage.vue index 5a388bf..1edc152 100644 --- a/src/pages/AccountPage.vue +++ b/src/pages/AccountPage.vue @@ -3,43 +3,63 @@
-
- {{ $t('account__user_settings') }} -
-
- - - +
+
+ {{ $t('account__user_settings') }}
+
+ + + + +
{{ $t('account__chats') }} -
{{ $t('account__subscribe') }}
-
- + + - + {{ $t('account__subscribe_info') }} + {{ $t('account__subscribe_select_payment_1') }} + + {{ $t('account__subscribe_select_payment_2') }} -
-
{{ $t('account__subscribe_current_balance') }}
-
- {{ $t('account__subscribe_about') }} 3 {{ $t('months') }} -
-
+ +
50
- - - - - {{ $t('month') }} - - +
- {{ $t('account__subscribe_select_payment_1') }} - - {{ $t('account__subscribe_select_payment_2') }} +
+ + \ No newline at end of file diff --git a/src/pages/CompanyInfoPage.vue b/src/pages/CompanyInfoPage.vue index 43b0962..8d1d56f 100644 --- a/src/pages/CompanyInfoPage.vue +++ b/src/pages/CompanyInfoPage.vue @@ -6,35 +6,69 @@ {{$t('company_info__title_card')}}
- + diff --git a/src/pages/CreateAccountPage.vue b/src/pages/CreateAccountPage.vue index c6559af..38ee95c 100644 --- a/src/pages/CreateAccountPage.vue +++ b/src/pages/CreateAccountPage.vue @@ -2,10 +2,12 @@ - + + + diff --git a/src/pages/CreateProjectPage.vue b/src/pages/CreateProjectPage.vue index 0d2b69c..3540940 100644 --- a/src/pages/CreateProjectPage.vue +++ b/src/pages/CreateProjectPage.vue @@ -6,7 +6,7 @@ {{$t('project_card__add_project')}}
- + diff --git a/src/pages/LoginPage.vue b/src/pages/LoginPage.vue index b01b5ff..ca3cd5d 100644 --- a/src/pages/LoginPage.vue +++ b/src/pages/LoginPage.vue @@ -1,6 +1,5 @@ diff --git a/src/pages/PersonInfoPage.vue b/src/pages/PersonInfoPage.vue index ff97185..75e5cda 100644 --- a/src/pages/PersonInfoPage.vue +++ b/src/pages/PersonInfoPage.vue @@ -14,70 +14,71 @@ -
-
+
+ -
- + +
{{ person.tname }}
{{ person.tusername }}
- +
+ - - - - + + + + - + - -
+ +
@@ -108,5 +109,5 @@ } - diff --git a/src/pages/ProjectInfoPage.vue b/src/pages/ProjectInfoPage.vue index ff17328..0481631 100644 --- a/src/pages/ProjectInfoPage.vue +++ b/src/pages/ProjectInfoPage.vue @@ -1,5 +1,4 @@ @@ -78,7 +77,7 @@ } const tabs = [ - {name: 'chats', label: 'project__chats', icon: 'mdi-message-outline', component: tabComponents.projectPageChats, to: { name: 'chats'} }, + {name: 'chats', label: 'project__chats', icon: 'mdi-chat-outline', component: tabComponents.projectPageChats, to: { name: 'chats'} }, {name: 'persons', label: 'project__persons', icon: 'mdi-account-outline', component: tabComponents.projectPagePersons, to: { name: 'persons'} }, {name: 'companies', label: 'project__companies', icon: 'mdi-account-group-outline', component: tabComponents.projectPageCompanies, to: { name: 'companies'} }, ] diff --git a/src/pages/ProjectsPage.vue b/src/pages/ProjectsPage.vue index 7e2264c..028a88c 100644 --- a/src/pages/ProjectsPage.vue +++ b/src/pages/ProjectsPage.vue @@ -15,11 +15,15 @@ dense >
- - + +
- Alex mart + {{ + tgUser?.first_name + + (tgUser?.first_name && tgUser?.last_name ? ' ' : '') + + tgUser?.last_name + }}
@@ -62,28 +66,25 @@ {{ item.name }} {{item.description}} - -
-
- - {{ item.chats }} -
-
- - {{ item.persons }} -
-
- - {{ item.companies }} -
-
-
+ +
+ +
+
+ + {{ item.chats }} +
+
+ + {{ item.persons }} +
+
- + + + diff --git a/src/pages/TermsPage.vue b/src/pages/TermsPage.vue new file mode 100644 index 0000000..edd5408 --- /dev/null +++ b/src/pages/TermsPage.vue @@ -0,0 +1,22 @@ + + + + + diff --git a/src/router/index.ts b/src/router/index.ts index 1d43c18..124bd68 100644 --- a/src/router/index.ts +++ b/src/router/index.ts @@ -6,7 +6,8 @@ import { createWebHistory, } from 'vue-router' import routes from './routes' -import { useProjectsStore } from '../stores/projects' +import { useAuthStore } from 'stores/auth' +import { useProjectsStore } from 'stores/projects' /* * If not building with SSR mode, you can @@ -31,8 +32,62 @@ export default defineRouter(function (/* { store, ssrContext } */) { // quasar.conf.js -> build -> publicPath history: createHistory(process.env.VUE_ROUTER_BASE), }) + + const publicPaths = ['/login', '/terms-of-use', '/create-account', '/recovery-password'] + + Router.beforeEach(async (to) => { + const authStore = useAuthStore() + + // Инициализация хранилища перед проверкой + if (!authStore.isInitialized) { + await authStore.initialize() + } + + // Проверка авторизации для непубличных маршрутов + if (!publicPaths.includes(to.path)) { + if (!authStore.isAuthenticated) { + return { + path: '/login', + query: { redirect: to.fullPath } + } + } + } + + // Редирект авторизованных пользователей с публичных маршрутов + if (publicPaths.includes(to.path) && authStore.isAuthenticated) { + return { path: '/' } + } + }) + + const handleBackButton = async () => { + const currentRoute = Router.currentRoute.value + if (currentRoute.meta.backRoute) { + await Router.push(currentRoute.meta.backRoute); + } else { + if (window.history.length > 1) { + Router.go(-1) + } else { + await Router.push('/projects') + } + } + } + Router.afterEach((to) => { + const BackButton = window.Telegram?.WebApp?.BackButton; + if (BackButton) { + // Управление видимостью + if (to.meta.hideBackButton) { + BackButton.hide() + } else { + BackButton.show() + } + + // Обновляем обработчик клика + BackButton.offClick(handleBackButton as () => void) + BackButton.onClick(handleBackButton as () => void) + } + if (!to.params.id) { const projectsStore = useProjectsStore() projectsStore.setCurrentProjectId(null) diff --git a/src/router/routes.ts b/src/router/routes.ts index 047c6d9..acb2807 100644 --- a/src/router/routes.ts +++ b/src/router/routes.ts @@ -21,7 +21,8 @@ const routes: RouteRecordRaw[] = [ { name: 'projects', path: '/projects', - component: () => import('pages/ProjectsPage.vue') + component: () => import('pages/ProjectsPage.vue'), + meta: { hideBackButton: true } }, { name: 'project_add', @@ -55,48 +56,81 @@ const routes: RouteRecordRaw[] = [ { name: 'chats', path: 'chats', - component: () => import('../components/admin/project-page/ProjectPageChats.vue') + component: () => import('components/admin/project-page/ProjectPageChats.vue'), + meta: { backRoute: '/projects' } }, { name: 'persons', path: 'persons', - component: () => import('../components/admin/project-page/ProjectPagePersons.vue') + component: () => import('components/admin/project-page/ProjectPagePersons.vue'), + meta: { backRoute: '/projects' } }, { name: 'companies', path: 'companies', - component: () => import('../components/admin/project-page/ProjectPageCompanies.vue') + component: () => import('components/admin/project-page/ProjectPageCompanies.vue'), + meta: { backRoute: '/projects' } } ] }, { - path: '/account', + name: 'company_info', + path: '/project/:id(\\d+)/company/:companyId', + component: () => import('pages/CompanyInfoPage.vue'), + beforeEnter: setProjectBeforeEnter + }, + { + name: 'person_info', + path: '/project/:id(\\d+)/person/:personId', + component: () => import('pages/PersonInfoPage.vue'), + beforeEnter: setProjectBeforeEnter + }, + + { name: 'account', + path: '/account', component: () => import('pages/AccountPage.vue') }, + { + name: 'create_account', + path: '/create-account', + component: () => import('pages/CreateAccountPage.vue') + }, { - path: '/login', name: 'login', + path: '/login', component: () => import('pages/LoginPage.vue') }, { - path: '/recovery-password', name: 'recovery_password', + path: '/recovery-password', component: () => import('pages/ForgotPasswordPage.vue') }, { - path: '/create-company', - name: 'create_company', + name: 'add_company', + path: '/add-company', component: () => import('pages/CreateCompanyPage.vue') }, { - path: '/person-info', name: 'person_info', + path: '/person-info', component: () => import('pages/PersonInfoPage.vue') + }, + + { + name: 'settings', + path: '/settings', + component: () => import('pages/SettingsPage.vue') + }, + + { + name: 'terms', + path: '/terms-of-use', + component: () => import('pages/TermsPage.vue') } ] }, diff --git a/src/stores/auth.ts b/src/stores/auth.ts new file mode 100644 index 0000000..a048f00 --- /dev/null +++ b/src/stores/auth.ts @@ -0,0 +1,65 @@ +import { defineStore } from 'pinia' +import { ref, computed } from 'vue' +import { api } from 'boot/axios' + +interface User { + id: string + email?: string + username: string + first_name?: string + last_name?: string + avatar?: string +} + +export const useAuthStore = defineStore('auth', () => { + // State + const user = ref(null) + const isInitialized = ref(false) + + // Getters + const isAuthenticated = computed(() => !!user.value) + + // Actions + const initialize = async () => { + try { + const { data } = await api.get('/customer/profile') + user.value = data + } catch (error) { + console.error(error) + user.value = null + } finally { + isInitialized.value = true + } + } + + const loginWithCredentials = async (email: string, password: string) => { + // будет переделано на беке - нужно сменить урл + await api.post('/api/admin/customer/login', { email, password }, { withCredentials: true }) + await initialize() + } + + const loginWithTelegram = async (initData: string) => { + await api.post('/api/admin/customer/login', { initData }, { withCredentials: true }) + await initialize() + } + + const logout = async () => { + try { + await api.get('/customer/logout', {}) + } finally { + user.value = null + // @ts-expect-ignore + // window.Telegram?.WebApp.close() + } + } + + return { + user, + isAuthenticated, + isInitialized, + initialize, + loginWithCredentials, + loginWithTelegram, + logout + } +}) \ No newline at end of file diff --git a/src/stores/textSize.ts b/src/stores/textSize.ts new file mode 100644 index 0000000..9a4ed0a --- /dev/null +++ b/src/stores/textSize.ts @@ -0,0 +1,121 @@ +import { api } from 'boot/axios' +import { defineStore } from 'pinia' +import { ref, computed } from 'vue' + +interface FontSizeResponse { + fontSize: number +} + +interface FontSizeError { + message: string + code: number +} + +export const useTextSizeStore = defineStore('textSize', () => { + // State + const baseSize = ref(16) // Значение по умолчанию + const isLoading = ref(false) + const error = ref(null) + const isInitialized = ref(false) + + // Константы + const minFontSize = 12 + const maxFontSize = 20 + const fontSizeStep = 2 + + // Getters + const currentFontSize = computed(() => baseSize.value) + const canIncrease = computed(() => baseSize.value < maxFontSize) + const canDecrease = computed(() => baseSize.value > minFontSize) + + // Actions + const fetchFontSize = async () => { + try { + isLoading.value = true + const response = await api.get('customer/settings') + baseSize.value = clampFontSize(response.data.fontSize) + updateCssVariable() + } catch (err) { + handleError(err, 'Failed to fetch font size') + baseSize.value = 16 // Fallback к значению по умолчанию + throw err + } finally { + isLoading.value = false + } + } + + const updateFontSize = async (newSize: number) => { + try { + const validatedSize = clampFontSize(newSize) + + await api.put('customer/settings', { fontSize: validatedSize }) + + baseSize.value = validatedSize + updateCssVariable() + error.value = null + } catch (err) { + handleError(err, 'Failed to update font size') + throw err + } + } + + const increaseFontSize = async () => { + if (!canIncrease.value) return + await updateFontSize(baseSize.value + fontSizeStep) + } + + const decreaseFontSize = async () => { + if (!canDecrease.value) return + await updateFontSize(baseSize.value - fontSizeStep) + } + + // Helpers + const clampFontSize = (size: number): number => { + return Math.max(minFontSize, Math.min(size, maxFontSize)) + } + + const updateCssVariable = () => { + document.documentElement.style.setProperty( + '--dynamic-font-size', + `${baseSize.value}px` + ) + } + + const handleError = (err: unknown, defaultMessage: string) => { + const apiError = err as { response?: { data: { message: string; code: number } } } + error.value = { + message: apiError?.response?.data?.message || defaultMessage, + code: apiError?.response?.data?.code || 500 + } + console.error('FontSize Error:', error.value) + } + + // Инициализация при первом использовании + const initialize = async () => { + if (isInitialized.value) return + + try { + await fetchFontSize() + } catch { + // Оставляем значение по умолчанию + } finally { + isInitialized.value = true + } + } + + return { + baseSize, + currentFontSize, + minFontSize, + maxFontSize, + isLoading, + error, + canIncrease, + canDecrease, + fetchFontSize, + increaseFontSize, + decreaseFontSize, + updateFontSize, + initialize + } +}) \ No newline at end of file diff --git a/src/types.ts b/src/types.ts index 04a2f59..d9a16e5 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,3 +1,13 @@ +import type { WebApp } from "@twa-dev/types" + +declare global { + interface Window { + Telegram: { + WebApp: WebApp + } + } +} + interface ProjectParams { name: string description?: string diff --git a/todo.txt b/todo.txt index b231fb7..e405bf0 100644 --- a/todo.txt +++ b/todo.txt @@ -2,10 +2,10 @@ 1. Login: + Окно "Забыли пароль?" -- Надпись "Неправильный логин или пароль" -- Окно "Регистрация нового пользователя" ++ Надпись "Неправильный логин или пароль" ++ Окно "Регистрация нового пользователя" - Переводы -- Верификация e-mail ++ Верификация e-mail (не делать - плохо выглядит) 2. Account: + Работа с изображением логотипа компании @@ -17,14 +17,14 @@ + (баг) Промотка шапки в конце прокрутки списка проектов + Добавить тень при прокрутке списка на заголовке "Проекты" + Окно добавить проект -- При добавлении проекта проверять валидность, если не валидно то скрывать галку "Применить" ++ При добавлении проекта проверять валидность, если не валидно то скрывать галку "Применить" 4.1 ProjectPage - Заголовок: -- Анимация расширенной версии (плавное увеличение блока div) ++ Анимация расширенной версии (плавное увеличение блока div) + Окно редактирования проекта -- При изменении свойств проекта проверять валидность, если не валидно то скрывать галку "Применить" -- Продумать backup -- Окно отправки проекта в архив ++ При изменении свойств проекта проверять валидность, если не валидно то скрывать галку "Применить" ++ Продумать backup (потом) ++ Окно отправки проекта в архив + Окно удаления проекта 4.2 ProjectPage - Чаты: @@ -48,9 +48,18 @@ - При изменении компании проверять валидность, если не валидно то скрывать галку "Применить" - Окно настройки видимости компаний -4.5 ProjectPage - МаскировкаЖ +4.5 ProjectPage - Маскировка: - Сделать стор и настроить компоненты +5. Settings: +- Роутинг +- Переключатель языков +- Встроить в Телеграмм + +6. Лицензионное соглашение: +- Роутинг и заготовка +- Текст соглашения +- Встроить в Телеграмм BUGS: - 1. Прыгает кнопка fab при перещелкивании табов diff --git a/tsconfig.json b/tsconfig.json index 104aa55..96fc57b 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -12,6 +12,7 @@ "boot/*": ["./src/boot/*"], "stores/*": ["./src/stores/*"] }, - "types": ["node"] - } + "types": ["@twa-dev/types", "node"] + }, + "include": ["src/**/*", "types/**/*"] } \ No newline at end of file