copy paste images in admin
This commit is contained in:
1 parent
bef622b61f
commit
42ca51d58b
9 files changed
+189
-13
No files matched your search
@@ -28,7 +28,7 @@ data/
|
|||||||
site.yml Name, Pronomen, Links, Skills, "Gerade dran"
|
site.yml Name, Pronomen, Links, Skills, "Gerade dran"
|
||||||
journey.yml Stationen für den Werdegang
|
journey.yml Stationen für den Werdegang
|
||||||
homelab.yml Hardware und Dienste
|
homelab.yml Hardware und Dienste
|
||||||
pages/ Werdegang und Homelab als <name>.de.md / <name>.en.md
|
pages/ Werdegang und Homelab als <name>.de.md / <name>.en.md, Bilder in pages/images/
|
||||||
projects/<slug>/ meta.yml, de.md, en.md und die Bilder
|
projects/<slug>/ meta.yml, de.md, en.md und die Bilder
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -48,6 +48,8 @@ Unter derselben Adresse zeigt `/de/` die ganze Seite mit Entwürfen, als Vorscha
|
|||||||
|
|
||||||
Hochgeladene Bilder werden auf höchstens 2400 px verkleinert und neu gespeichert, dabei fallen EXIF-Daten wie der Aufnahmeort weg.
|
Hochgeladene Bilder werden auf höchstens 2400 px verkleinert und neu gespeichert, dabei fallen EXIF-Daten wie der Aufnahmeort weg.
|
||||||
|
|
||||||
|
In die Textfelder für Projekte, Werdegang und Homelab lassen sich Bilder mit Strg+V oder per Drag & Drop einfügen. Sie werden sofort hochgeladen und als Markdown eingesetzt, bei Projekten landen sie im Projektordner, sonst in `pages/images/`. Bei einem neuen Projekt geht das erst nach dem ersten Speichern.
|
||||||
|
|
||||||
## Deployment
|
## Deployment
|
||||||
|
|
||||||
Läuft als Docker-Stack in Dockge hinter Nginx Proxy Manager.
|
Läuft als Docker-Stack in Dockge hinter Nginx Proxy Manager.
|
||||||
|
|||||||
@@ -29,8 +29,98 @@ function rowIndexes(field) {
|
|||||||
return indexes;
|
return indexes;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Images pasted or dropped into a text field are uploaded right away and inserted as Markdown.
|
||||||
|
// The form says where they go (data-image-upload), the text field opts in with data-images.
|
||||||
|
|
||||||
|
let uploading = 0;
|
||||||
|
let placeholders = 0;
|
||||||
|
|
||||||
|
async function uploadImage(form, file, name) {
|
||||||
|
const body = new FormData();
|
||||||
|
body.set('_csrf', form.elements._csrf.value);
|
||||||
|
body.set('bild', file, name);
|
||||||
|
const res = await fetch(form.dataset.imageUpload, { method: 'POST', body });
|
||||||
|
const type = res.headers.get('content-type') ?? '';
|
||||||
|
const data = type.includes('json') ? await res.json() : null;
|
||||||
|
if (res.ok && data?.src) return data.src;
|
||||||
|
const message = data?.error ?? (type.startsWith('text/plain') ? await res.text() : '');
|
||||||
|
throw new Error(message || 'Das Bild konnte nicht hochgeladen werden.');
|
||||||
|
}
|
||||||
|
|
||||||
|
function replaceText(area, search, replacement) {
|
||||||
|
const start = area.value.indexOf(search);
|
||||||
|
if (start !== -1) area.setRangeText(replacement, start, start + search.length, 'preserve');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function insertImages(area, files, pasted) {
|
||||||
|
if (!area.form.dataset.imageUpload) {
|
||||||
|
window.alert('Speichere das Projekt einmal, danach kannst du Bilder in den Text einfügen.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Each image gets a paragraph of its own, glued to the text around it would break headings
|
||||||
|
const before = area.value.slice(0, area.selectionStart);
|
||||||
|
const after = area.value.slice(area.selectionEnd);
|
||||||
|
const lead = !before || before.endsWith('\n\n') ? '' : before.endsWith('\n') ? '\n' : '\n\n';
|
||||||
|
const trail = !after || after.startsWith('\n\n') ? '' : after.startsWith('\n') ? '\n' : '\n\n';
|
||||||
|
const marks = files.map(() => `![Bild ${++placeholders} wird hochgeladen …]()`);
|
||||||
|
area.setRangeText(lead + marks.join('\n\n') + trail, area.selectionStart, area.selectionEnd, 'end');
|
||||||
|
|
||||||
|
// One after the other, so two pasted screenshots don't race for the same file name
|
||||||
|
uploading++;
|
||||||
|
try {
|
||||||
|
for (const [i, file] of files.entries()) {
|
||||||
|
try {
|
||||||
|
const src = await uploadImage(area.form, file, pasted ? 'bild' : file.name);
|
||||||
|
replaceText(area, marks[i], ``);
|
||||||
|
} catch (err) {
|
||||||
|
replaceText(area, marks[i], '');
|
||||||
|
window.alert(err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
uploading--;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const imagesIn = (list) => [...(list?.files ?? [])].filter((file) => file.type.startsWith('image/'));
|
||||||
|
const closest = (event, selector) => (event.target instanceof Element ? event.target.closest(selector) : null);
|
||||||
|
const hasFiles = (event) => [...(event.dataTransfer?.types ?? [])].includes('Files');
|
||||||
|
|
||||||
|
document.addEventListener('paste', (event) => {
|
||||||
|
const area = closest(event, 'textarea[data-images]');
|
||||||
|
const files = imagesIn(event.clipboardData);
|
||||||
|
// Office programs put a picture of copied text on the clipboard too, then the text wins
|
||||||
|
if (!area || !files.length || event.clipboardData.getData('text/plain').trim()) return;
|
||||||
|
event.preventDefault();
|
||||||
|
insertImages(area, files, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Without this a file dropped next to a text field opens in the tab and the form is gone
|
||||||
|
document.addEventListener('dragover', (event) => {
|
||||||
|
if (!hasFiles(event) || closest(event, 'input[type="file"]')) return;
|
||||||
|
event.preventDefault();
|
||||||
|
event.dataTransfer.dropEffect = closest(event, 'textarea[data-images]') ? 'copy' : 'none';
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener('drop', (event) => {
|
||||||
|
if (!hasFiles(event) || closest(event, 'input[type="file"]')) return;
|
||||||
|
event.preventDefault();
|
||||||
|
const area = closest(event, 'textarea[data-images]');
|
||||||
|
const files = imagesIn(event.dataTransfer);
|
||||||
|
if (!area || !files.length) return;
|
||||||
|
// The cursor doesn't follow a dropped file, so without one placed in the field the image goes at the end
|
||||||
|
if (document.activeElement !== area) area.setSelectionRange(area.value.length, area.value.length);
|
||||||
|
area.focus();
|
||||||
|
insertImages(area, files, false);
|
||||||
|
});
|
||||||
|
|
||||||
document.addEventListener('submit', (event) => {
|
document.addEventListener('submit', (event) => {
|
||||||
const form = event.target;
|
const form = event.target;
|
||||||
|
if (uploading) {
|
||||||
|
event.preventDefault();
|
||||||
|
window.alert('Einen Moment noch, die Bilder werden noch hochgeladen.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (form.dataset.confirm && !window.confirm(form.dataset.confirm)) {
|
if (form.dataset.confirm && !window.confirm(form.dataset.confirm)) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
return;
|
return;
|
||||||
|
|||||||
+23
-1
@@ -196,5 +196,27 @@ export function createFiles({ contentDir, dataDir }) {
|
|||||||
await rm(projectDir(slug), { recursive: true, force: true });
|
await rm(projectDir(slug), { recursive: true, force: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
return { readYaml, writeYaml, readMarkdown, writeMarkdown, removeFile, readProject, saveProject, deleteProject };
|
// Images pasted into a text. Returns the path for the Markdown, relative to the text file.
|
||||||
|
async function saveProjectImage(slug, upload) {
|
||||||
|
return saveImage(projectDir(slug), upload);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function savePageImage(upload) {
|
||||||
|
const dir = path.join(dataDir, 'pages', 'images');
|
||||||
|
await mkdir(dir, { recursive: true });
|
||||||
|
return `images/${await saveImage(dir, upload)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
readYaml,
|
||||||
|
writeYaml,
|
||||||
|
readMarkdown,
|
||||||
|
writeMarkdown,
|
||||||
|
removeFile,
|
||||||
|
readProject,
|
||||||
|
saveProject,
|
||||||
|
deleteProject,
|
||||||
|
saveProjectImage,
|
||||||
|
savePageImage,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
@@ -23,6 +23,10 @@ export function adminRouter({ config, content, stores }) {
|
|||||||
{ name: 'cover', maxCount: 1 },
|
{ name: 'cover', maxCount: 1 },
|
||||||
{ name: 'images', maxCount: 30 },
|
{ name: 'images', maxCount: 30 },
|
||||||
]);
|
]);
|
||||||
|
const textImage = multer({
|
||||||
|
storage: multer.memoryStorage(),
|
||||||
|
limits: { fileSize: 25 * 1024 * 1024, files: 1, fields: 5 },
|
||||||
|
}).single('bild');
|
||||||
const reload = () => Promise.all(stores.map((store) => store.reload()));
|
const reload = () => Promise.all(stores.map((store) => store.reload()));
|
||||||
|
|
||||||
function view(res, template, data = {}) {
|
function view(res, template, data = {}) {
|
||||||
@@ -81,6 +85,33 @@ export function adminRouter({ config, content, stores }) {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/* Images pasted or dropped into a text field, admin.js inserts the returned path as Markdown */
|
||||||
|
|
||||||
|
const withTextImage = (req, res, next) => {
|
||||||
|
textImage(req, res, (err) => {
|
||||||
|
if (!err) return next();
|
||||||
|
if (!(err instanceof multer.MulterError)) return next(err);
|
||||||
|
res.status(413).json({ error: err.code === 'LIMIT_FILE_SIZE' ? 'Das Bild ist größer als 25 MB.' : 'Bitte ein Bild nach dem anderen.' });
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
async function saveTextImage(req, res, save) {
|
||||||
|
if (!req.file) return res.status(422).json({ error: 'Es kam kein Bild an.' });
|
||||||
|
try {
|
||||||
|
res.json({ src: await save(req.file) });
|
||||||
|
} catch (err) {
|
||||||
|
if (!(err instanceof forms.InputError)) throw err;
|
||||||
|
res.status(422).json({ error: err.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
router.post('/admin/projekte/:slug/bild', withTextImage, csrf, async (req, res) => {
|
||||||
|
if (!(await files.readProject(req.params.slug))) return res.status(404).json({ error: 'Projekt nicht gefunden.' });
|
||||||
|
await saveTextImage(req, res, (upload) => files.saveProjectImage(req.params.slug, upload));
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/admin/seiten/bild', withTextImage, csrf, (req, res) => saveTextImage(req, res, files.savePageImage));
|
||||||
|
|
||||||
router.post('/admin/logout', urlencoded, csrf, (req, res) => {
|
router.post('/admin/logout', urlencoded, csrf, (req, res) => {
|
||||||
auth.logout(req, res);
|
auth.logout(req, res);
|
||||||
res.redirect(303, '/admin/login');
|
res.redirect(303, '/admin/login');
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
<a class="link" href="/de/homelab">Vorschau</a>
|
<a class="link" href="/de/homelab">Vorschau</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form class="form" method="post" action="/admin/homelab">
|
<form class="form" method="post" action="/admin/homelab" data-image-upload="/admin/seiten/bild">
|
||||||
<input type="hidden" name="_csrf" value="{{ csrf }}">
|
<input type="hidden" name="_csrf" value="{{ csrf }}">
|
||||||
|
|
||||||
<section class="panel">
|
<section class="panel">
|
||||||
@@ -17,7 +17,7 @@
|
|||||||
{{ check('Homelab-Seite öffentlich zeigen', 'visible', form.visible) }}
|
{{ check('Homelab-Seite öffentlich zeigen', 'visible', form.visible) }}
|
||||||
</div>
|
</div>
|
||||||
{{ pair('Einleitung', 'lead', form.lead, rows=2) }}
|
{{ pair('Einleitung', 'lead', form.lead, rows=2) }}
|
||||||
{{ pair('Text unter Hardware und Diensten', 'body', form.body, rows=7, hint='Markdown geht, z. B. „## Was ich dabei gelernt habe“ als Überschrift.') }}
|
{{ pair('Text unter Hardware und Diensten', 'body', form.body, rows=7, hint='Markdown geht, z. B. „## Was ich dabei gelernt habe“ als Überschrift. Bilder mit Strg+V oder per Drag & Drop einfügen.', images=true) }}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="panel">
|
<section class="panel">
|
||||||
|
|||||||
@@ -8,12 +8,12 @@
|
|||||||
<a class="link" href="/de/werdegang">Vorschau</a>
|
<a class="link" href="/de/werdegang">Vorschau</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form class="form" method="post" action="/admin/werdegang">
|
<form class="form" method="post" action="/admin/werdegang" data-image-upload="/admin/seiten/bild">
|
||||||
<input type="hidden" name="_csrf" value="{{ csrf }}">
|
<input type="hidden" name="_csrf" value="{{ csrf }}">
|
||||||
|
|
||||||
<section class="panel">
|
<section class="panel">
|
||||||
<h2 class="panel__title">Über mich</h2>
|
<h2 class="panel__title">Über mich</h2>
|
||||||
{{ pair('Text', 'about', form.about, rows=7, hint='Markdown geht. Englisch ist optional.') }}
|
{{ pair('Text', 'about', form.about, rows=7, hint='Markdown geht. Englisch ist optional. Bilder mit Strg+V oder per Drag & Drop einfügen.', images=true) }}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="panel">
|
<section class="panel">
|
||||||
|
|||||||
@@ -8,10 +8,11 @@
|
|||||||
</label>
|
</label>
|
||||||
{%- endmacro %}
|
{%- endmacro %}
|
||||||
|
|
||||||
{% macro area(label, name, value='', rows=3, hint='', mono=false) -%}
|
{# images: pasted or dropped images are uploaded to the form's data-image-upload and inserted as Markdown #}
|
||||||
|
{% macro area(label, name, value='', rows=3, hint='', mono=false, images=false) -%}
|
||||||
<label class="field">
|
<label class="field">
|
||||||
<span class="field__label">{{ label }}</span>
|
<span class="field__label">{{ label }}</span>
|
||||||
<textarea class="input{% if mono %} input--mono{% endif %}" name="{{ name }}"{% if '#' in name %} data-name="{{ name }}"{% endif %} rows="{{ rows }}">{{ value }}</textarea>
|
<textarea class="input{% if mono %} input--mono{% endif %}" name="{{ name }}"{% if '#' in name %} data-name="{{ name }}"{% endif %}{% if images %} data-images{% endif %} rows="{{ rows }}">{{ value }}</textarea>
|
||||||
{% if hint %}<span class="field__hint">{{ hint }}</span>{% endif %}
|
{% if hint %}<span class="field__hint">{{ hint }}</span>{% endif %}
|
||||||
</label>
|
</label>
|
||||||
{%- endmacro %}
|
{%- endmacro %}
|
||||||
@@ -35,7 +36,7 @@
|
|||||||
{%- endmacro %}
|
{%- endmacro %}
|
||||||
|
|
||||||
{# Same field in German and English side by side #}
|
{# Same field in German and English side by side #}
|
||||||
{% macro pair(label, name, value, rows=0, hint='', mono=false) -%}
|
{% macro pair(label, name, value, rows=0, hint='', mono=false, images=false) -%}
|
||||||
<fieldset class="pair">
|
<fieldset class="pair">
|
||||||
<legend class="field__label">{{ label }}</legend>
|
<legend class="field__label">{{ label }}</legend>
|
||||||
<div class="pair__fields">
|
<div class="pair__fields">
|
||||||
@@ -44,7 +45,7 @@
|
|||||||
<label class="pair__field">
|
<label class="pair__field">
|
||||||
<span class="pair__lang">{{ lang | upper }}</span>
|
<span class="pair__lang">{{ lang | upper }}</span>
|
||||||
{% if rows %}
|
{% if rows %}
|
||||||
<textarea class="input{% if mono %} input--mono{% endif %}" name="{{ full }}"{% if '#' in full %} data-name="{{ full }}"{% endif %} rows="{{ rows }}">{{ value[lang] }}</textarea>
|
<textarea class="input{% if mono %} input--mono{% endif %}" name="{{ full }}"{% if '#' in full %} data-name="{{ full }}"{% endif %}{% if images %} data-images{% endif %} rows="{{ rows }}">{{ value[lang] }}</textarea>
|
||||||
{% else %}
|
{% else %}
|
||||||
<input class="input" type="text" name="{{ full }}"{% if '#' in full %} data-name="{{ full }}"{% endif %} value="{{ value[lang] }}">
|
<input class="input" type="text" name="{{ full }}"{% if '#' in full %} data-name="{{ full }}"{% endif %} value="{{ value[lang] }}">
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
{% if not isNew %}<a class="link" href="/de/portfolio/{{ savedSlug }}">Vorschau</a>{% endif %}
|
{% if not isNew %}<a class="link" href="/de/portfolio/{{ savedSlug }}">Vorschau</a>{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form class="form" method="post" action="{{ '/admin/projekte/neu' if isNew else '/admin/projekte/' + savedSlug }}" enctype="multipart/form-data">
|
<form class="form" method="post" action="{{ '/admin/projekte/neu' if isNew else '/admin/projekte/' + savedSlug }}" enctype="multipart/form-data"{% if not isNew %} data-image-upload="/admin/projekte/{{ savedSlug }}/bild"{% endif %}>
|
||||||
<input type="hidden" name="_csrf" value="{{ csrf }}">
|
<input type="hidden" name="_csrf" value="{{ csrf }}">
|
||||||
|
|
||||||
<section class="panel">
|
<section class="panel">
|
||||||
@@ -26,14 +26,14 @@
|
|||||||
|
|
||||||
<section class="panel">
|
<section class="panel">
|
||||||
<h2 class="panel__title">Texte</h2>
|
<h2 class="panel__title">Texte</h2>
|
||||||
<p class="panel__hint">Englisch ist optional, ohne zeigt die englische Seite den deutschen Text. Im Text geht Markdown. Bilder aus der Galerie bindest du mit <code></code> ein.</p>
|
<p class="panel__hint">Englisch ist optional, ohne zeigt die englische Seite den deutschen Text. Im Text geht Markdown. Bilder kannst du mit Strg+V oder per Drag & Drop direkt in den Text einfügen{% if isNew %}, sobald das Projekt einmal gespeichert ist{% endif %}. Bilder aus der Galerie bindest du mit <code></code> ein.</p>
|
||||||
<div class="langs">
|
<div class="langs">
|
||||||
{% for lang, label in [['de', 'Deutsch'], ['en', 'English']] %}
|
{% for lang, label in [['de', 'Deutsch'], ['en', 'English']] %}
|
||||||
<div class="lang">
|
<div class="lang">
|
||||||
<h3 class="lang__title">{{ label }}</h3>
|
<h3 class="lang__title">{{ label }}</h3>
|
||||||
{{ field('Titel', lang + '[title]', project[lang].title, attrs=('data-title' if lang == 'de' else '')) }}
|
{{ field('Titel', lang + '[title]', project[lang].title, attrs=('data-title' if lang == 'de' else '')) }}
|
||||||
{{ area('Kurzbeschreibung', lang + '[summary]', project[lang].summary, 2) }}
|
{{ area('Kurzbeschreibung', lang + '[summary]', project[lang].summary, 2) }}
|
||||||
{{ area('Text', lang + '[body]', project[lang].body, 16, mono=true) }}
|
{{ area('Text', lang + '[body]', project[lang].body, 16, mono=true, images=true) }}
|
||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -229,6 +229,36 @@ test('journey, profile and homelab', async () => {
|
|||||||
assert.equal(publicStore.get().pages.homelab.de.lead, 'Was läuft');
|
assert.equal(publicStore.get().pages.homelab.de.lead, 'Was läuft');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('images pasted into a text are saved next to it', async () => {
|
||||||
|
const paste = async (url, file, name = 'bild', token = csrf) => {
|
||||||
|
const body = new FormData();
|
||||||
|
body.set('_csrf', token);
|
||||||
|
body.set('bild', new Blob([file]), name);
|
||||||
|
return request(url, { method: 'POST', body });
|
||||||
|
};
|
||||||
|
|
||||||
|
let res = await paste('/admin/projekte/alpha/bild', await image('png'));
|
||||||
|
assert.equal(res.status, 200);
|
||||||
|
assert.deepEqual(await res.json(), { src: 'bild.png' });
|
||||||
|
assert.equal((await sharp(path.join(dataDir, 'projects/alpha/bild.png')).metadata()).width, 2400);
|
||||||
|
assert.deepEqual(await (await paste('/admin/projekte/alpha/bild', await image('png'))).json(), { src: 'bild-2.png' });
|
||||||
|
|
||||||
|
res = await paste('/admin/seiten/bild', await image('jpeg'), 'Rack Foto.jpg');
|
||||||
|
assert.deepEqual(await res.json(), { src: 'images/rack-foto.jpg' });
|
||||||
|
res = await request('/admin/homelab', {
|
||||||
|
method: 'POST',
|
||||||
|
body: form({ visible: 'on', 'body[de]': 'Mein Rack:\n\n' }),
|
||||||
|
});
|
||||||
|
assert.equal(res.status, 303);
|
||||||
|
assert.match(publicStore.get().pages.homelab.de.html, /<img src="\/media\/pages\/images\/rack-foto\.jpg\?w=1600&[^"]+" srcset=/);
|
||||||
|
|
||||||
|
res = await paste('/admin/seiten/bild', 'not an image', 'virus.exe');
|
||||||
|
assert.equal(res.status, 422);
|
||||||
|
assert.match((await res.json()).error, /kein Bild/);
|
||||||
|
assert.equal((await paste('/admin/projekte/gibts-nicht/bild', await image('png'))).status, 404);
|
||||||
|
assert.equal((await paste('/admin/seiten/bild', await image('png'), 'bild', 'falsch')).status, 403);
|
||||||
|
});
|
||||||
|
|
||||||
test('clients are counted by address, IPv6 by /64', () => {
|
test('clients are counted by address, IPv6 by /64', () => {
|
||||||
assert.equal(clientKey('79.221.147.164'), '79.221.147.164');
|
assert.equal(clientKey('79.221.147.164'), '79.221.147.164');
|
||||||
assert.equal(clientKey('::ffff:192.168.178.20'), '192.168.178.20');
|
assert.equal(clientKey('::ffff:192.168.178.20'), '192.168.178.20');
|
||||||
|
|||||||
Reference in new issue
Block a user