Змінено директорії
Додано скрипти CRON Поліпшено механізм запису стендів та їх редагування
23
README.md
@@ -17,7 +17,6 @@
|
|||||||
7. [Налаштування IP адреси CloudFlare](#7-настройка-ip-адреси-cloudflare)
|
7. [Налаштування IP адреси CloudFlare](#7-настройка-ip-адреси-cloudflare)
|
||||||
8. [Налаштування NGINX для веб](#8-настройка-nginx-для-веб)
|
8. [Налаштування NGINX для веб](#8-настройка-nginx-для-веб)
|
||||||
9. [Встановлення Certbot](#9-встановлення-certbot)
|
9. [Встановлення Certbot](#9-встановлення-certbot)
|
||||||
10. [CRONTAB Backup](#10-crontab-backup)
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -267,27 +266,5 @@ sudo /opt/certbot/bin/pip install --upgrade certbot certbot-nginx
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 10. CRONTAB Backup
|
|
||||||
|
|
||||||
Редагування cron для резервного копіювання бази даних:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
crontab -e
|
|
||||||
```
|
|
||||||
|
|
||||||
Додати завдання:
|
|
||||||
|
|
||||||
```cron
|
|
||||||
#####################
|
|
||||||
# Щоденне збереження резервної копії бази Sheep-Service о 22:30
|
|
||||||
30 22 * * * cd /home/username/webapps/sheep-service.com && /usr/bin/python3 backup.py >> /home/username/webapps/sheep-service.com/log/backup.log 2>&1
|
|
||||||
#####################
|
|
||||||
```
|
|
||||||
|
|
||||||
Встановлення Python-залежностей:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
sudo pip3 install python-dotenv
|
|
||||||
```
|
|
||||||
|
|
||||||
💡 **Порада:** Переконайтесь, що всі шляхи до директорій відповідають вашому користувачу та що всі сервіси працюють після перезавантаження Docker або сервера.
|
💡 **Порада:** Переконайтесь, що всі шляхи до директорій відповідають вашому користувачу та що всі сервіси працюють після перезавантаження Docker або сервера.
|
||||||
@@ -6,7 +6,7 @@ COPY package.json package-lock.json ./
|
|||||||
|
|
||||||
RUN npm install
|
RUN npm install
|
||||||
|
|
||||||
RUN apt-get update && apt-get install -y chromium
|
RUN apt-get update
|
||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
|
|||||||
@@ -197,7 +197,8 @@ db.serialize(() => {
|
|||||||
week_days TEXT DEFAULT '[0, 1, 2, 3, 4, 5, 6]',
|
week_days TEXT DEFAULT '[0, 1, 2, 3, 4, 5, 6]',
|
||||||
processing_time REAL DEFAULT 1,
|
processing_time REAL DEFAULT 1,
|
||||||
status INTEGER DEFAULT 0,
|
status INTEGER DEFAULT 0,
|
||||||
updated_at TIMESTAMP
|
updated_at TIMESTAMP,
|
||||||
|
created_at TIMESTAMP
|
||||||
)
|
)
|
||||||
`);
|
`);
|
||||||
|
|
||||||
@@ -210,6 +211,7 @@ db.serialize(() => {
|
|||||||
sheep_id INTEGER,
|
sheep_id INTEGER,
|
||||||
number_sheep TEXT,
|
number_sheep TEXT,
|
||||||
updated_at TIMESTAMP,
|
updated_at TIMESTAMP,
|
||||||
|
created_at TIMESTAMP,
|
||||||
FOREIGN KEY (stand_id) REFERENCES stand_list(id),
|
FOREIGN KEY (stand_id) REFERENCES stand_list(id),
|
||||||
FOREIGN KEY (sheep_id) REFERENCES sheeps(id)
|
FOREIGN KEY (sheep_id) REFERENCES sheeps(id)
|
||||||
)
|
)
|
||||||
|
|||||||
4
api/package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "API Sheep Service",
|
"name": "API Sheep Service",
|
||||||
"version": "1.0.1",
|
"version": "2.0.1",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "API Sheep Service",
|
"name": "API Sheep Service",
|
||||||
"version": "1.0.1",
|
"version": "2.0.1",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "API Sheep Service",
|
"name": "API Sheep Service",
|
||||||
"version": "1.0.1",
|
"version": "2.0.1",
|
||||||
"main": "app.js",
|
"main": "app.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "node app.js"
|
"start": "node app.js"
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
const db = require("../config/db");
|
const db = require("../config/db");
|
||||||
|
const Notification = require("../utils/notification.js");
|
||||||
|
|
||||||
class HistoryEntranceService {
|
class HistoryEntranceService {
|
||||||
getHistoryEntrance(entrance_id) {
|
getHistoryEntrance(entrance_id) {
|
||||||
@@ -58,6 +59,21 @@ class HistoryEntranceService {
|
|||||||
} else if (this.changes === 0) {
|
} else if (this.changes === 0) {
|
||||||
return res(false);
|
return res(false);
|
||||||
} else {
|
} else {
|
||||||
|
if (Number(data.sheep_id) > 0) {
|
||||||
|
Notification.sendSheep({
|
||||||
|
sheep_id: Number(data.sheep_id),
|
||||||
|
title: "Нова територія",
|
||||||
|
body: "Вам призначено нову територію"
|
||||||
|
});
|
||||||
|
} else if (Number(data.sheep_id) == 0 && Number(data.group_id) > 0) {
|
||||||
|
Notification.sendGroup({
|
||||||
|
group_id: Number(data.group_id),
|
||||||
|
title: "Нова територія",
|
||||||
|
body: "Призначено нову групову територію"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
res({ "create": "ok", "id": this.lastID });
|
res({ "create": "ok", "id": this.lastID });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -51,13 +51,27 @@ class HistoryHomesteadService {
|
|||||||
Number(data.group_id),
|
Number(data.group_id),
|
||||||
Number(data.sheep_id),
|
Number(data.sheep_id),
|
||||||
1
|
1
|
||||||
], function(err) {
|
], function (err) {
|
||||||
if (err) {
|
if (err) {
|
||||||
console.error(err.message);
|
console.error(err.message);
|
||||||
return res(false);
|
return res(false);
|
||||||
} else if (this.changes === 0) {
|
} else if (this.changes === 0) {
|
||||||
return res(false);
|
return res(false);
|
||||||
} else {
|
} else {
|
||||||
|
if (Number(data.sheep_id) > 0) {
|
||||||
|
Notification.sendSheep({
|
||||||
|
sheep_id: Number(data.sheep_id),
|
||||||
|
title: "Нова територія",
|
||||||
|
body: "Вам призначено нову територію"
|
||||||
|
});
|
||||||
|
} else if (Number(data.sheep_id) == 0 && Number(data.group_id) > 0) {
|
||||||
|
Notification.sendGroup({
|
||||||
|
group_id: Number(data.group_id),
|
||||||
|
title: "Нова територія",
|
||||||
|
body: "Призначено нову групову територію"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
res({ "create": "ok", "id": this.lastID });
|
res({ "create": "ok", "id": this.lastID });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -71,7 +85,7 @@ class HistoryHomesteadService {
|
|||||||
Math.floor(new Date(Date.now()).getTime()),
|
Math.floor(new Date(Date.now()).getTime()),
|
||||||
0,
|
0,
|
||||||
Number(homestead_id)
|
Number(homestead_id)
|
||||||
], function(err) {
|
], function (err) {
|
||||||
if (err) {
|
if (err) {
|
||||||
console.error(err.message);
|
console.error(err.message);
|
||||||
return res(false);
|
return res(false);
|
||||||
@@ -86,7 +100,7 @@ class HistoryHomesteadService {
|
|||||||
|
|
||||||
deleteHistoryHomestead(data) {
|
deleteHistoryHomestead(data) {
|
||||||
return new Promise((res, rej) => {
|
return new Promise((res, rej) => {
|
||||||
db.run('DELETE FROM homestead_history WHERE id = ?', [Number(homestead_id)], function(err) {
|
db.run('DELETE FROM homestead_history WHERE id = ?', [Number(homestead_id)], function (err) {
|
||||||
if (err) {
|
if (err) {
|
||||||
console.error(err.message);
|
console.error(err.message);
|
||||||
return res(false);
|
return res(false);
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
const crypto = require('crypto');
|
|
||||||
const db = require("../config/db");
|
const db = require("../config/db");
|
||||||
|
const Notification = require("../utils/notification.js");
|
||||||
|
|
||||||
class StandService {
|
class StandService {
|
||||||
getStand(id) {
|
getStand(id) {
|
||||||
@@ -27,7 +27,8 @@ class StandService {
|
|||||||
"week_days": JSON.parse(row.week_days),
|
"week_days": JSON.parse(row.week_days),
|
||||||
"processing_time": Number(row.processing_time),
|
"processing_time": Number(row.processing_time),
|
||||||
"status": row.status == 1 ? true : false,
|
"status": row.status == 1 ? true : false,
|
||||||
"updated_at": Number(row.updated_at)
|
"updated_at": Number(row.updated_at),
|
||||||
|
"created_at": Number(row.created_at)
|
||||||
}
|
}
|
||||||
|
|
||||||
return res(data);
|
return res(data);
|
||||||
@@ -62,7 +63,8 @@ class StandService {
|
|||||||
"week_days": JSON.parse(row.week_days),
|
"week_days": JSON.parse(row.week_days),
|
||||||
"processing_time": Number(row.processing_time),
|
"processing_time": Number(row.processing_time),
|
||||||
"status": row.status == 1 ? true : false,
|
"status": row.status == 1 ? true : false,
|
||||||
"updated_at": Number(row.updated_at)
|
"updated_at": Number(row.updated_at),
|
||||||
|
"created_at": Number(row.created_at)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -74,7 +76,7 @@ class StandService {
|
|||||||
|
|
||||||
createStand(data) {
|
createStand(data) {
|
||||||
return new Promise((res, rej) => {
|
return new Promise((res, rej) => {
|
||||||
let sql = 'INSERT INTO stand_list(title, geo, hour_start, hour_end, quantity_sheep, week_days, processing_time, status, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)';
|
let sql = 'INSERT INTO stand_list(title, geo, hour_start, hour_end, quantity_sheep, week_days, processing_time, status, updated_at, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)';
|
||||||
|
|
||||||
db.run(sql, [
|
db.run(sql, [
|
||||||
data.title,
|
data.title,
|
||||||
@@ -85,6 +87,7 @@ class StandService {
|
|||||||
JSON.stringify(data.week_days || [1]),
|
JSON.stringify(data.week_days || [1]),
|
||||||
Number(data.processing_time) || 1,
|
Number(data.processing_time) || 1,
|
||||||
1,
|
1,
|
||||||
|
Math.floor(Date.now()),
|
||||||
Math.floor(Date.now())
|
Math.floor(Date.now())
|
||||||
], function (err) {
|
], function (err) {
|
||||||
if (err) {
|
if (err) {
|
||||||
@@ -106,6 +109,7 @@ class StandService {
|
|||||||
hour_start = COALESCE(?, hour_start),
|
hour_start = COALESCE(?, hour_start),
|
||||||
hour_end = COALESCE(?, hour_end),
|
hour_end = COALESCE(?, hour_end),
|
||||||
quantity_sheep = COALESCE(?, quantity_sheep),
|
quantity_sheep = COALESCE(?, quantity_sheep),
|
||||||
|
processing_time = COALESCE(?, processing_time),
|
||||||
week_days = COALESCE(?, week_days),
|
week_days = COALESCE(?, week_days),
|
||||||
status = COALESCE(?, status),
|
status = COALESCE(?, status),
|
||||||
updated_at = ?
|
updated_at = ?
|
||||||
@@ -118,6 +122,7 @@ class StandService {
|
|||||||
data.hour_start !== undefined ? Number(data.hour_start) : null,
|
data.hour_start !== undefined ? Number(data.hour_start) : null,
|
||||||
data.hour_end !== undefined ? Number(data.hour_end) : null,
|
data.hour_end !== undefined ? Number(data.hour_end) : null,
|
||||||
data.quantity_sheep !== undefined ? Number(data.quantity_sheep) : null,
|
data.quantity_sheep !== undefined ? Number(data.quantity_sheep) : null,
|
||||||
|
data.processing_time !== undefined ? Number(data.processing_time) : null,
|
||||||
data.week_days !== undefined ? JSON.stringify(data.week_days) : null,
|
data.week_days !== undefined ? JSON.stringify(data.week_days) : null,
|
||||||
data.status !== undefined ? (data.status ? 1 : 0) : null,
|
data.status !== undefined ? (data.status ? 1 : 0) : null,
|
||||||
Date.now(),
|
Date.now(),
|
||||||
@@ -153,17 +158,35 @@ class StandService {
|
|||||||
createSchedule(stand_id) {
|
createSchedule(stand_id) {
|
||||||
return new Promise((res, rej) => {
|
return new Promise((res, rej) => {
|
||||||
let date_start;
|
let date_start;
|
||||||
let getNextMonday = (ts) => {
|
|
||||||
|
const MS_DAY = 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
// Нормализует timestamp из БД: если в секундах — умножает на 1000
|
||||||
|
const normalizeTs = (ts) => {
|
||||||
|
if (!ts) return null;
|
||||||
|
// если строка — привести к числу
|
||||||
|
const n = Number(ts);
|
||||||
|
if (Number.isNaN(n)) return null;
|
||||||
|
// если кажется секундами (меньше ~1e12) — умножаем
|
||||||
|
return n < 1e12 ? n * 1000 : n;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Возвращает timestamp (ms) следующего понедельника после ts
|
||||||
|
const getNextMonday = (ts) => {
|
||||||
let date = new Date(ts);
|
let date = new Date(ts);
|
||||||
// следующий день после max_date
|
// нормализуем на начало дня
|
||||||
|
date.setHours(0, 0, 0, 0);
|
||||||
|
|
||||||
|
// всегда переходить к следующему дню (чтобы гарантированно получить следующий понедельник,
|
||||||
|
// даже если ts уже в понедельник)
|
||||||
date.setDate(date.getDate() + 1);
|
date.setDate(date.getDate() + 1);
|
||||||
|
|
||||||
// пока не понедельник – добавляем дни
|
// пока не понедельник (в JS: 1 — понедельник)
|
||||||
while (date.getDay() !== 1) {
|
while (date.getDay() !== 1) {
|
||||||
date.setDate(date.getDate() + 1);
|
date.setDate(date.getDate() + 1);
|
||||||
}
|
}
|
||||||
return date.getTime();
|
return date.getTime();
|
||||||
}
|
};
|
||||||
|
|
||||||
// 1. Получаем стенд
|
// 1. Получаем стенд
|
||||||
db.get(`SELECT * FROM stand_list WHERE id = ?`, [stand_id], (err, stand) => {
|
db.get(`SELECT * FROM stand_list WHERE id = ?`, [stand_id], (err, stand) => {
|
||||||
@@ -176,8 +199,16 @@ class StandService {
|
|||||||
return res(false);
|
return res(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
stand.geo = JSON.parse(stand.geo);
|
stand.geo = JSON.parse(stand.geo);
|
||||||
stand.week_days = JSON.parse(stand.week_days)
|
} catch (e) {
|
||||||
|
stand.geo = stand.geo;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
stand.week_days = JSON.parse(stand.week_days);
|
||||||
|
} catch (e) {
|
||||||
|
stand.week_days = Array.isArray(stand.week_days) ? stand.week_days : [];
|
||||||
|
}
|
||||||
|
|
||||||
// 2. Берём последний date из расписания
|
// 2. Берём последний date из расписания
|
||||||
db.get(
|
db.get(
|
||||||
@@ -189,27 +220,35 @@ class StandService {
|
|||||||
return res(false);
|
return res(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (row && row.max_date) {
|
const normalized = normalizeTs(row && row.max_date ? row.max_date : null);
|
||||||
date_start = getNextMonday(row.max_date); // заменить начальную дату
|
if (normalized) {
|
||||||
|
date_start = getNextMonday(normalized);
|
||||||
} else {
|
} else {
|
||||||
date_start = getNextMonday(Date.now()); // заменить начальную дату
|
date_start = getNextMonday(Date.now());
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Генерация новых записей
|
// 3. Генерация новых записей
|
||||||
const stand_length = (stand.hour_end - stand.hour_start) / stand.processing_time;
|
// вычисляем количество слотов (целое)
|
||||||
const timestamp = Math.floor(Date.now());
|
const stand_length = Math.max(0, Math.floor((stand.hour_end - stand.hour_start) / stand.processing_time));
|
||||||
|
const timestamp = Date.now();
|
||||||
const list = [];
|
const list = [];
|
||||||
|
|
||||||
for (const dayOffset of stand.week_days) {
|
// Ожидается, что stand.week_days — массив чисел 0..6, где 0 -> Понедельник, 6 -> Воскресенье
|
||||||
const stand_date = date_start + (dayOffset * 24 * 60 * 60 * 1000);
|
for (const dayOffsetRaw of stand.week_days) {
|
||||||
|
const dayOffset = Number(dayOffsetRaw);
|
||||||
|
if (!Number.isFinite(dayOffset) || dayOffset < 0 || dayOffset > 6) continue;
|
||||||
|
|
||||||
|
// dayOffset: 0 => Monday, 6 => Sunday
|
||||||
|
const stand_date = date_start + (dayOffset * MS_DAY);
|
||||||
|
|
||||||
for (let i = 0; i < stand_length; i++) {
|
for (let i = 0; i < stand_length; i++) {
|
||||||
for (let q = 0; q < stand.quantity_sheep; q++) {
|
for (let q = 0; q < (Number(stand.quantity_sheep) || 0); q++) {
|
||||||
list.push([
|
list.push([
|
||||||
stand.hour_start + (stand.processing_time * i),
|
stand.hour_start + (stand.processing_time * i),
|
||||||
q,
|
q,
|
||||||
stand_date,
|
stand_date,
|
||||||
stand.id,
|
stand.id,
|
||||||
|
timestamp,
|
||||||
timestamp
|
timestamp
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
@@ -221,10 +260,10 @@ class StandService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 4. Массовая вставка
|
// 4. Массовая вставка
|
||||||
const placeholders = list.map(() => "(?, ?, ?, ?, ?)").join(",");
|
const placeholders = list.map(() => "(?, ?, ?, ?, ?, ?)").join(",");
|
||||||
const values = list.flat();
|
const values = list.flat();
|
||||||
const insertSQL = `
|
const insertSQL = `
|
||||||
INSERT INTO stand_schedule(hour, number_sheep, date, stand_id, updated_at)
|
INSERT INTO stand_schedule(hour, number_sheep, date, stand_id, updated_at, created_at)
|
||||||
VALUES ${placeholders}
|
VALUES ${placeholders}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
@@ -233,7 +272,13 @@ class StandService {
|
|||||||
console.error(err.message);
|
console.error(err.message);
|
||||||
return res(false);
|
return res(false);
|
||||||
}
|
}
|
||||||
res({ status: "ok", inserted: list.length});
|
// Notification.sendStand({
|
||||||
|
// title: "Додан новий день служіння",
|
||||||
|
// body: `Стенд «${stand.title}» поповнився, встигніть записатися.`,
|
||||||
|
// page: `/stand/card/${stand.id}`
|
||||||
|
// });
|
||||||
|
|
||||||
|
res({ status: "ok", inserted: list.length });
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
@@ -255,6 +300,8 @@ class StandService {
|
|||||||
s.id = ss.sheep_id
|
s.id = ss.sheep_id
|
||||||
WHERE
|
WHERE
|
||||||
ss.stand_id = ?
|
ss.stand_id = ?
|
||||||
|
AND
|
||||||
|
date(ss.date / 1000, 'unixepoch') >= date('now')
|
||||||
ORDER BY
|
ORDER BY
|
||||||
ss.id;
|
ss.id;
|
||||||
`;
|
`;
|
||||||
@@ -273,7 +320,8 @@ class StandService {
|
|||||||
"sheep_id": Number(row.sheep_id),
|
"sheep_id": Number(row.sheep_id),
|
||||||
"sheep_name": row.sheep_name,
|
"sheep_name": row.sheep_name,
|
||||||
"number_sheep": Number(row.number_sheep),
|
"number_sheep": Number(row.number_sheep),
|
||||||
"updated_at": Number(row.updated_at)
|
"updated_at": Number(row.updated_at),
|
||||||
|
"created_at": Number(row.created_at)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
152
api/utils/notification.js
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
const db = require("../config/db");
|
||||||
|
const webpush = require('web-push');
|
||||||
|
|
||||||
|
const VAPID_PUBLIC_KEY = process.env.VAPID_PUBLIC_KEY;
|
||||||
|
const VAPID_PRIVATE_KEY = process.env.VAPID_PRIVATE_KEY;
|
||||||
|
|
||||||
|
webpush.setVapidDetails(
|
||||||
|
'mailto:rozenrod320@gmail.com',
|
||||||
|
VAPID_PUBLIC_KEY,
|
||||||
|
VAPID_PRIVATE_KEY
|
||||||
|
);
|
||||||
|
|
||||||
|
class Notification {
|
||||||
|
async sendSheep({ sheep_id, title, body, page }) {
|
||||||
|
const sql = `
|
||||||
|
SELECT * FROM subscription
|
||||||
|
WHERE sheep_id = ?
|
||||||
|
ORDER BY id
|
||||||
|
`;
|
||||||
|
|
||||||
|
db.all(sql, [sheep_id], async (err, rows) => {
|
||||||
|
if (err) {
|
||||||
|
console.error('DB error:', err.message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!rows.length) {
|
||||||
|
console.log(`🐑 No subscriptions found for sheep_id: ${sheep_id}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`📨 Sending notification to ${rows.length} subscriptions...`);
|
||||||
|
|
||||||
|
const payload = JSON.stringify({
|
||||||
|
title: title ?? "Тестове повідомлення",
|
||||||
|
body: body ?? "Ви успішно підписалися на отримання push повідомлень!",
|
||||||
|
url: `https://${process.env.DOMAIN}${page ?? ""}`
|
||||||
|
});
|
||||||
|
|
||||||
|
const results = await Promise.allSettled(rows.map(row => {
|
||||||
|
const subscription = {
|
||||||
|
endpoint: row.endpoint,
|
||||||
|
keys: JSON.parse(row.keys),
|
||||||
|
};
|
||||||
|
return webpush.sendNotification(subscription, payload);
|
||||||
|
}));
|
||||||
|
|
||||||
|
const failed = results.filter(r => r.status === 'rejected').length;
|
||||||
|
console.log(`✅ Sent: ${rows.length - failed}, ❌ Failed: ${failed}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async sendGroup({ group_id, title, body, page }) {
|
||||||
|
const sql = `
|
||||||
|
SELECT
|
||||||
|
subscription.*
|
||||||
|
FROM
|
||||||
|
subscription
|
||||||
|
JOIN
|
||||||
|
sheeps
|
||||||
|
ON
|
||||||
|
sheeps.id = subscription.sheep_id
|
||||||
|
WHERE
|
||||||
|
sheeps.group_id = ?
|
||||||
|
ORDER BY
|
||||||
|
subscription.id;
|
||||||
|
`;
|
||||||
|
|
||||||
|
db.all(sql, [group_id], async (err, rows) => {
|
||||||
|
if (err) {
|
||||||
|
console.error('DB error:', err.message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!rows.length) {
|
||||||
|
console.log(`🐑 No subscriptions found for sheep_id: ${sheep_id}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`📨 Sending notification to ${rows.length} subscriptions...`);
|
||||||
|
|
||||||
|
const payload = JSON.stringify({
|
||||||
|
title: title ?? "Тестове повідомлення",
|
||||||
|
body: body ?? "Ви успішно підписалися на отримання push повідомлень!",
|
||||||
|
url: `https://${process.env.DOMAIN}${page ?? ""}`
|
||||||
|
});
|
||||||
|
|
||||||
|
const results = await Promise.allSettled(rows.map(row => {
|
||||||
|
const subscription = {
|
||||||
|
endpoint: row.endpoint,
|
||||||
|
keys: JSON.parse(row.keys),
|
||||||
|
};
|
||||||
|
return webpush.sendNotification(subscription, payload);
|
||||||
|
}));
|
||||||
|
|
||||||
|
const failed = results.filter(r => r.status === 'rejected').length;
|
||||||
|
console.log(`✅ Sent: ${rows.length - failed}, ❌ Failed: ${failed}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async sendStand({ title, body, page }) {
|
||||||
|
const sql = `
|
||||||
|
SELECT
|
||||||
|
subscription.*
|
||||||
|
FROM
|
||||||
|
subscription
|
||||||
|
JOIN
|
||||||
|
sheeps
|
||||||
|
ON sheeps.id = subscription.sheep_id
|
||||||
|
JOIN
|
||||||
|
possibilities
|
||||||
|
ON possibilities.sheep_id = sheeps.id
|
||||||
|
WHERE
|
||||||
|
possibilities.can_view_stand = '1'
|
||||||
|
ORDER BY
|
||||||
|
subscription.id;
|
||||||
|
`;
|
||||||
|
|
||||||
|
db.all(sql, async (err, rows) => {
|
||||||
|
if (err) {
|
||||||
|
console.error('DB error:', err.message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!rows.length) {
|
||||||
|
console.log(`🐑 No subscriptions found for sheep_id: ${sheep_id}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`📨 Sending notification to ${rows.length} subscriptions...`);
|
||||||
|
|
||||||
|
const payload = JSON.stringify({
|
||||||
|
title: title ?? "Тестове повідомлення",
|
||||||
|
body: body ?? "Ви успішно підписалися на отримання push повідомлень!",
|
||||||
|
url: `https://${process.env.DOMAIN}${page ?? ""}`
|
||||||
|
});
|
||||||
|
|
||||||
|
const results = await Promise.allSettled(rows.map(row => {
|
||||||
|
const subscription = {
|
||||||
|
endpoint: row.endpoint,
|
||||||
|
keys: JSON.parse(row.keys),
|
||||||
|
};
|
||||||
|
return webpush.sendNotification(subscription, payload);
|
||||||
|
}));
|
||||||
|
|
||||||
|
const failed = results.filter(r => r.status === 'rejected').length;
|
||||||
|
console.log(`✅ Sent: ${rows.length - failed}, ❌ Failed: ${failed}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = new Notification();
|
||||||
37
backup.py
@@ -1,37 +0,0 @@
|
|||||||
import os
|
|
||||||
import requests
|
|
||||||
from datetime import datetime
|
|
||||||
from zipfile import ZipFile, ZIP_DEFLATED
|
|
||||||
from dotenv import load_dotenv
|
|
||||||
|
|
||||||
# Загрузка переменных из .env
|
|
||||||
load_dotenv()
|
|
||||||
|
|
||||||
TELEGRAM_TOKEN = os.getenv('TELEGRAM_TOKEN')
|
|
||||||
CHAT_ID = os.getenv('CHAT_ID')
|
|
||||||
DB_PATH = os.path.join(os.getenv('DB_PATH'), 'database.sqlite')
|
|
||||||
|
|
||||||
def send_document(filename, caption):
|
|
||||||
url = f'https://api.telegram.org/bot{TELEGRAM_TOKEN}/sendDocument'
|
|
||||||
with open(filename, 'rb') as f:
|
|
||||||
response = requests.post(
|
|
||||||
url,
|
|
||||||
data={'chat_id': CHAT_ID, 'caption': caption},
|
|
||||||
files={'document': f}
|
|
||||||
)
|
|
||||||
print(response.json())
|
|
||||||
|
|
||||||
def main():
|
|
||||||
if not TELEGRAM_TOKEN or not CHAT_ID or not DB_PATH:
|
|
||||||
print("Помилка: TELEGRAM_TOKEN, CHAT_ID або DB_PATH не задано в .env.")
|
|
||||||
return
|
|
||||||
|
|
||||||
if os.path.exists(DB_PATH):
|
|
||||||
timestamp = datetime.now().strftime("%d.%m.%Y %H:%M")
|
|
||||||
caption = f"Backup Sheep Service DB - {timestamp}"
|
|
||||||
send_document(DB_PATH, caption)
|
|
||||||
else:
|
|
||||||
print("ZIP file not created")
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
11
cron/Dockerfile
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
FROM node:20.18
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY package.json package-lock.json ./
|
||||||
|
|
||||||
|
RUN npm install
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
CMD npm start
|
||||||
17
cron/config/db.js
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
const sqlite3 = require("sqlite3");
|
||||||
|
const path = require("path");
|
||||||
|
|
||||||
|
const dbPath = process.env.DATABASE_PATH || path.join(__dirname, "..");
|
||||||
|
const fullPath = path.join(dbPath, "database.sqlite");
|
||||||
|
|
||||||
|
const db = new sqlite3.Database(fullPath, (err) => {
|
||||||
|
if (err) {
|
||||||
|
console.error("❌ Failed to open sqlite database:", err);
|
||||||
|
} else {
|
||||||
|
console.log("✅ SQLite DB opened at", fullPath);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
db.exec("PRAGMA foreign_keys = ON;");
|
||||||
|
|
||||||
|
module.exports = db;
|
||||||
13
cron/cron.js
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
const cron = require("node-cron");
|
||||||
|
const Backup = require("./tasks/backup");
|
||||||
|
|
||||||
|
// Завдання: резервна копія БД кожен день в 22:30
|
||||||
|
cron.schedule("30 22 * * *", () => {
|
||||||
|
Backup.database();
|
||||||
|
|
||||||
|
const now = new Date().toLocaleString();
|
||||||
|
console.log(`[${now}] Завдання «Backup» виконане!`);
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
console.log("Cron-завдання запущено.");
|
||||||
3594
cron/package-lock.json
generated
Normal file
18
cron/package.json
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"name": "CRON Sheep Service",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"main": "cron.js",
|
||||||
|
"scripts": {
|
||||||
|
"start": "node cron.js"
|
||||||
|
},
|
||||||
|
"author": "",
|
||||||
|
"license": "ISC",
|
||||||
|
"description": "",
|
||||||
|
"dependencies": {
|
||||||
|
"node-cron": "^4.2.1",
|
||||||
|
"sqlite3": "^5.1.7",
|
||||||
|
"web-push": "^3.6.7",
|
||||||
|
"dotenv": "^17.2.0",
|
||||||
|
"node-telegram-bot-api": "^0.66.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
33
cron/tasks/backup.js
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
const fs = require("fs");
|
||||||
|
const path = require("path");
|
||||||
|
const TelegramBot = require("node-telegram-bot-api");
|
||||||
|
|
||||||
|
const TOKEN = process.env.TELEGRAM_TOKEN;
|
||||||
|
const CHAT_ID = process.env.CHAT_ID;
|
||||||
|
const DB_PATH = process.env.DATABASE_PATH || "../";
|
||||||
|
const FILE = path.join(DB_PATH, "database.sqlite");
|
||||||
|
|
||||||
|
const bot = new TelegramBot(TOKEN, { polling: false });
|
||||||
|
|
||||||
|
class Backup {
|
||||||
|
async database() {
|
||||||
|
try {
|
||||||
|
if (!fs.existsSync(FILE)) {
|
||||||
|
console.log("❌ Файл бази даних не знайдено:", FILE);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`📤 Надсилаю файл: ${FILE}`);
|
||||||
|
|
||||||
|
await bot.sendDocument(CHAT_ID, fs.createReadStream(FILE), {
|
||||||
|
caption: "📦 Резервна копія бази даних",
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log("✅ Файл успішно надіслано!");
|
||||||
|
} catch (err) {
|
||||||
|
console.error("❌ Помилка при надсиланні файлу:", err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = new Backup();
|
||||||
152
cron/utils/notification.js
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
const db = require("../config/db");
|
||||||
|
const webpush = require('web-push');
|
||||||
|
|
||||||
|
const VAPID_PUBLIC_KEY = process.env.VAPID_PUBLIC_KEY;
|
||||||
|
const VAPID_PRIVATE_KEY = process.env.VAPID_PRIVATE_KEY;
|
||||||
|
|
||||||
|
webpush.setVapidDetails(
|
||||||
|
'mailto:rozenrod320@gmail.com',
|
||||||
|
VAPID_PUBLIC_KEY,
|
||||||
|
VAPID_PRIVATE_KEY
|
||||||
|
);
|
||||||
|
|
||||||
|
class Notification {
|
||||||
|
async sendSheep({ sheep_id, title, body, page }) {
|
||||||
|
const sql = `
|
||||||
|
SELECT * FROM subscription
|
||||||
|
WHERE sheep_id = ?
|
||||||
|
ORDER BY id
|
||||||
|
`;
|
||||||
|
|
||||||
|
db.all(sql, [sheep_id], async (err, rows) => {
|
||||||
|
if (err) {
|
||||||
|
console.error('DB error:', err.message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!rows.length) {
|
||||||
|
console.log(`🐑 No subscriptions found for sheep_id: ${sheep_id}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`📨 Sending notification to ${rows.length} subscriptions...`);
|
||||||
|
|
||||||
|
const payload = JSON.stringify({
|
||||||
|
title: title ?? "Тестове повідомлення",
|
||||||
|
body: body ?? "Ви успішно підписалися на отримання push повідомлень!",
|
||||||
|
url: `https://${process.env.DOMAIN}${page ?? ""}`
|
||||||
|
});
|
||||||
|
|
||||||
|
const results = await Promise.allSettled(rows.map(row => {
|
||||||
|
const subscription = {
|
||||||
|
endpoint: row.endpoint,
|
||||||
|
keys: JSON.parse(row.keys),
|
||||||
|
};
|
||||||
|
return webpush.sendNotification(subscription, payload);
|
||||||
|
}));
|
||||||
|
|
||||||
|
const failed = results.filter(r => r.status === 'rejected').length;
|
||||||
|
console.log(`✅ Sent: ${rows.length - failed}, ❌ Failed: ${failed}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async sendGroup({ group_id, title, body, page }) {
|
||||||
|
const sql = `
|
||||||
|
SELECT
|
||||||
|
subscription.*
|
||||||
|
FROM
|
||||||
|
subscription
|
||||||
|
JOIN
|
||||||
|
sheeps
|
||||||
|
ON
|
||||||
|
sheeps.id = subscription.sheep_id
|
||||||
|
WHERE
|
||||||
|
sheeps.group_id = ?
|
||||||
|
ORDER BY
|
||||||
|
subscription.id;
|
||||||
|
`;
|
||||||
|
|
||||||
|
db.all(sql, [group_id], async (err, rows) => {
|
||||||
|
if (err) {
|
||||||
|
console.error('DB error:', err.message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!rows.length) {
|
||||||
|
console.log(`🐑 No subscriptions found for sheep_id: ${sheep_id}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`📨 Sending notification to ${rows.length} subscriptions...`);
|
||||||
|
|
||||||
|
const payload = JSON.stringify({
|
||||||
|
title: title ?? "Тестове повідомлення",
|
||||||
|
body: body ?? "Ви успішно підписалися на отримання push повідомлень!",
|
||||||
|
url: `https://${process.env.DOMAIN}${page ?? ""}`
|
||||||
|
});
|
||||||
|
|
||||||
|
const results = await Promise.allSettled(rows.map(row => {
|
||||||
|
const subscription = {
|
||||||
|
endpoint: row.endpoint,
|
||||||
|
keys: JSON.parse(row.keys),
|
||||||
|
};
|
||||||
|
return webpush.sendNotification(subscription, payload);
|
||||||
|
}));
|
||||||
|
|
||||||
|
const failed = results.filter(r => r.status === 'rejected').length;
|
||||||
|
console.log(`✅ Sent: ${rows.length - failed}, ❌ Failed: ${failed}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async sendStand({ title, body, page }) {
|
||||||
|
const sql = `
|
||||||
|
SELECT
|
||||||
|
subscription.*
|
||||||
|
FROM
|
||||||
|
subscription
|
||||||
|
JOIN
|
||||||
|
sheeps
|
||||||
|
ON sheeps.id = subscription.sheep_id
|
||||||
|
JOIN
|
||||||
|
possibilities
|
||||||
|
ON possibilities.sheep_id = sheeps.id
|
||||||
|
WHERE
|
||||||
|
possibilities.can_view_stand = '1'
|
||||||
|
ORDER BY
|
||||||
|
subscription.id;
|
||||||
|
`;
|
||||||
|
|
||||||
|
db.all(sql, async (err, rows) => {
|
||||||
|
if (err) {
|
||||||
|
console.error('DB error:', err.message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!rows.length) {
|
||||||
|
console.log(`🐑 No subscriptions found for sheep_id: ${sheep_id}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`📨 Sending notification to ${rows.length} subscriptions...`);
|
||||||
|
|
||||||
|
const payload = JSON.stringify({
|
||||||
|
title: title ?? "Тестове повідомлення",
|
||||||
|
body: body ?? "Ви успішно підписалися на отримання push повідомлень!",
|
||||||
|
url: `https://${process.env.DOMAIN}${page ?? ""}`
|
||||||
|
});
|
||||||
|
|
||||||
|
const results = await Promise.allSettled(rows.map(row => {
|
||||||
|
const subscription = {
|
||||||
|
endpoint: row.endpoint,
|
||||||
|
keys: JSON.parse(row.keys),
|
||||||
|
};
|
||||||
|
return webpush.sendNotification(subscription, payload);
|
||||||
|
}));
|
||||||
|
|
||||||
|
const failed = results.filter(r => r.status === 'rejected').length;
|
||||||
|
console.log(`✅ Sent: ${rows.length - failed}, ❌ Failed: ${failed}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = new Notification();
|
||||||
|
Before Width: | Height: | Size: 365 KiB After Width: | Height: | Size: 365 KiB |
|
Before Width: | Height: | Size: 110 KiB After Width: | Height: | Size: 110 KiB |
|
Before Width: | Height: | Size: 76 KiB After Width: | Height: | Size: 76 KiB |
|
Before Width: | Height: | Size: 71 KiB After Width: | Height: | Size: 71 KiB |
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 77 KiB After Width: | Height: | Size: 77 KiB |
|
Before Width: | Height: | Size: 74 KiB After Width: | Height: | Size: 74 KiB |
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 76 KiB After Width: | Height: | Size: 76 KiB |
|
Before Width: | Height: | Size: 89 KiB After Width: | Height: | Size: 89 KiB |
|
Before Width: | Height: | Size: 87 KiB After Width: | Height: | Size: 87 KiB |
|
Before Width: | Height: | Size: 70 KiB After Width: | Height: | Size: 70 KiB |
|
Before Width: | Height: | Size: 82 KiB After Width: | Height: | Size: 82 KiB |
|
Before Width: | Height: | Size: 87 KiB After Width: | Height: | Size: 87 KiB |
|
Before Width: | Height: | Size: 87 KiB After Width: | Height: | Size: 87 KiB |
|
Before Width: | Height: | Size: 61 KiB After Width: | Height: | Size: 61 KiB |
|
Before Width: | Height: | Size: 67 KiB After Width: | Height: | Size: 67 KiB |
|
Before Width: | Height: | Size: 103 KiB After Width: | Height: | Size: 103 KiB |
|
Before Width: | Height: | Size: 101 KiB After Width: | Height: | Size: 101 KiB |
|
Before Width: | Height: | Size: 78 KiB After Width: | Height: | Size: 78 KiB |
|
Before Width: | Height: | Size: 90 KiB After Width: | Height: | Size: 90 KiB |
|
Before Width: | Height: | Size: 48 KiB After Width: | Height: | Size: 48 KiB |
|
Before Width: | Height: | Size: 101 KiB After Width: | Height: | Size: 101 KiB |
|
Before Width: | Height: | Size: 68 KiB After Width: | Height: | Size: 68 KiB |
|
Before Width: | Height: | Size: 53 KiB After Width: | Height: | Size: 53 KiB |
|
Before Width: | Height: | Size: 69 KiB After Width: | Height: | Size: 69 KiB |
|
Before Width: | Height: | Size: 70 KiB After Width: | Height: | Size: 70 KiB |
|
Before Width: | Height: | Size: 77 KiB After Width: | Height: | Size: 77 KiB |
|
Before Width: | Height: | Size: 81 KiB After Width: | Height: | Size: 81 KiB |
|
Before Width: | Height: | Size: 71 KiB After Width: | Height: | Size: 71 KiB |
|
Before Width: | Height: | Size: 75 KiB After Width: | Height: | Size: 75 KiB |
|
Before Width: | Height: | Size: 91 KiB After Width: | Height: | Size: 91 KiB |
|
Before Width: | Height: | Size: 72 KiB After Width: | Height: | Size: 72 KiB |
|
Before Width: | Height: | Size: 64 KiB After Width: | Height: | Size: 64 KiB |
|
Before Width: | Height: | Size: 70 KiB After Width: | Height: | Size: 70 KiB |
|
Before Width: | Height: | Size: 76 KiB After Width: | Height: | Size: 76 KiB |
|
Before Width: | Height: | Size: 88 KiB After Width: | Height: | Size: 88 KiB |
|
Before Width: | Height: | Size: 133 KiB After Width: | Height: | Size: 133 KiB |
|
Before Width: | Height: | Size: 126 KiB After Width: | Height: | Size: 126 KiB |
|
Before Width: | Height: | Size: 106 KiB After Width: | Height: | Size: 106 KiB |
|
Before Width: | Height: | Size: 94 KiB After Width: | Height: | Size: 94 KiB |
|
Before Width: | Height: | Size: 92 KiB After Width: | Height: | Size: 92 KiB |
|
Before Width: | Height: | Size: 88 KiB After Width: | Height: | Size: 88 KiB |
|
Before Width: | Height: | Size: 86 KiB After Width: | Height: | Size: 86 KiB |
|
Before Width: | Height: | Size: 92 KiB After Width: | Height: | Size: 92 KiB |
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 97 KiB After Width: | Height: | Size: 97 KiB |
|
Before Width: | Height: | Size: 76 KiB After Width: | Height: | Size: 76 KiB |
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 51 KiB After Width: | Height: | Size: 51 KiB |
|
Before Width: | Height: | Size: 96 KiB After Width: | Height: | Size: 96 KiB |
|
Before Width: | Height: | Size: 84 KiB After Width: | Height: | Size: 84 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 33 KiB After Width: | Height: | Size: 33 KiB |
|
Before Width: | Height: | Size: 29 KiB After Width: | Height: | Size: 29 KiB |
|
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 34 KiB After Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 35 KiB After Width: | Height: | Size: 35 KiB |
|
Before Width: | Height: | Size: 36 KiB After Width: | Height: | Size: 36 KiB |
|
Before Width: | Height: | Size: 29 KiB After Width: | Height: | Size: 29 KiB |
|
Before Width: | Height: | Size: 36 KiB After Width: | Height: | Size: 36 KiB |
|
Before Width: | Height: | Size: 35 KiB After Width: | Height: | Size: 35 KiB |
|
Before Width: | Height: | Size: 41 KiB After Width: | Height: | Size: 41 KiB |
|
Before Width: | Height: | Size: 41 KiB After Width: | Height: | Size: 41 KiB |
|
Before Width: | Height: | Size: 36 KiB After Width: | Height: | Size: 36 KiB |
|
Before Width: | Height: | Size: 33 KiB After Width: | Height: | Size: 33 KiB |
|
Before Width: | Height: | Size: 23 KiB After Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 38 KiB After Width: | Height: | Size: 38 KiB |
|
Before Width: | Height: | Size: 28 KiB After Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 33 KiB After Width: | Height: | Size: 33 KiB |
|
Before Width: | Height: | Size: 33 KiB After Width: | Height: | Size: 33 KiB |
|
Before Width: | Height: | Size: 34 KiB After Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 34 KiB After Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 34 KiB After Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 33 KiB After Width: | Height: | Size: 33 KiB |
|
Before Width: | Height: | Size: 31 KiB After Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 33 KiB After Width: | Height: | Size: 33 KiB |
|
Before Width: | Height: | Size: 40 KiB After Width: | Height: | Size: 40 KiB |