init
This commit is contained in:
commit
5d0cd02c21
19
.editorconfig
Normal file
19
.editorconfig
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
root = true
|
||||||
|
|
||||||
|
[*]
|
||||||
|
indent_style = tab
|
||||||
|
indent_size = 2
|
||||||
|
end_of_line = lf
|
||||||
|
charset = utf-8
|
||||||
|
trim_trailing_whitespace = true
|
||||||
|
insert_final_newline = true
|
||||||
|
|
||||||
|
[package.json]
|
||||||
|
indent_style = space
|
||||||
|
indent_size = 2
|
||||||
|
end_of_line = lf
|
||||||
|
|
||||||
|
[docker-compose.yml]
|
||||||
|
indent_style = space
|
||||||
|
indent_size = 2
|
||||||
|
end_of_line = lf
|
182
.gitignore
vendored
Normal file
182
.gitignore
vendored
Normal file
@ -0,0 +1,182 @@
|
|||||||
|
# Based on https://raw.githubusercontent.com/github/gitignore/main/Node.gitignore
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
|
||||||
|
logs
|
||||||
|
_.log
|
||||||
|
npm-debug.log_
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
.pnpm-debug.log*
|
||||||
|
|
||||||
|
# Caches
|
||||||
|
|
||||||
|
.cache
|
||||||
|
|
||||||
|
# Diagnostic reports (https://nodejs.org/api/report.html)
|
||||||
|
|
||||||
|
report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
|
||||||
|
|
||||||
|
# Runtime data
|
||||||
|
|
||||||
|
pids
|
||||||
|
_.pid
|
||||||
|
_.seed
|
||||||
|
*.pid.lock
|
||||||
|
|
||||||
|
# Directory for instrumented libs generated by jscoverage/JSCover
|
||||||
|
|
||||||
|
lib-cov
|
||||||
|
|
||||||
|
# Coverage directory used by tools like istanbul
|
||||||
|
|
||||||
|
coverage
|
||||||
|
*.lcov
|
||||||
|
|
||||||
|
# nyc test coverage
|
||||||
|
|
||||||
|
.nyc_output
|
||||||
|
|
||||||
|
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
|
||||||
|
|
||||||
|
.grunt
|
||||||
|
|
||||||
|
# Bower dependency directory (https://bower.io/)
|
||||||
|
|
||||||
|
bower_components
|
||||||
|
|
||||||
|
# node-waf configuration
|
||||||
|
|
||||||
|
.lock-wscript
|
||||||
|
|
||||||
|
# Compiled binary addons (https://nodejs.org/api/addons.html)
|
||||||
|
|
||||||
|
build/Release
|
||||||
|
|
||||||
|
# Dependency directories
|
||||||
|
|
||||||
|
node_modules/
|
||||||
|
jspm_packages/
|
||||||
|
|
||||||
|
# Snowpack dependency directory (https://snowpack.dev/)
|
||||||
|
|
||||||
|
web_modules/
|
||||||
|
|
||||||
|
# TypeScript cache
|
||||||
|
|
||||||
|
*.tsbuildinfo
|
||||||
|
|
||||||
|
# Optional npm cache directory
|
||||||
|
|
||||||
|
.npm
|
||||||
|
|
||||||
|
# Optional eslint cache
|
||||||
|
|
||||||
|
.eslintcache
|
||||||
|
|
||||||
|
# Optional stylelint cache
|
||||||
|
|
||||||
|
.stylelintcache
|
||||||
|
|
||||||
|
# Microbundle cache
|
||||||
|
|
||||||
|
.rpt2_cache/
|
||||||
|
.rts2_cache_cjs/
|
||||||
|
.rts2_cache_es/
|
||||||
|
.rts2_cache_umd/
|
||||||
|
|
||||||
|
# Optional REPL history
|
||||||
|
|
||||||
|
.node_repl_history
|
||||||
|
|
||||||
|
# Output of 'npm pack'
|
||||||
|
|
||||||
|
*.tgz
|
||||||
|
|
||||||
|
# Yarn Integrity file
|
||||||
|
|
||||||
|
.yarn-integrity
|
||||||
|
|
||||||
|
# dotenv environment variable files
|
||||||
|
|
||||||
|
.env
|
||||||
|
.env.development.local
|
||||||
|
.env.test.local
|
||||||
|
.env.production.local
|
||||||
|
.env.local
|
||||||
|
|
||||||
|
# parcel-bundler cache (https://parceljs.org/)
|
||||||
|
|
||||||
|
.parcel-cache
|
||||||
|
|
||||||
|
# Next.js build output
|
||||||
|
|
||||||
|
.next
|
||||||
|
out
|
||||||
|
|
||||||
|
# Nuxt.js build / generate output
|
||||||
|
|
||||||
|
.nuxt
|
||||||
|
dist
|
||||||
|
|
||||||
|
# Gatsby files
|
||||||
|
|
||||||
|
# Comment in the public line in if your project uses Gatsby and not Next.js
|
||||||
|
|
||||||
|
# https://nextjs.org/blog/next-9-1#public-directory-support
|
||||||
|
|
||||||
|
# public
|
||||||
|
|
||||||
|
# vuepress build output
|
||||||
|
|
||||||
|
.vuepress/dist
|
||||||
|
|
||||||
|
# vuepress v2.x temp and cache directory
|
||||||
|
|
||||||
|
.temp
|
||||||
|
|
||||||
|
# Docusaurus cache and generated files
|
||||||
|
|
||||||
|
.docusaurus
|
||||||
|
|
||||||
|
# Serverless directories
|
||||||
|
|
||||||
|
.serverless/
|
||||||
|
|
||||||
|
# FuseBox cache
|
||||||
|
|
||||||
|
.fusebox/
|
||||||
|
|
||||||
|
# DynamoDB Local files
|
||||||
|
|
||||||
|
.dynamodb/
|
||||||
|
|
||||||
|
# TernJS port file
|
||||||
|
|
||||||
|
.tern-port
|
||||||
|
|
||||||
|
# Stores VSCode versions used for testing VSCode extensions
|
||||||
|
|
||||||
|
.vscode-test
|
||||||
|
|
||||||
|
# yarn v2
|
||||||
|
|
||||||
|
.yarn/cache
|
||||||
|
.yarn/unplugged
|
||||||
|
.yarn/build-state.yml
|
||||||
|
.yarn/install-state.gz
|
||||||
|
.pnp.*
|
||||||
|
|
||||||
|
# IntelliJ based IDEs
|
||||||
|
.idea
|
||||||
|
|
||||||
|
# Finder (MacOS) folder config
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
|
# Misc
|
||||||
|
config.ini
|
||||||
|
*.mdb
|
||||||
|
*.exe
|
||||||
|
/.*.tmp
|
||||||
|
/grafana
|
21
LICENSE
Normal file
21
LICENSE
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2024-present Snazzah
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
BIN
client/bun.lockb
Normal file
BIN
client/bun.lockb
Normal file
Binary file not shown.
107
client/index.ts
Normal file
107
client/index.ts
Normal file
@ -0,0 +1,107 @@
|
|||||||
|
import debounce from 'just-debounce-it';
|
||||||
|
import { watch } from 'node:fs';
|
||||||
|
import { stat, readFile, writeFile } from 'node:fs/promises';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import readline from 'node:readline/promises';
|
||||||
|
import { parse } from 'ini';
|
||||||
|
import MDBReader from 'mdb-reader';
|
||||||
|
import { diff } from 'just-diff';
|
||||||
|
|
||||||
|
const rl = readline.createInterface({
|
||||||
|
input: process.stdin,
|
||||||
|
output: process.stdout
|
||||||
|
});
|
||||||
|
|
||||||
|
async function promptToClose(message: string) {
|
||||||
|
await rl.question(`== ${message}\n== Press enter to quit.`);
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for config.ini file
|
||||||
|
const configPath = join(process.cwd(), 'config.ini');
|
||||||
|
try {
|
||||||
|
const configStat = await stat(configPath);
|
||||||
|
if (configStat.isDirectory()) await promptToClose('The config file must be a file.');
|
||||||
|
} catch (e) {
|
||||||
|
await writeFile(configPath, [
|
||||||
|
'; The database file to upload, defaults to C:\\Users\\<user>\\Documents\\Affirmatech\\N3FJP Software\\ARRL-Field-Day\\LogData.mdb',
|
||||||
|
`; database_file = "${process.env.USERNAME ? `C:\\Users\\${process.env.USERNAME}\\Documents\\Affirmatech\\N3FJP Software\\ARRL-Field-Day\\LogData.mdb` : ''}"`,
|
||||||
|
'',
|
||||||
|
'; The endpoint to upload to',
|
||||||
|
'log_endpoint = "https://example.local/up"'
|
||||||
|
].join('\n'))
|
||||||
|
await promptToClose('Failed to find config file. A new one was created, so put in your variables in there.');
|
||||||
|
}
|
||||||
|
|
||||||
|
let databaseFile: string;
|
||||||
|
let logEndpoint: string;
|
||||||
|
try {
|
||||||
|
const config = parse(await readFile(configPath, { encoding: 'utf-8' }));
|
||||||
|
databaseFile = config.database_file;
|
||||||
|
logEndpoint = config.log_endpoint;
|
||||||
|
if (!logEndpoint) await promptToClose('A log endpoint is required in the config file.');
|
||||||
|
} catch (e) {
|
||||||
|
databaseFile = '';
|
||||||
|
await promptToClose('Failed to parse config file. You can remove the file and re-run this program if more problems occur.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const dbPath = databaseFile ?? process.env.USERNAME ? join('C:/Users', process.env.USERNAME!, 'Documents/Affirmatech/N3FJP Software/ARRL-Field-Day/LogData.mdb') : '';
|
||||||
|
|
||||||
|
// Check for log file
|
||||||
|
try {
|
||||||
|
if (!dbPath) await promptToClose('A log path is needed to use this program.');
|
||||||
|
const fileStat = await stat(dbPath);
|
||||||
|
if (fileStat.isDirectory()) await promptToClose('The log path must point to a file.');
|
||||||
|
console.log(`\n== Following changes for the file "${dbPath}"\n== Ctrl-C to exit.\n`);
|
||||||
|
} catch (e) {
|
||||||
|
await promptToClose(`Failed to find log file at "${dbPath}"`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Upload logic
|
||||||
|
let lastData: Record<string, any> = {};
|
||||||
|
async function _upload() {
|
||||||
|
console.log('-- Reading log...');
|
||||||
|
const reader = new MDBReader(await readFile(dbPath));
|
||||||
|
if (!reader.getTableNames().includes('tblContacts'))
|
||||||
|
return console.log('!! No "tblContacts" table present.');
|
||||||
|
|
||||||
|
const table = reader.getTable("tblContacts");
|
||||||
|
const tableData = table.getData<Record<string, string>>();
|
||||||
|
|
||||||
|
const data = tableData.reduce((p, r) => ({ ...p, [r.fldPrimaryKey]: r }), {} as Record<string, any>);
|
||||||
|
const differences = diff(lastData, data);
|
||||||
|
const changedIds = [...new Set(differences.filter((r) => r.op !== 'remove').map((r) => r.path[0]))];
|
||||||
|
const changedRows = changedIds.map((id) => data[id]);
|
||||||
|
|
||||||
|
if (changedIds.length === 0) return void console.log('-- No changes found.');
|
||||||
|
|
||||||
|
console.log(`-> Uploading ${changedIds.length.toLocaleString()} row(s)...`);
|
||||||
|
const response = await fetch(logEndpoint, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(changedRows),
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
console.log(`<- Recieved ${response.status} (${response.statusText})`);
|
||||||
|
if (response.status === 200) lastData = data;
|
||||||
|
}
|
||||||
|
|
||||||
|
const upload = debounce(_upload, 250);
|
||||||
|
|
||||||
|
const watcher = watch(dbPath, (event, filename) => {
|
||||||
|
if (event === 'rename') return void console.log(`== ${filename} was renamed, something might break`);
|
||||||
|
upload();
|
||||||
|
});
|
||||||
|
|
||||||
|
process.on("SIGINT", () => {
|
||||||
|
// close watcher when Ctrl-C is pressed
|
||||||
|
console.log('\n== Closing watcher...');
|
||||||
|
watcher.close();
|
||||||
|
|
||||||
|
process.exit(0);
|
||||||
|
});
|
||||||
|
rl.close();
|
||||||
|
|
||||||
|
// "Upload first" option
|
||||||
|
if (process.argv[2] === '--upload') _upload();
|
22
client/package.json
Normal file
22
client/package.json
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"name": "fdlogup-client",
|
||||||
|
"module": "index.ts",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"exe": "bun build --compile --target=bun-windows-x64 ./index.ts --minify --outfile fdlogup-client",
|
||||||
|
"start": "bun run ./index.ts"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/bun": "latest",
|
||||||
|
"@types/ini": "^4.1.1"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"typescript": "^5.0.0"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"ini": "^4.1.3",
|
||||||
|
"just-debounce-it": "^3.2.0",
|
||||||
|
"just-diff": "^6.0.2",
|
||||||
|
"mdb-reader": "^3.0.0"
|
||||||
|
}
|
||||||
|
}
|
27
client/tsconfig.json
Normal file
27
client/tsconfig.json
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
// Enable latest features
|
||||||
|
"lib": ["ESNext", "DOM"],
|
||||||
|
"target": "ESNext",
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"allowJs": true,
|
||||||
|
|
||||||
|
// Bundler mode
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"noEmit": true,
|
||||||
|
|
||||||
|
// Best practices
|
||||||
|
"strict": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
|
||||||
|
// Some stricter flags (disabled by default)
|
||||||
|
"noUnusedLocals": false,
|
||||||
|
"noUnusedParameters": false,
|
||||||
|
"noPropertyAccessFromIndexSignature": false
|
||||||
|
}
|
||||||
|
}
|
34
docker-compose.yml
Normal file
34
docker-compose.yml
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- '5432:5432'
|
||||||
|
environment:
|
||||||
|
POSTGRES_PASSWORD: fdlogup
|
||||||
|
POSTGRES_USER: fdlogup
|
||||||
|
POSTGRES_DB: fdlogup
|
||||||
|
volumes:
|
||||||
|
- "postgres:/var/lib/postgresql/data"
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U fdlogup -d fdlogup"]
|
||||||
|
interval: "10s"
|
||||||
|
timeout: "5s"
|
||||||
|
retries: 5
|
||||||
|
start_period: "5s"
|
||||||
|
grafana:
|
||||||
|
image: grafana/grafana:latest
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- '3000:3000'
|
||||||
|
depends_on:
|
||||||
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
|
environment:
|
||||||
|
- GF_INSTALL_PLUGINS=orchestracities-map-panel
|
||||||
|
volumes:
|
||||||
|
- ./grafana:/var/lib/grafana
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
postgres:
|
||||||
|
grafana:
|
BIN
server/bun.lockb
Normal file
BIN
server/bun.lockb
Normal file
Binary file not shown.
58
server/index.ts
Normal file
58
server/index.ts
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
// import { InfluxDB, Point } from '@influxdata/influxdb-client';
|
||||||
|
import pg from 'pg';
|
||||||
|
const { Client } = pg;
|
||||||
|
const client = new Client(process.env.DATABASE_URL);
|
||||||
|
await client.connect();
|
||||||
|
|
||||||
|
// export const influx = new InfluxDB({ url: process.env.INFLUX_URL!, token: process.env.INFLUX_TOKEN! });
|
||||||
|
// export const writeApi = influx.getWriteApi(process.env.INFLUX_ORG!, process.env.INFLUX_BUCKET!, 's');
|
||||||
|
|
||||||
|
const fields = ['id', 'call', 'prefix', 'mode', 'band', 'state', 'section', 'country', 'initials', 'operator', 'class', 'computer', 'time'];
|
||||||
|
|
||||||
|
const server = Bun.serve({
|
||||||
|
hostname: process.env.HOST || '0.0.0.0',
|
||||||
|
port: process.env.PORT ? parseInt(process.env.PORT) : 9992,
|
||||||
|
async fetch (req) {
|
||||||
|
const path = new URL(req.url).pathname;
|
||||||
|
console.log('-> Recieved logs, parsing...');
|
||||||
|
if (req.method !== 'POST') new Response('Invalid method', { status: 405 });
|
||||||
|
if (req.headers.get('Content-Type') !== 'application/json') new Response('Invalid content type', { status: 404 });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const newRows: any[] = await req.json();
|
||||||
|
|
||||||
|
const query = `INSERT INTO contacts (${fields.join(', ')}) VALUES (${fields.map((_, i) => `$${i + 1}`).join(', ')}) ON CONFLICT (id) DO UPDATE SET ${fields.map((f, i) => `${f} = $${i + 1}`).slice(1).join(', ')}`
|
||||||
|
console.log(query);
|
||||||
|
|
||||||
|
for (const row of newRows) {
|
||||||
|
const data: Record<string, string> = {
|
||||||
|
id: row.fldPrimaryKey,
|
||||||
|
call: row.fldCall,
|
||||||
|
prefix: row.fldPrefix,
|
||||||
|
mode: row.fldModeContest,
|
||||||
|
band: row.fldBand,
|
||||||
|
state: row.fldState,
|
||||||
|
section: row.fldSection,
|
||||||
|
country: row.fldCountryWorked,
|
||||||
|
initials: row.fldInitials,
|
||||||
|
operator: row.fldOperator,
|
||||||
|
class: row.fldClass,
|
||||||
|
computer: row.fldComputerName,
|
||||||
|
time: `${row.fldDateStr.replaceAll('/', '-')} ${row.fldTimeOnStr}+00`
|
||||||
|
};
|
||||||
|
await client.query(query, fields.map((f) => data[f]));
|
||||||
|
}
|
||||||
|
|
||||||
|
// await writeApi.close();
|
||||||
|
console.log(`<- Wrote ${newRows.length.toLocaleString()} row(s)!`);
|
||||||
|
} catch (e) {
|
||||||
|
console.log('!! Failed to push');
|
||||||
|
console.error(e);
|
||||||
|
new Response('Server error', { status: 500 });
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Response('Okay!');
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
console.log(`Listening on ${server.url}`);
|
19
server/package.json
Normal file
19
server/package.json
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"name": "fdlogup-server",
|
||||||
|
"module": "index.ts",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "bun --hot run ./index.ts",
|
||||||
|
"start": "bun run ./index.ts"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/bun": "latest",
|
||||||
|
"@types/pg": "^8.11.6"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"typescript": "^5.0.0"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"pg": "^8.12.0"
|
||||||
|
}
|
||||||
|
}
|
82
server/schema.sql
Normal file
82
server/schema.sql
Normal file
File diff suppressed because one or more lines are too long
27
server/tsconfig.json
Normal file
27
server/tsconfig.json
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
// Enable latest features
|
||||||
|
"lib": ["ESNext", "DOM"],
|
||||||
|
"target": "ESNext",
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"allowJs": true,
|
||||||
|
|
||||||
|
// Bundler mode
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"noEmit": true,
|
||||||
|
|
||||||
|
// Best practices
|
||||||
|
"strict": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
|
||||||
|
// Some stricter flags (disabled by default)
|
||||||
|
"noUnusedLocals": false,
|
||||||
|
"noUnusedParameters": false,
|
||||||
|
"noPropertyAccessFromIndexSignature": false
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in New Issue
Block a user