Compare commits
4 Commits
a4895374b9
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 818f801939 | |||
| 386d206ff1 | |||
| 67d6fd4941 | |||
| 1680dcb0fe |
@@ -392,6 +392,87 @@ function hasCRLF(value: unknown): boolean {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Pentest 97.1 + 97.2 (LOW/INFO, 2026-06-21): Attachment-Validierung
|
||||||
|
// vor `Buffer.from(...)` damit malformed Content (null, true, leerer
|
||||||
|
// String, falsches Base64) nicht erst tief im SMTP-Service kracht und
|
||||||
|
// dort die rohe Node.js-Fehlermeldung in der Response landet. Außerdem
|
||||||
|
// explizite App-Level-Caps (10 MB pro Datei, 25 MB total, 25 Anhänge),
|
||||||
|
// damit die Frontend-Doku auch ohne bodyParser-Heroics gilt.
|
||||||
|
const MAX_ATTACHMENT_COUNT = 25;
|
||||||
|
const MAX_PER_FILE_BYTES = 10 * 1024 * 1024;
|
||||||
|
const MAX_TOTAL_BYTES = 25 * 1024 * 1024;
|
||||||
|
// Standard-Base64-Alphabet inkl. optionalem `=`-Padding (kein URL-safe).
|
||||||
|
const BASE64_RE = /^[A-Za-z0-9+/]+={0,2}$/;
|
||||||
|
|
||||||
|
interface AttachmentValidationError {
|
||||||
|
status: 400;
|
||||||
|
error: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateAttachments(
|
||||||
|
attachments: unknown,
|
||||||
|
): { ok: true } | AttachmentValidationError {
|
||||||
|
if (attachments === undefined) return { ok: true };
|
||||||
|
if (!Array.isArray(attachments)) {
|
||||||
|
return { status: 400, error: 'attachments muss ein Array sein.' };
|
||||||
|
}
|
||||||
|
if (attachments.length > MAX_ATTACHMENT_COUNT) {
|
||||||
|
return {
|
||||||
|
status: 400,
|
||||||
|
error: `Maximal ${MAX_ATTACHMENT_COUNT} Anhänge pro E-Mail erlaubt (${attachments.length} übermittelt).`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
let totalBytes = 0;
|
||||||
|
for (let i = 0; i < attachments.length; i++) {
|
||||||
|
const a = attachments[i];
|
||||||
|
const label = `Anhang ${i + 1}`;
|
||||||
|
if (!a || typeof a !== 'object') {
|
||||||
|
return { status: 400, error: `${label} hat das falsche Format.` };
|
||||||
|
}
|
||||||
|
const filename = (a as Record<string, unknown>).filename;
|
||||||
|
const content = (a as Record<string, unknown>).content;
|
||||||
|
const contentType = (a as Record<string, unknown>).contentType;
|
||||||
|
if (typeof filename !== 'string' || filename.trim() === '') {
|
||||||
|
return { status: 400, error: `${label} hat keinen Dateinamen.` };
|
||||||
|
}
|
||||||
|
if (typeof content !== 'string' || content.length === 0) {
|
||||||
|
return {
|
||||||
|
status: 400,
|
||||||
|
error: `Anhang "${filename}" hat keinen Inhalt (content muss ein nicht-leerer Base64-String sein).`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (!BASE64_RE.test(content)) {
|
||||||
|
return {
|
||||||
|
status: 400,
|
||||||
|
error: `Anhang "${filename}" ist kein gültiger Base64-String.`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (contentType !== undefined && typeof contentType !== 'string') {
|
||||||
|
return {
|
||||||
|
status: 400,
|
||||||
|
error: `Anhang "${filename}" hat ein ungültiges contentType-Feld.`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
// Größen-Abschätzung anhand der Base64-Länge: jedes 4-Zeichen-Quartett
|
||||||
|
// dekodiert zu max 3 Bytes. So vermeiden wir Buffer.from() schon hier.
|
||||||
|
const approxBytes = Math.ceil(content.length * 0.75);
|
||||||
|
if (approxBytes > MAX_PER_FILE_BYTES) {
|
||||||
|
return {
|
||||||
|
status: 400,
|
||||||
|
error: `Anhang "${filename}" überschreitet die Maximalgröße von ${MAX_PER_FILE_BYTES / 1024 / 1024} MB.`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
totalBytes += approxBytes;
|
||||||
|
if (totalBytes > MAX_TOTAL_BYTES) {
|
||||||
|
return {
|
||||||
|
status: 400,
|
||||||
|
error: `Gesamtgröße aller Anhänge überschreitet ${MAX_TOTAL_BYTES / 1024 / 1024} MB.`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
// E-Mail senden
|
// E-Mail senden
|
||||||
export async function sendEmailFromAccount(req: AuthRequest, res: Response): Promise<void> {
|
export async function sendEmailFromAccount(req: AuthRequest, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
@@ -408,6 +489,18 @@ export async function sendEmailFromAccount(req: AuthRequest, res: Response): Pro
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Pentest 97.1 + 97.2: Attachment-Validierung vor Buffer.from()
|
||||||
|
// (Format, Größe, Anzahl) – sonst leakte der rohe Node.js-Fehler
|
||||||
|
// in die Response und Limits waren nur Frontend-Doku.
|
||||||
|
const attachmentCheck = validateAttachments(attachments);
|
||||||
|
if (!('ok' in attachmentCheck)) {
|
||||||
|
res.status(attachmentCheck.status).json({
|
||||||
|
success: false,
|
||||||
|
error: attachmentCheck.error,
|
||||||
|
} as ApiResponse);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// StressfreiEmail laden
|
// StressfreiEmail laden
|
||||||
const stressfreiEmail = await stressfreiEmailService.getEmailWithMailboxById(stressfreiEmailId);
|
const stressfreiEmail = await stressfreiEmailService.getEmailWithMailboxById(stressfreiEmailId);
|
||||||
|
|
||||||
|
|||||||
@@ -87,48 +87,72 @@ export async function downloadFile(req: AuthRequest, res: Response): Promise<voi
|
|||||||
// Stored-XSS gerendert.
|
// Stored-XSS gerendert.
|
||||||
//
|
//
|
||||||
// Default: Content-Disposition: attachment → Browser lädt nur runter.
|
// Default: Content-Disposition: attachment → Browser lädt nur runter.
|
||||||
// Opt-in inline-Vorschau (Bank-Karten/Ausweis-Anzeigen-Button) per
|
// Opt-in inline-Vorschau (Bank-Karten/Ausweis-Anzeigen-Button,
|
||||||
// ?disposition=inline, ABER nur wenn die ersten Bytes der Datei das
|
// Vertragsdokumente-Vorschau) per ?disposition=inline, ABER nur wenn
|
||||||
// Magic eines bekannten safe Typs (PDF, PNG, JPEG, GIF, WebP) zeigen.
|
// die ersten Bytes der Datei das Magic eines bekannten safe Typs
|
||||||
// Bei Mismatch fällt's auf attachment zurück – Stored XSS bleibt
|
// (PDF, PNG, JPEG, GIF, WebP) zeigen. Bei Mismatch fällt's auf
|
||||||
// weiterhin unmöglich.
|
// attachment zurück – Stored XSS bleibt weiterhin unmöglich.
|
||||||
|
//
|
||||||
|
// Pentest 101.1 (INFO, 2026-06-22): R101.1 berichtete, dass inline
|
||||||
|
// nie greift. Die Logik selbst ist OK; um künftige Regressionen
|
||||||
|
// sichtbar zu machen, loggen wir jetzt, wenn `inline` zwar angefragt
|
||||||
|
// wurde, aber wegen Magic-Byte-Mismatch oder Read-Fehler abgelehnt
|
||||||
|
// wird (passiert im Normalfall NIE bei echten PDFs/Images).
|
||||||
const filename = path.basename(absolute).replace(/[^A-Za-z0-9._-]/g, '_');
|
const filename = path.basename(absolute).replace(/[^A-Za-z0-9._-]/g, '_');
|
||||||
const wantsInline = req.query.disposition === 'inline';
|
const wantsInline = req.query.disposition === 'inline';
|
||||||
let useInline = false;
|
const safeContentType = wantsInline ? detectSafeContentType(absolute) : null;
|
||||||
let inlineContentType: string | null = null;
|
|
||||||
if (wantsInline) {
|
if (wantsInline && !safeContentType) {
|
||||||
try {
|
console.warn(
|
||||||
const fd = fs.openSync(absolute, 'r');
|
`[fileDownload] inline angefragt, aber Magic-Byte-Check fehlgeschlagen: ${requested}`,
|
||||||
const head = Buffer.alloc(12);
|
);
|
||||||
fs.readSync(fd, head, 0, 12, 0);
|
|
||||||
fs.closeSync(fd);
|
|
||||||
if (head.subarray(0, 5).toString('latin1') === '%PDF-') {
|
|
||||||
useInline = true;
|
|
||||||
inlineContentType = 'application/pdf';
|
|
||||||
} else if (head.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))) {
|
|
||||||
useInline = true;
|
|
||||||
inlineContentType = 'image/png';
|
|
||||||
} else if (head[0] === 0xff && head[1] === 0xd8 && head[2] === 0xff) {
|
|
||||||
useInline = true;
|
|
||||||
inlineContentType = 'image/jpeg';
|
|
||||||
} else if (head.subarray(0, 6).toString('latin1') === 'GIF87a'
|
|
||||||
|| head.subarray(0, 6).toString('latin1') === 'GIF89a') {
|
|
||||||
useInline = true;
|
|
||||||
inlineContentType = 'image/gif';
|
|
||||||
} else if (head.subarray(0, 4).toString('latin1') === 'RIFF'
|
|
||||||
&& head.subarray(8, 12).toString('latin1') === 'WEBP') {
|
|
||||||
useInline = true;
|
|
||||||
inlineContentType = 'image/webp';
|
|
||||||
}
|
|
||||||
} catch { /* ignore – fällt auf attachment zurück */ }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
res.setHeader('X-Content-Type-Options', 'nosniff');
|
res.setHeader('X-Content-Type-Options', 'nosniff');
|
||||||
if (useInline && inlineContentType) {
|
if (safeContentType) {
|
||||||
res.setHeader('Content-Type', inlineContentType);
|
res.setHeader('Content-Type', safeContentType);
|
||||||
res.setHeader('Content-Disposition', `inline; filename="${filename}"`);
|
res.setHeader('Content-Disposition', `inline; filename="${filename}"`);
|
||||||
} else {
|
} else {
|
||||||
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
|
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
|
||||||
}
|
}
|
||||||
res.sendFile(absolute);
|
res.sendFile(absolute);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Liest die ersten 12 Bytes der Datei und gibt einen MIME-Type zurück,
|
||||||
|
* wenn die Datei einer bekannten Whitelist (PDF, PNG, JPEG, GIF, WebP)
|
||||||
|
* entspricht. Sonst `null` – dann wird die Datei als attachment serviert.
|
||||||
|
*
|
||||||
|
* Wird nur aufgerufen, wenn `?disposition=inline` angefragt wurde, damit
|
||||||
|
* der Standardfluss (attachment) ohne zusätzlichen Disk-Read auskommt.
|
||||||
|
*/
|
||||||
|
function detectSafeContentType(absolute: string): string | null {
|
||||||
|
let fd: number | null = null;
|
||||||
|
try {
|
||||||
|
fd = fs.openSync(absolute, 'r');
|
||||||
|
const head = Buffer.alloc(12);
|
||||||
|
const bytesRead = fs.readSync(fd, head, 0, 12, 0);
|
||||||
|
if (bytesRead < 5) return null; // Datei zu klein für jede Magic-Sig
|
||||||
|
if (head.subarray(0, 5).toString('latin1') === '%PDF-') return 'application/pdf';
|
||||||
|
if (bytesRead >= 8
|
||||||
|
&& head.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))
|
||||||
|
) return 'image/png';
|
||||||
|
if (bytesRead >= 3 && head[0] === 0xff && head[1] === 0xd8 && head[2] === 0xff) return 'image/jpeg';
|
||||||
|
if (bytesRead >= 6
|
||||||
|
&& (head.subarray(0, 6).toString('latin1') === 'GIF87a'
|
||||||
|
|| head.subarray(0, 6).toString('latin1') === 'GIF89a')
|
||||||
|
) return 'image/gif';
|
||||||
|
if (bytesRead >= 12
|
||||||
|
&& head.subarray(0, 4).toString('latin1') === 'RIFF'
|
||||||
|
&& head.subarray(8, 12).toString('latin1') === 'WEBP'
|
||||||
|
) return 'image/webp';
|
||||||
|
return null;
|
||||||
|
} catch (err) {
|
||||||
|
console.warn(`[fileDownload] Magic-Byte-Read fehlgeschlagen für ${absolute}:`, err);
|
||||||
|
return null;
|
||||||
|
} finally {
|
||||||
|
if (fd !== null) {
|
||||||
|
try { fs.closeSync(fd); } catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -97,6 +97,43 @@ isolierte Instanz (keine Multi-Tenancy im Code), Provisioning + Abrechnung
|
|||||||
|
|
||||||
## ✅ Erledigt
|
## ✅ Erledigt
|
||||||
|
|
||||||
|
- [x] **🔧 Pentest R101.1 – Inline-Preview-Pfad refaktoriert + Diagnose-Log**
|
||||||
|
- Pentester R101.1 (INFO/funktional) berichtet: `?disposition=inline`
|
||||||
|
bewirkt nichts, Browser zeigt Download-Dialog. Die Logik im
|
||||||
|
`fileDownload.controller` ist eigentlich korrekt – sauberer Magic-
|
||||||
|
Byte-Check für PDF/PNG/JPEG/GIF/WebP – und liefert beim Direkttest
|
||||||
|
gegen echte Vertrags-PDFs `application/pdf`. Wir können das in Prod
|
||||||
|
aber nicht reproduzieren.
|
||||||
|
- Refaktorierung: Magic-Byte-Check in `detectSafeContentType()`
|
||||||
|
extrahiert, finally-Block schließt File-Descriptor garantiert,
|
||||||
|
Short-Read-Fälle (`bytesRead < n`) jetzt sauber geguardet.
|
||||||
|
- Sicherheits-Verhalten unverändert: bei Magic-Byte-Mismatch bleibt
|
||||||
|
es bei `Content-Disposition: attachment` (Stored-XSS-Schutz aus
|
||||||
|
R30.13).
|
||||||
|
- Neu: `console.warn`, wenn `inline` angefragt wurde, aber der
|
||||||
|
Magic-Byte-Check fehlschlägt oder der Read crasht. Damit fällt
|
||||||
|
der Fall im Prod-Log auf, falls er nochmal auftritt – bisher
|
||||||
|
war's silent.
|
||||||
|
|
||||||
|
- [x] **🔒 Pentest R97 – Attachment-Validierung im Send-Handler**
|
||||||
|
- R97.1 (LOW): malformed `content` (`null`, fehlend, `true`, `""`)
|
||||||
|
erzeugte 200/500 mit rohem `Buffer.from()`-Fehlertext in der
|
||||||
|
Response. `content: ""` ließ sogar eine Mail mit 0-Byte-Anhang
|
||||||
|
durchgehen.
|
||||||
|
- R97.2 (INFO): keine App-Level-Caps (Größe + Anzahl) – die im
|
||||||
|
Frontend dokumentierten 10 MB/25 MB/Datei-Limits hingen am
|
||||||
|
bodyParser; falls der je hochgedreht wird, fällt die Sicherung.
|
||||||
|
- Fix: `validateAttachments()` im Controller `sendEmailFromAccount`
|
||||||
|
läuft **vor** dem `sendEmail`-Aufruf:
|
||||||
|
- `attachments` muss Array oder undefined sein
|
||||||
|
- max 25 Anhänge
|
||||||
|
- jeder: `filename` non-empty String, `content` non-empty Base64-
|
||||||
|
String (Regex), optional `contentType` String
|
||||||
|
- max 10 MB/Datei, 25 MB gesamt (Schätzung via base64-Länge × 0.75,
|
||||||
|
kein Buffer.from-Aufruf während der Validierung)
|
||||||
|
- Bei Verstoß harte 400 mit klarer Meldung. Sanity-Test: 18/18 Cases
|
||||||
|
grün inkl. aller R97.1-Pentest-Payloads.
|
||||||
|
|
||||||
- [x] **🆕 E-Mail-Compose: Vertragsdokumente anhängen + Kundendaten einfügen**
|
- [x] **🆕 E-Mail-Compose: Vertragsdokumente anhängen + Kundendaten einfügen**
|
||||||
- Im Compose-Modal (nur wenn Vertrag-Kontext) zwei neue Buttons neben
|
- Im Compose-Modal (nur wenn Vertrag-Kontext) zwei neue Buttons neben
|
||||||
"Datei anhängen":
|
"Datei anhängen":
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { FileText, Loader2 } from 'lucide-react';
|
import { FileText, Loader2, ExternalLink } from 'lucide-react';
|
||||||
import toast from 'react-hot-toast';
|
import toast from 'react-hot-toast';
|
||||||
import Modal from '../ui/Modal';
|
import Modal from '../ui/Modal';
|
||||||
import Button from '../ui/Button';
|
import Button from '../ui/Button';
|
||||||
import { contractApi, EmailAttachment } from '../../services/api';
|
import { contractApi, EmailAttachment } from '../../services/api';
|
||||||
import { serverFileToAttachment, totalAttachmentBytes } from './composeAttachmentHelpers';
|
import { serverFileToAttachment, totalAttachmentBytes } from './composeAttachmentHelpers';
|
||||||
|
import { viewUrl } from '../../utils/fileUrl';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
@@ -127,29 +128,42 @@ export default function AttachContractDocumentsModal({
|
|||||||
</div>
|
</div>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
{docs.map((doc) => (
|
{docs.map((doc) => (
|
||||||
<label
|
<div
|
||||||
key={doc.id}
|
key={doc.id}
|
||||||
className="flex items-start gap-2 p-2 rounded hover:bg-gray-50 cursor-pointer"
|
className="flex items-start gap-2 p-2 rounded hover:bg-gray-50"
|
||||||
>
|
>
|
||||||
<input
|
<label className="flex items-start gap-2 flex-1 min-w-0 cursor-pointer">
|
||||||
type="checkbox"
|
<input
|
||||||
checked={selected.has(doc.id)}
|
type="checkbox"
|
||||||
onChange={() => toggle(doc.id)}
|
checked={selected.has(doc.id)}
|
||||||
disabled={busy}
|
onChange={() => toggle(doc.id)}
|
||||||
className="mt-0.5 rounded"
|
disabled={busy}
|
||||||
/>
|
className="mt-0.5 rounded"
|
||||||
<div className="flex-1 min-w-0">
|
/>
|
||||||
<div className="flex items-center gap-2 text-sm">
|
<div className="flex-1 min-w-0">
|
||||||
<FileText className="w-4 h-4 text-gray-400 flex-shrink-0" />
|
<div className="flex items-center gap-2 text-sm">
|
||||||
<span className="truncate">{doc.originalName}</span>
|
<FileText className="w-4 h-4 text-gray-400 flex-shrink-0" />
|
||||||
</div>
|
<span className="truncate">{doc.originalName}</span>
|
||||||
{doc.notes && (
|
|
||||||
<div className="text-xs text-gray-500 mt-0.5 ml-6 truncate">
|
|
||||||
{doc.notes}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
{doc.notes && (
|
||||||
</div>
|
<div className="text-xs text-gray-500 mt-0.5 ml-6 truncate">
|
||||||
</label>
|
{doc.notes}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
<a
|
||||||
|
href={viewUrl(doc.documentPath)}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
className="flex-shrink-0 inline-flex items-center gap-1 px-2 py-1 text-xs text-blue-600 hover:text-blue-800 hover:bg-blue-50 rounded transition-colors"
|
||||||
|
title="Dokument in neuem Tab öffnen"
|
||||||
|
>
|
||||||
|
<ExternalLink className="w-3.5 h-3.5" />
|
||||||
|
<span>Vorschau</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -489,9 +489,13 @@ export default function ContractEmailsSection({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex -mx-6 -mb-6" style={{ minHeight: '400px' }}>
|
<div
|
||||||
{/* Email List */}
|
className="flex -mx-6 -mb-6"
|
||||||
<div className="w-1/3 border-r border-gray-200 overflow-auto">
|
style={{ height: '600px' }}
|
||||||
|
>
|
||||||
|
{/* Email List – scrollt intern, damit die Vertrags-Seite nicht
|
||||||
|
elendig lang wird. */}
|
||||||
|
<div className="w-1/3 border-r border-gray-200 overflow-y-auto">
|
||||||
{selectedFolder === 'TRASH' ? (
|
{selectedFolder === 'TRASH' ? (
|
||||||
<TrashEmailList
|
<TrashEmailList
|
||||||
emails={trashEmails}
|
emails={trashEmails}
|
||||||
|
|||||||
@@ -295,7 +295,13 @@ export default function EmailClientTab({ customerId }: EmailClientTabProps) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full" style={{ minHeight: '600px' }}>
|
// Bounded auf Viewport-Höhe – sonst ignoriert h-full ohnehin den
|
||||||
|
// Tab-Container und der Postfach-Inhalt wächst beliebig, sodass die
|
||||||
|
// ganze Seite scrollt statt nur die E-Mail-Liste.
|
||||||
|
<div
|
||||||
|
className="flex flex-col"
|
||||||
|
style={{ height: 'calc(100vh - 240px)', minHeight: '500px' }}
|
||||||
|
>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between gap-4 p-4 border-b border-gray-200 bg-gray-50">
|
<div className="flex items-center justify-between gap-4 p-4 border-b border-gray-200 bg-gray-50">
|
||||||
{/* Account Selector */}
|
{/* Account Selector */}
|
||||||
|
|||||||
Reference in New Issue
Block a user