TASK22028: expand signer coverage for delete flows
This commit is contained in:
@@ -92,11 +92,13 @@ export const deleteAwaitingSubmissionsFromBlob = (
|
|||||||
"&casefolderID=" +
|
"&casefolderID=" +
|
||||||
casefolderID;
|
casefolderID;
|
||||||
|
|
||||||
var config = {
|
return buildHashedQueryUrl(queryUrl)
|
||||||
method: "get",
|
.then((signedUrl) =>
|
||||||
url: queryUrl + hashAPIPath(queryUrl)
|
axios({
|
||||||
};
|
method: "get",
|
||||||
return axios(config)
|
url: signedUrl
|
||||||
|
})
|
||||||
|
)
|
||||||
.then((res) => {
|
.then((res) => {
|
||||||
return res.data;
|
return res.data;
|
||||||
})
|
})
|
||||||
@@ -118,11 +120,13 @@ export const deleteMyRepresentationsFromBlob = (
|
|||||||
"&repfile=" +
|
"&repfile=" +
|
||||||
repfile;
|
repfile;
|
||||||
|
|
||||||
var config = {
|
return buildHashedQueryUrl(queryUrl)
|
||||||
method: "get",
|
.then((signedUrl) =>
|
||||||
url: queryUrl + hashAPIPath(queryUrl)
|
axios({
|
||||||
};
|
method: "get",
|
||||||
return axios(config)
|
url: signedUrl
|
||||||
|
})
|
||||||
|
)
|
||||||
.then((res) => {
|
.then((res) => {
|
||||||
return res.data;
|
return res.data;
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -182,19 +182,21 @@ export const deleteMyRepresentations = (myRepresentationsID) => {
|
|||||||
"/api/endpoint/deletemyrepresentations_api?myRepresentationsID=" +
|
"/api/endpoint/deletemyrepresentations_api?myRepresentationsID=" +
|
||||||
myRepresentationsID;
|
myRepresentationsID;
|
||||||
|
|
||||||
var config = {
|
return buildHashedQueryUrl(queryUrl)
|
||||||
method: "delete",
|
.then((signedUrl) =>
|
||||||
url: queryUrl + hashAPIPath(queryUrl),
|
axios({
|
||||||
headers: {
|
method: "delete",
|
||||||
"OData-MaxVersion": "4.0",
|
url: signedUrl,
|
||||||
"OData-Version": "4.0",
|
headers: {
|
||||||
"Accept": "application/json;odata.metadata=none",
|
"OData-MaxVersion": "4.0",
|
||||||
"Prefer": 'odata.include-annotations="*",return=representation',
|
"OData-Version": "4.0",
|
||||||
"Content-Type": "application/json"
|
"Accept": "application/json;odata.metadata=none",
|
||||||
}
|
"Prefer":
|
||||||
};
|
'odata.include-annotations="*",return=representation',
|
||||||
|
"Content-Type": "application/json"
|
||||||
return axios(config)
|
}
|
||||||
|
})
|
||||||
|
)
|
||||||
.then((res) => {
|
.then((res) => {
|
||||||
return res.data;
|
return res.data;
|
||||||
})
|
})
|
||||||
@@ -224,17 +226,20 @@ export const deleteAwaitingSubmissions = (incidentID) => {
|
|||||||
export const deleteWatchedCases = async (watchedCaseID) => {
|
export const deleteWatchedCases = async (watchedCaseID) => {
|
||||||
var queryUrl =
|
var queryUrl =
|
||||||
"/api/endpoint/deletewatchedcases_api?watchedCaseID=" + watchedCaseID;
|
"/api/endpoint/deletewatchedcases_api?watchedCaseID=" + watchedCaseID;
|
||||||
var config = {
|
|
||||||
method: "delete",
|
|
||||||
url: queryUrl
|
|
||||||
};
|
|
||||||
|
|
||||||
try {
|
return buildHashedQueryUrl(queryUrl)
|
||||||
const res = await axios(config);
|
.then((signedUrl) =>
|
||||||
return res.data;
|
axios({
|
||||||
} catch (error) {
|
method: "delete",
|
||||||
consoleLogger(error);
|
url: signedUrl
|
||||||
}
|
})
|
||||||
|
)
|
||||||
|
.then((res) => {
|
||||||
|
return res.data;
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
consoleLogger(error);
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
export const sendCaseCompleteMessage = async (
|
export const sendCaseCompleteMessage = async (
|
||||||
|
|||||||
@@ -529,6 +529,18 @@
|
|||||||
- Added focused behavioural test:
|
- Added focused behavioural test:
|
||||||
- `tests/phase14/service-behaviour.test.cjs` now includes allow-list coverage for deleteblobcase path.
|
- `tests/phase14/service-behaviour.test.cjs` now includes allow-list coverage for deleteblobcase path.
|
||||||
|
|
||||||
|
## Follow-up status (2026-03-13 — additional delete flows)
|
||||||
|
|
||||||
|
- Expanded signer allow-list for additional browser delete paths:
|
||||||
|
- `/api/endpoint/deletemyrepresentations_api`
|
||||||
|
- `/api/endpoint/deletewatchedcases_api`
|
||||||
|
- `/api/file/deleteblobrep`
|
||||||
|
- Migrated affected direct-service delete calls to signer-based hash retrieval:
|
||||||
|
- `portalDirectService.deleteWatchedCases`
|
||||||
|
- `portalDirectService.deleteMyRepresentations`
|
||||||
|
- `documentDirectService.deleteAwaitingSubmissionsFromBlob`
|
||||||
|
- `documentDirectService.deleteMyRepresentationsFromBlob`
|
||||||
|
|
||||||
## Likely next steps
|
## Likely next steps
|
||||||
|
|
||||||
1. Stabilize and simplify breadcrumb/back-link decision logic with focused regression checks.
|
1. Stabilize and simplify breadcrumb/back-link decision logic with focused regression checks.
|
||||||
|
|||||||
@@ -1045,3 +1045,40 @@ Validation:
|
|||||||
Follow-ups:
|
Follow-ups:
|
||||||
|
|
||||||
- Keep signer allow-list additions minimal and path-specific.
|
- Keep signer allow-list additions minimal and path-specific.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### CL-029: Follow-up fix — signer coverage for additional delete flows
|
||||||
|
|
||||||
|
date: 2026-03-13
|
||||||
|
author: Cline
|
||||||
|
scope: `actions/services/{portalDirectService,documentDirectService}.js`, `pages/api/endpoint/gethash_api.js`, `tests/phase7/service-behaviour.test.cjs`, `tests/phase14/service-behaviour.test.cjs`
|
||||||
|
type: change
|
||||||
|
rationale: Resolve browser hash-signing gaps for additional delete operations still using direct client hash generation.
|
||||||
|
impact: Restores browser-safe signing for delete watched cases, delete my representations, and delete blob representation/awaiting-submission paths while retaining authenticated allow-list signer model.
|
||||||
|
status: completed
|
||||||
|
|
||||||
|
Summary:
|
||||||
|
|
||||||
|
- Updated signer allow-list to include:
|
||||||
|
- `/api/endpoint/deletemyrepresentations_api`
|
||||||
|
- `/api/endpoint/deletewatchedcases_api`
|
||||||
|
- `/api/file/deleteblobrep`
|
||||||
|
- Migrated direct service calls to signer-based hash retrieval for:
|
||||||
|
- `portalDirectService.deleteWatchedCases`
|
||||||
|
- `portalDirectService.deleteMyRepresentations`
|
||||||
|
- `documentDirectService.deleteAwaitingSubmissionsFromBlob`
|
||||||
|
- `documentDirectService.deleteMyRepresentationsFromBlob`
|
||||||
|
- Extended focused tests:
|
||||||
|
- `tests/phase7/service-behaviour.test.cjs` (service signer usage)
|
||||||
|
- `tests/phase14/service-behaviour.test.cjs` (allow-list coverage)
|
||||||
|
|
||||||
|
Validation:
|
||||||
|
|
||||||
|
- `node tests/phase7/service-behaviour.test.cjs` -> pass (12/12)
|
||||||
|
- `node tests/phase14/service-behaviour.test.cjs` -> pass (9/9)
|
||||||
|
- `npm run lint` -> warnings only (pre-existing)
|
||||||
|
|
||||||
|
Follow-ups:
|
||||||
|
|
||||||
|
- Keep allow-list expansion path-scoped and driven by concrete browser-call requirements only.
|
||||||
|
|||||||
@@ -551,6 +551,20 @@ Status key: `[x] done`, `[ ] pending`
|
|||||||
|
|
||||||
- Revert commit `49dd990` to undo allow-list/test follow-up.
|
- Revert commit `49dd990` to undo allow-list/test follow-up.
|
||||||
|
|
||||||
|
### Follow-up completion snapshot (2026-03-13 — additional delete flows)
|
||||||
|
|
||||||
|
1. `[x]` Expand signer allow-list for required browser delete paths
|
||||||
|
2. `[x]` Move remaining delete service calls to signer-based hash retrieval
|
||||||
|
3. `[x]` Extend focused service/signer tests
|
||||||
|
4. `[x]` Validate
|
||||||
|
- phase7 behaviour tests pass (12/12)
|
||||||
|
- phase14 behaviour tests pass (9/9)
|
||||||
|
- lint warnings only (pre-existing)
|
||||||
|
|
||||||
|
### Follow-up rollback notes (additional delete flows)
|
||||||
|
|
||||||
|
- Revert the follow-up commit for this slice to restore prior hashing behavior.
|
||||||
|
|
||||||
## Safe execution mode for migration chunks (required)
|
## Safe execution mode for migration chunks (required)
|
||||||
|
|
||||||
To reduce terminal hangs during bulk migration work, run refactor chunks in **safe stepwise mode** instead of long chained commands.
|
To reduce terminal hangs during bulk migration work, run refactor chunks in **safe stepwise mode** instead of long chained commands.
|
||||||
|
|||||||
@@ -14,9 +14,12 @@ ApiProxy.get(async (req, res) => {
|
|||||||
const queryPath = req.query.path;
|
const queryPath = req.query.path;
|
||||||
const allowedPrefix = [
|
const allowedPrefix = [
|
||||||
"/api/endpoint/getportallogin_api",
|
"/api/endpoint/getportallogin_api",
|
||||||
|
"/api/endpoint/deletemyrepresentations_api",
|
||||||
|
"/api/endpoint/deletewatchedcases_api",
|
||||||
"/api/file/upload",
|
"/api/file/upload",
|
||||||
"/api/file/uploadsinglefile",
|
"/api/file/uploadsinglefile",
|
||||||
"/api/file/deleteblobcase",
|
"/api/file/deleteblobcase",
|
||||||
|
"/api/file/deleteblobrep",
|
||||||
"/api/file/createrepcompletemessage_api",
|
"/api/file/createrepcompletemessage_api",
|
||||||
"/api/file/createappealcompletemessage_api"
|
"/api/file/createappealcompletemessage_api"
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -152,6 +152,72 @@ test("gethash_api returns hash for allow-listed deleteblobcase path", async () =
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("gethash_api returns hash for allow-listed deleteblobrep path", async () => {
|
||||||
|
const mod = loadModule("pages/api/endpoint/gethash_api.js", {
|
||||||
|
hashAPIPath: () => "&hash=deleterep",
|
||||||
|
getSession: async () => ({ user: { id: "u1" } }),
|
||||||
|
nextConnect: createNextConnectMock(),
|
||||||
|
middleware: () => {}
|
||||||
|
});
|
||||||
|
|
||||||
|
const req = {
|
||||||
|
query: {
|
||||||
|
path: "/api/file/deleteblobrep?container=c1&casefolderID=r1&repfile=f1"
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const res = createRes();
|
||||||
|
await mod.default.handler(req, res);
|
||||||
|
|
||||||
|
assert.strictEqual(res.state.statusCode, 200);
|
||||||
|
assert.deepStrictEqual(JSON.parse(JSON.stringify(res.state.jsonBody)), {
|
||||||
|
hash: "&hash=deleterep"
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("gethash_api returns hash for allow-listed deletewatchedcases path", async () => {
|
||||||
|
const mod = loadModule("pages/api/endpoint/gethash_api.js", {
|
||||||
|
hashAPIPath: () => "&hash=watch",
|
||||||
|
getSession: async () => ({ user: { id: "u1" } }),
|
||||||
|
nextConnect: createNextConnectMock(),
|
||||||
|
middleware: () => {}
|
||||||
|
});
|
||||||
|
|
||||||
|
const req = {
|
||||||
|
query: {
|
||||||
|
path: "/api/endpoint/deletewatchedcases_api?watchedCaseID=123"
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const res = createRes();
|
||||||
|
await mod.default.handler(req, res);
|
||||||
|
|
||||||
|
assert.strictEqual(res.state.statusCode, 200);
|
||||||
|
assert.deepStrictEqual(JSON.parse(JSON.stringify(res.state.jsonBody)), {
|
||||||
|
hash: "&hash=watch"
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("gethash_api returns hash for allow-listed deletemyrepresentations path", async () => {
|
||||||
|
const mod = loadModule("pages/api/endpoint/gethash_api.js", {
|
||||||
|
hashAPIPath: () => "&hash=delrep",
|
||||||
|
getSession: async () => ({ user: { id: "u1" } }),
|
||||||
|
nextConnect: createNextConnectMock(),
|
||||||
|
middleware: () => {}
|
||||||
|
});
|
||||||
|
|
||||||
|
const req = {
|
||||||
|
query: {
|
||||||
|
path: "/api/endpoint/deletemyrepresentations_api?myRepresentationsID=abc"
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const res = createRes();
|
||||||
|
await mod.default.handler(req, res);
|
||||||
|
|
||||||
|
assert.strictEqual(res.state.statusCode, 200);
|
||||||
|
assert.deepStrictEqual(JSON.parse(JSON.stringify(res.state.jsonBody)), {
|
||||||
|
hash: "&hash=delrep"
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
test("gethash_api rejects unauthenticated requests with 401", async () => {
|
test("gethash_api rejects unauthenticated requests with 401", async () => {
|
||||||
const mod = loadModule("pages/api/endpoint/gethash_api.js", {
|
const mod = loadModule("pages/api/endpoint/gethash_api.js", {
|
||||||
hashAPIPath: () => "&hash=expected",
|
hashAPIPath: () => "&hash=expected",
|
||||||
|
|||||||
@@ -155,10 +155,15 @@ test("document/getAwaitingSubmissionFromBlob logs and returns undefined on failu
|
|||||||
test("portal/deleteMyRepresentations appends hash and returns data", async () => {
|
test("portal/deleteMyRepresentations appends hash and returns data", async () => {
|
||||||
const axios = createAxiosMock();
|
const axios = createAxiosMock();
|
||||||
const logger = createLoggerMock();
|
const logger = createLoggerMock();
|
||||||
const hashCalls = [];
|
const signCalls = [];
|
||||||
const hashAPIPath = (queryPath) => {
|
|
||||||
hashCalls.push(queryPath);
|
axios.getHandler = async (url) => {
|
||||||
return "&hash=portal123";
|
if (url.startsWith("/api/endpoint/gethash_api?path=")) {
|
||||||
|
signCalls.push(url);
|
||||||
|
return { data: { hash: "&hash=portal123" } };
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error("Unexpected get url: " + url);
|
||||||
};
|
};
|
||||||
|
|
||||||
axios.requestHandler = async () => ({ data: { removed: true } });
|
axios.requestHandler = async () => ({ data: { removed: true } });
|
||||||
@@ -167,28 +172,39 @@ test("portal/deleteMyRepresentations appends hash and returns data", async () =>
|
|||||||
axios,
|
axios,
|
||||||
BASE_URL: "",
|
BASE_URL: "",
|
||||||
consoleLogger: logger.consoleLogger,
|
consoleLogger: logger.consoleLogger,
|
||||||
hashAPIPath
|
hashAPIPath: () => "&hash=fallback"
|
||||||
});
|
});
|
||||||
|
|
||||||
const result = await portal.deleteMyRepresentations("rep-1");
|
const result = await portal.deleteMyRepresentations("rep-1");
|
||||||
|
|
||||||
assert.deepStrictEqual(normalize(result), { removed: true });
|
assert.deepStrictEqual(normalize(result), { removed: true });
|
||||||
assert.strictEqual(hashCalls.length, 1);
|
|
||||||
assert.strictEqual(
|
assert.strictEqual(
|
||||||
hashCalls[0],
|
signCalls[0],
|
||||||
"/api/endpoint/deletemyrepresentations_api?myRepresentationsID=rep-1"
|
"/api/endpoint/gethash_api?path=%2Fapi%2Fendpoint%2Fdeletemyrepresentations_api%3FmyRepresentationsID%3Drep-1"
|
||||||
);
|
);
|
||||||
assert.strictEqual(
|
assert.strictEqual(
|
||||||
axios.calls[0].config.url,
|
axios.calls[0].url,
|
||||||
|
"/api/endpoint/gethash_api?path=%2Fapi%2Fendpoint%2Fdeletemyrepresentations_api%3FmyRepresentationsID%3Drep-1"
|
||||||
|
);
|
||||||
|
assert.strictEqual(
|
||||||
|
axios.calls[1].config.url,
|
||||||
"/api/endpoint/deletemyrepresentations_api?myRepresentationsID=rep-1&hash=portal123"
|
"/api/endpoint/deletemyrepresentations_api?myRepresentationsID=rep-1&hash=portal123"
|
||||||
);
|
);
|
||||||
assert.strictEqual(axios.calls[0].config.method, "delete");
|
assert.strictEqual(axios.calls[1].config.method, "delete");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("portal/deleteMyRepresentations logs and returns undefined on failure", async () => {
|
test("portal/deleteMyRepresentations logs and returns undefined on failure", async () => {
|
||||||
const axios = createAxiosMock();
|
const axios = createAxiosMock();
|
||||||
const logger = createLoggerMock();
|
const logger = createLoggerMock();
|
||||||
|
|
||||||
|
axios.getHandler = async (url) => {
|
||||||
|
if (url.startsWith("/api/endpoint/gethash_api?path=")) {
|
||||||
|
return { data: { hash: "&hash=portal-fail" } };
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error("Unexpected get url: " + url);
|
||||||
|
};
|
||||||
|
|
||||||
axios.requestHandler = async () =>
|
axios.requestHandler = async () =>
|
||||||
Promise.reject(createAxiosError(401, "Unauthorized"));
|
Promise.reject(createAxiosError(401, "Unauthorized"));
|
||||||
|
|
||||||
@@ -196,7 +212,7 @@ test("portal/deleteMyRepresentations logs and returns undefined on failure", asy
|
|||||||
axios,
|
axios,
|
||||||
BASE_URL: "",
|
BASE_URL: "",
|
||||||
consoleLogger: logger.consoleLogger,
|
consoleLogger: logger.consoleLogger,
|
||||||
hashAPIPath: () => "&hash=portal-fail"
|
hashAPIPath: () => "&hash=fallback"
|
||||||
});
|
});
|
||||||
|
|
||||||
const result = await portal.deleteMyRepresentations("rep-2");
|
const result = await portal.deleteMyRepresentations("rep-2");
|
||||||
@@ -205,6 +221,87 @@ test("portal/deleteMyRepresentations logs and returns undefined on failure", asy
|
|||||||
assert.strictEqual(logger.calls.length, 1);
|
assert.strictEqual(logger.calls.length, 1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("portal/deleteWatchedCases uses signer hash and returns data", async () => {
|
||||||
|
const axios = createAxiosMock();
|
||||||
|
const logger = createLoggerMock();
|
||||||
|
const signCalls = [];
|
||||||
|
|
||||||
|
axios.getHandler = async (url) => {
|
||||||
|
if (url.startsWith("/api/endpoint/gethash_api?path=")) {
|
||||||
|
signCalls.push(url);
|
||||||
|
return { data: { hash: "&hash=watch123" } };
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error("Unexpected get url: " + url);
|
||||||
|
};
|
||||||
|
|
||||||
|
axios.requestHandler = async () => ({ data: { removed: true } });
|
||||||
|
|
||||||
|
const portal = loadServiceModule("portalDirectService.js", {
|
||||||
|
axios,
|
||||||
|
BASE_URL: "",
|
||||||
|
consoleLogger: logger.consoleLogger,
|
||||||
|
hashAPIPath: () => "&hash=fallback"
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await portal.deleteWatchedCases("watch-1");
|
||||||
|
|
||||||
|
assert.deepStrictEqual(normalize(result), { removed: true });
|
||||||
|
assert.strictEqual(
|
||||||
|
signCalls[0],
|
||||||
|
"/api/endpoint/gethash_api?path=%2Fapi%2Fendpoint%2Fdeletewatchedcases_api%3FwatchedCaseID%3Dwatch-1"
|
||||||
|
);
|
||||||
|
assert.strictEqual(
|
||||||
|
axios.calls[1].config.url,
|
||||||
|
"/api/endpoint/deletewatchedcases_api?watchedCaseID=watch-1&hash=watch123"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("document delete blob flows use signer hash", async () => {
|
||||||
|
const axios = createAxiosMock();
|
||||||
|
const logger = createLoggerMock();
|
||||||
|
const signCalls = [];
|
||||||
|
|
||||||
|
axios.getHandler = async (url) => {
|
||||||
|
if (url.startsWith("/api/endpoint/gethash_api?path=")) {
|
||||||
|
signCalls.push(url);
|
||||||
|
return { data: { hash: "&hash=doc123" } };
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error("Unexpected get url: " + url);
|
||||||
|
};
|
||||||
|
|
||||||
|
axios.requestHandler = async () => ({ data: { ok: true } });
|
||||||
|
|
||||||
|
const document = loadServiceModule("documentDirectService.js", {
|
||||||
|
axios,
|
||||||
|
BASE_URL: "",
|
||||||
|
consoleLogger: logger.consoleLogger,
|
||||||
|
hashAPIPath: () => "&hash=fallback"
|
||||||
|
});
|
||||||
|
|
||||||
|
const one = await document.deleteAwaitingSubmissionsFromBlob(
|
||||||
|
"c1",
|
||||||
|
"case-1"
|
||||||
|
);
|
||||||
|
const two = await document.deleteMyRepresentationsFromBlob(
|
||||||
|
"c1",
|
||||||
|
"case-1",
|
||||||
|
"rep-a"
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.deepStrictEqual(normalize(one), { ok: true });
|
||||||
|
assert.deepStrictEqual(normalize(two), { ok: true });
|
||||||
|
assert.strictEqual(
|
||||||
|
signCalls[0],
|
||||||
|
"/api/endpoint/gethash_api?path=%2Fapi%2Ffile%2Fdeleteblobcase%3Fcontainer%3Dc1%26casefolderID%3Dcase-1"
|
||||||
|
);
|
||||||
|
assert.strictEqual(
|
||||||
|
signCalls[1],
|
||||||
|
"/api/endpoint/gethash_api?path=%2Fapi%2Ffile%2Fdeleteblobrep%3Fcontainer%3Dc1%26casefolderID%3Dcase-1%26repfile%3Drep-a"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
test("account/getPortalLogin appends hash and returns res.data", async () => {
|
test("account/getPortalLogin appends hash and returns res.data", async () => {
|
||||||
const axios = createAxiosMock();
|
const axios = createAxiosMock();
|
||||||
const logger = createLoggerMock();
|
const logger = createLoggerMock();
|
||||||
|
|||||||
Reference in New Issue
Block a user