mirror of
https://github.com/screentinker/screentinker.git
synced 2026-06-17 03:32:32 -06:00
feat: full-screen-only guardrail for agency designations (#73)
Agencies can only be designated FULL-SCREEN playlists (no item with zone_id) - a full-screen agency upload can't safely target a zone, so the ambiguous case is excluded rather than solved. Checked at THREE points: - Designation (tokens.js create + PUT /:id/targets) -> 400: reject a zoned target. - Upload (agency.js item-add) -> 409: block if the playlist BECAME zoned after designation. MANDATORY because auto-publish has no draft net - a full-screen playlist designated to an auto-publish token, then zone-assigned, would otherwise auto-publish a full-screen upload into a zoned playlist. The upload check is the only thing that catches it. - Picker (settings.js): zoned playlists greyed/disabled with the reason (GET /playlists now returns a zoned flag); backend reject is the guard if the UI is bypassed. i18n x5. isZonedPlaylist = EXISTS(playlist_items WHERE zone_id IS NOT NULL). Pure restriction - no zone structure, no api_token_target_zones. Bite-test (the exact sequence) GREEN and re-proven to bite: full-screen -> designate to an auto-publish token -> zone-assign the playlist -> agency upload is BLOCKED (409), not auto-published; neutralizing the upload check makes it go red. 149 suite green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
4c38536cc6
commit
57d78dd1fa
|
|
@ -364,6 +364,7 @@ export default {
|
||||||
'apitoken.agency_no_playlists': 'Erstelle zuerst eine Playlist – ein Agentur-Token muss auf eine zielen.',
|
'apitoken.agency_no_playlists': 'Erstelle zuerst eine Playlist – ein Agentur-Token muss auf eine zielen.',
|
||||||
'apitoken.targets_label': 'Zugewiesen:',
|
'apitoken.targets_label': 'Zugewiesen:',
|
||||||
'apitoken.edit_targets': 'Playlists bearbeiten',
|
'apitoken.edit_targets': 'Playlists bearbeiten',
|
||||||
|
'apitoken.zoned_playlist_reason': 'Einer Zone zugewiesen — Agenturen brauchen eine Vollbild-Playlist',
|
||||||
'apitoken.targets_updated': 'Zuweisungen aktualisiert',
|
'apitoken.targets_updated': 'Zuweisungen aktualisiert',
|
||||||
'apitoken.auto_publish_label': 'Automatisch veröffentlichen (meine Freigabe überspringen)',
|
'apitoken.auto_publish_label': 'Automatisch veröffentlichen (meine Freigabe überspringen)',
|
||||||
'apitoken.auto_publish_hint': 'Aus (Standard): Hinzufügungen warten als Entwurf auf deine Veröffentlichung. An: sie gehen sofort live – nur für Agenturen, denen du voll vertraust.',
|
'apitoken.auto_publish_hint': 'Aus (Standard): Hinzufügungen warten als Entwurf auf deine Veröffentlichung. An: sie gehen sofort live – nur für Agenturen, denen du voll vertraust.',
|
||||||
|
|
|
||||||
|
|
@ -400,6 +400,7 @@ export default {
|
||||||
'apitoken.agency_no_playlists': 'Create a playlist first — an agency token must target one.',
|
'apitoken.agency_no_playlists': 'Create a playlist first — an agency token must target one.',
|
||||||
'apitoken.targets_label': 'Designated:',
|
'apitoken.targets_label': 'Designated:',
|
||||||
'apitoken.edit_targets': 'Edit playlists',
|
'apitoken.edit_targets': 'Edit playlists',
|
||||||
|
'apitoken.zoned_playlist_reason': 'Assigned to a zone — agencies need a full-screen playlist',
|
||||||
'apitoken.targets_updated': 'Designations updated',
|
'apitoken.targets_updated': 'Designations updated',
|
||||||
'apitoken.auto_publish_label': 'Auto-publish (skip my approval)',
|
'apitoken.auto_publish_label': 'Auto-publish (skip my approval)',
|
||||||
'apitoken.auto_publish_hint': 'Off (default): additions wait as drafts for you to publish. On: they go live immediately — only for agencies you fully trust.',
|
'apitoken.auto_publish_hint': 'Off (default): additions wait as drafts for you to publish. On: they go live immediately — only for agencies you fully trust.',
|
||||||
|
|
|
||||||
|
|
@ -363,6 +363,7 @@ export default {
|
||||||
'apitoken.agency_no_playlists': 'Crea una lista primero: un token de agencia debe apuntar a una.',
|
'apitoken.agency_no_playlists': 'Crea una lista primero: un token de agencia debe apuntar a una.',
|
||||||
'apitoken.targets_label': 'Designadas:',
|
'apitoken.targets_label': 'Designadas:',
|
||||||
'apitoken.edit_targets': 'Editar listas',
|
'apitoken.edit_targets': 'Editar listas',
|
||||||
|
'apitoken.zoned_playlist_reason': 'Asignada a una zona — las agencias necesitan una lista de pantalla completa',
|
||||||
'apitoken.targets_updated': 'Designaciones actualizadas',
|
'apitoken.targets_updated': 'Designaciones actualizadas',
|
||||||
'apitoken.auto_publish_label': 'Publicación automática (omitir mi aprobación)',
|
'apitoken.auto_publish_label': 'Publicación automática (omitir mi aprobación)',
|
||||||
'apitoken.auto_publish_hint': 'Desactivado (predeterminado): las adiciones esperan como borradores para que las publiques. Activado: se publican de inmediato, solo para agencias de plena confianza.',
|
'apitoken.auto_publish_hint': 'Desactivado (predeterminado): las adiciones esperan como borradores para que las publiques. Activado: se publican de inmediato, solo para agencias de plena confianza.',
|
||||||
|
|
|
||||||
|
|
@ -364,6 +364,7 @@ export default {
|
||||||
'apitoken.agency_no_playlists': 'Créez d\'abord une liste : un jeton d\'agence doit en cibler une.',
|
'apitoken.agency_no_playlists': 'Créez d\'abord une liste : un jeton d\'agence doit en cibler une.',
|
||||||
'apitoken.targets_label': 'Assignées :',
|
'apitoken.targets_label': 'Assignées :',
|
||||||
'apitoken.edit_targets': 'Modifier les listes',
|
'apitoken.edit_targets': 'Modifier les listes',
|
||||||
|
'apitoken.zoned_playlist_reason': 'Affectée à une zone — les agences ont besoin d\'une liste plein écran',
|
||||||
'apitoken.targets_updated': 'Désignations mises à jour',
|
'apitoken.targets_updated': 'Désignations mises à jour',
|
||||||
'apitoken.auto_publish_label': 'Publication automatique (ignorer mon approbation)',
|
'apitoken.auto_publish_label': 'Publication automatique (ignorer mon approbation)',
|
||||||
'apitoken.auto_publish_hint': 'Désactivé (par défaut) : les ajouts attendent en brouillon votre publication. Activé : ils sont diffusés immédiatement, uniquement pour les agences de pleine confiance.',
|
'apitoken.auto_publish_hint': 'Désactivé (par défaut) : les ajouts attendent en brouillon votre publication. Activé : ils sont diffusés immédiatement, uniquement pour les agences de pleine confiance.',
|
||||||
|
|
|
||||||
|
|
@ -364,6 +364,7 @@ export default {
|
||||||
'apitoken.agency_no_playlists': 'Crie uma lista primeiro: um token de agência deve apontar para uma.',
|
'apitoken.agency_no_playlists': 'Crie uma lista primeiro: um token de agência deve apontar para uma.',
|
||||||
'apitoken.targets_label': 'Designadas:',
|
'apitoken.targets_label': 'Designadas:',
|
||||||
'apitoken.edit_targets': 'Editar listas',
|
'apitoken.edit_targets': 'Editar listas',
|
||||||
|
'apitoken.zoned_playlist_reason': 'Atribuída a uma zona — agências precisam de uma lista de tela cheia',
|
||||||
'apitoken.targets_updated': 'Designações atualizadas',
|
'apitoken.targets_updated': 'Designações atualizadas',
|
||||||
'apitoken.auto_publish_label': 'Publicação automática (ignorar minha aprovação)',
|
'apitoken.auto_publish_label': 'Publicação automática (ignorar minha aprovação)',
|
||||||
'apitoken.auto_publish_hint': 'Desativado (padrão): as adições aguardam como rascunho para você publicar. Ativado: vão ao ar imediatamente, apenas para agências de total confiança.',
|
'apitoken.auto_publish_hint': 'Desativado (padrão): as adições aguardam como rascunho para você publicar. Ativado: vão ao ar imediatamente, apenas para agências de total confiança.',
|
||||||
|
|
|
||||||
|
|
@ -412,7 +412,9 @@ export async function render(container) {
|
||||||
<h4 style="font-size:14px;margin-bottom:8px">${t('apitoken.edit_targets')}</h4>
|
<h4 style="font-size:14px;margin-bottom:8px">${t('apitoken.edit_targets')}</h4>
|
||||||
<div style="display:flex;flex-direction:column;gap:6px;max-height:200px;overflow:auto;margin-bottom:12px">
|
<div style="display:flex;flex-direction:column;gap:6px;max-height:200px;overflow:auto;margin-bottom:12px">
|
||||||
${pls.length
|
${pls.length
|
||||||
? pls.map(p => `<label style="display:flex;gap:8px;align-items:center;font-size:13px"><input type="checkbox" class="edit-pl" value="${esc(String(p.id))}"${current.has(String(p.id)) ? ' checked' : ''}> ${esc(p.name)}</label>`).join('')
|
? pls.map(p => p.zoned
|
||||||
|
? `<label style="display:flex;gap:8px;align-items:center;font-size:13px;opacity:.5"><input type="checkbox" disabled> ${esc(p.name)} <span style="font-size:11px;color:var(--text-muted)">— ${esc(t('apitoken.zoned_playlist_reason'))}</span></label>`
|
||||||
|
: `<label style="display:flex;gap:8px;align-items:center;font-size:13px"><input type="checkbox" class="edit-pl" value="${esc(String(p.id))}"${current.has(String(p.id)) ? ' checked' : ''}> ${esc(p.name)}</label>`).join('')
|
||||||
: `<p style="color:var(--text-muted);font-size:12px">${t('apitoken.agency_no_playlists')}</p>`}
|
: `<p style="color:var(--text-muted);font-size:12px">${t('apitoken.agency_no_playlists')}</p>`}
|
||||||
</div>
|
</div>
|
||||||
<button class="btn btn-primary btn-sm" id="saveTargetsBtn">${t('common.save')}</button>
|
<button class="btn btn-primary btn-sm" id="saveTargetsBtn">${t('common.save')}</button>
|
||||||
|
|
@ -446,7 +448,9 @@ export async function render(container) {
|
||||||
const list = document.getElementById('agencyPlaylistList');
|
const list = document.getElementById('agencyPlaylistList');
|
||||||
const pls = await api.getPlaylists().catch(() => []);
|
const pls = await api.getPlaylists().catch(() => []);
|
||||||
list.innerHTML = pls.length
|
list.innerHTML = pls.length
|
||||||
? pls.map(p => `<label style="display:flex;gap:8px;align-items:center;font-size:13px"><input type="checkbox" class="agency-pl" value="${esc(String(p.id))}"> ${esc(p.name)}</label>`).join('')
|
? pls.map(p => p.zoned
|
||||||
|
? `<label style="display:flex;gap:8px;align-items:center;font-size:13px;opacity:.5"><input type="checkbox" disabled> ${esc(p.name)} <span style="font-size:11px;color:var(--text-muted)">— ${esc(t('apitoken.zoned_playlist_reason'))}</span></label>`
|
||||||
|
: `<label style="display:flex;gap:8px;align-items:center;font-size:13px"><input type="checkbox" class="agency-pl" value="${esc(String(p.id))}"> ${esc(p.name)}</label>`).join('')
|
||||||
: `<p style="color:var(--text-muted);font-size:12px">${t('apitoken.agency_no_playlists')}</p>`;
|
: `<p style="color:var(--text-muted);font-size:12px">${t('apitoken.agency_no_playlists')}</p>`;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -17,4 +17,13 @@ function listDesignatedPlaylists(db, tokenId, workspaceId) {
|
||||||
`).all(tokenId, workspaceId);
|
`).all(tokenId, workspaceId);
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { listDesignatedPlaylists };
|
// #73 full-screen guardrail: a playlist is "zoned" if any item targets a layout zone. Agency
|
||||||
|
// uploads are full-screen and can't safely target a zone, so a zoned playlist can't be shared
|
||||||
|
// with an agency. Checked at BOTH designation (reject the grant) AND upload (block the add) -
|
||||||
|
// the upload check is mandatory because auto-publish has no draft step to catch a playlist
|
||||||
|
// that becomes zoned after designation.
|
||||||
|
function isZonedPlaylist(db, playlistId) {
|
||||||
|
return !!db.prepare('SELECT 1 FROM playlist_items WHERE playlist_id = ? AND zone_id IS NOT NULL LIMIT 1').get(playlistId);
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { listDesignatedPlaylists, isZonedPlaylist };
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@ const { db } = require('../db/database');
|
||||||
const upload = require('../middleware/upload');
|
const upload = require('../middleware/upload');
|
||||||
const { checkStorageLimit } = require('../middleware/subscription');
|
const { checkStorageLimit } = require('../middleware/subscription');
|
||||||
const { ingestUploadedFile } = require('../lib/content-ingest');
|
const { ingestUploadedFile } = require('../lib/content-ingest');
|
||||||
const { listDesignatedPlaylists } = require('../lib/agency-targets');
|
const { listDesignatedPlaylists, isZonedPlaylist } = require('../lib/agency-targets');
|
||||||
const { listLayoutGeometry } = require('../lib/agency-layouts');
|
const { listLayoutGeometry } = require('../lib/agency-layouts');
|
||||||
const { publishPlaylist } = require('./playlists'); // #73: shared publish path for auto-publish
|
const { publishPlaylist } = require('./playlists'); // #73: shared publish path for auto-publish
|
||||||
const { isConfigured } = require('../services/email'); // #73: gate digest enqueue on SMTP being set
|
const { isConfigured } = require('../services/email'); // #73: gate digest enqueue on SMTP being set
|
||||||
|
|
@ -72,6 +72,14 @@ router.post('/playlists/:playlistId/items', (req, res) => {
|
||||||
const { content_id } = req.body;
|
const { content_id } = req.body;
|
||||||
if (!content_id) return res.status(400).json({ error: 'content_id required' });
|
if (!content_id) return res.status(400).json({ error: 'content_id required' });
|
||||||
|
|
||||||
|
// #73 full-screen guardrail, upload-time (MANDATORY because auto-publish has no draft net):
|
||||||
|
// if the designated playlist has BECOME zoned since designation, block the add - a full-screen
|
||||||
|
// agency upload can't target a zone. 409 (not 401/403) so the portal shows the message, not its
|
||||||
|
// "key invalid" reset. This runs BEFORE the draft/publish branch, so auto-publish can't slip through.
|
||||||
|
if (isZonedPlaylist(db, req.params.playlistId)) {
|
||||||
|
return res.status(409).json({ error: "This playlist can't accept uploads right now — it's been assigned to a zone on a screen. Ask your contact." });
|
||||||
|
}
|
||||||
|
|
||||||
const content = db.prepare('SELECT id, workspace_id, duration_sec FROM content WHERE id = ?').get(content_id);
|
const content = db.prepare('SELECT id, workspace_id, duration_sec FROM content WHERE id = ?').get(content_id);
|
||||||
if (!content) return res.status(404).json({ error: 'Content not found' });
|
if (!content) return res.status(404).json({ error: 'Content not found' });
|
||||||
// cross-tenant guard: content must be in the token's bound workspace (or a template)
|
// cross-tenant guard: content must be in the token's bound workspace (or a template)
|
||||||
|
|
|
||||||
|
|
@ -138,7 +138,8 @@ function publishPlaylist(playlistId, req) {
|
||||||
router.get('/', (req, res) => {
|
router.get('/', (req, res) => {
|
||||||
if (!req.workspaceId) return res.json([]);
|
if (!req.workspaceId) return res.json([]);
|
||||||
const playlists = db.prepare(`
|
const playlists = db.prepare(`
|
||||||
SELECT p.*, COUNT(DISTINCT pi.id) as item_count, COUNT(DISTINCT d.id) as display_count
|
SELECT p.*, COUNT(DISTINCT pi.id) as item_count, COUNT(DISTINCT d.id) as display_count,
|
||||||
|
EXISTS(SELECT 1 FROM playlist_items z WHERE z.playlist_id = p.id AND z.zone_id IS NOT NULL) as zoned
|
||||||
FROM playlists p
|
FROM playlists p
|
||||||
LEFT JOIN playlist_items pi ON p.id = pi.playlist_id
|
LEFT JOIN playlist_items pi ON p.id = pi.playlist_id
|
||||||
LEFT JOIN devices d ON d.playlist_id = p.id
|
LEFT JOIN devices d ON d.playlist_id = p.id
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ const crypto = require('crypto');
|
||||||
const { db } = require('../db/database');
|
const { db } = require('../db/database');
|
||||||
const { generateToken, hashToken, displayPrefix } = require('../middleware/apiToken');
|
const { generateToken, hashToken, displayPrefix } = require('../middleware/apiToken');
|
||||||
const { accessContext } = require('../lib/tenancy');
|
const { accessContext } = require('../lib/tenancy');
|
||||||
|
const { isZonedPlaylist } = require('../lib/agency-targets'); // #73: full-screen-only guardrail
|
||||||
|
|
||||||
// #73: 'agency' is OFF the read/write/full ladder (not in apiToken.js SCOPE_RANK), so a
|
// #73: 'agency' is OFF the read/write/full ladder (not in apiToken.js SCOPE_RANK), so a
|
||||||
// tokenScopeGate-mounted router rejects it; it reaches only the AGENCY_ROUTER via agencyGate.
|
// tokenScopeGate-mounted router rejects it; it reaches only the AGENCY_ROUTER via agencyGate.
|
||||||
|
|
@ -53,6 +54,8 @@ router.post('/', (req, res) => {
|
||||||
const inWs = db.prepare('SELECT id FROM playlists WHERE id = ? AND workspace_id = ?');
|
const inWs = db.prepare('SELECT id FROM playlists WHERE id = ? AND workspace_id = ?');
|
||||||
for (const pid of targetIds) {
|
for (const pid of targetIds) {
|
||||||
if (!inWs.get(pid, req.workspaceId)) return res.status(400).json({ error: `playlist ${pid} is not in this workspace` });
|
if (!inWs.get(pid, req.workspaceId)) return res.status(400).json({ error: `playlist ${pid} is not in this workspace` });
|
||||||
|
// #73: agencies get FULL-SCREEN playlists only - a zoned playlist can't take full-screen uploads.
|
||||||
|
if (isZonedPlaylist(db, pid)) return res.status(400).json({ error: 'A selected playlist is assigned to a zone on a screen — agency uploads play full-screen, so it can\'t be shared with an agency. Use a full-screen playlist.' });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const secret = generateToken();
|
const secret = generateToken();
|
||||||
|
|
@ -92,6 +95,8 @@ router.put('/:id/targets', (req, res) => {
|
||||||
const inWs = db.prepare('SELECT id FROM playlists WHERE id = ? AND workspace_id = ?');
|
const inWs = db.prepare('SELECT id FROM playlists WHERE id = ? AND workspace_id = ?');
|
||||||
for (const pid of ids) {
|
for (const pid of ids) {
|
||||||
if (!inWs.get(pid, tok.workspace_id)) return res.status(400).json({ error: `playlist ${pid} is not in this token's workspace` });
|
if (!inWs.get(pid, tok.workspace_id)) return res.status(400).json({ error: `playlist ${pid} is not in this token's workspace` });
|
||||||
|
// #73: full-screen-only - a zoned playlist can't be (re-)designated to an agency.
|
||||||
|
if (isZonedPlaylist(db, pid)) return res.status(400).json({ error: 'A selected playlist is assigned to a zone on a screen — agency uploads play full-screen, so it can\'t be shared with an agency. Use a full-screen playlist.' });
|
||||||
}
|
}
|
||||||
const ins = db.prepare('INSERT OR IGNORE INTO api_token_targets (token_id, playlist_id) VALUES (?, ?)');
|
const ins = db.prepare('INSERT OR IGNORE INTO api_token_targets (token_id, playlist_id) VALUES (?, ?)');
|
||||||
db.transaction(() => {
|
db.transaction(() => {
|
||||||
|
|
|
||||||
|
|
@ -159,3 +159,35 @@ test('#73 edit-designations: PUT /:id/targets re-designates (add + remove); conf
|
||||||
assert.equal((await jfetch(`/api/agency/playlists/${plB.id}/layout`, auth(atok))).status, 200, 'kept B -> 200');
|
assert.equal((await jfetch(`/api/agency/playlists/${plB.id}/layout`, auth(atok))).status, 200, 'kept B -> 200');
|
||||||
assert.equal((await jfetch(`/api/agency/playlists/${plC.id}/layout`, auth(atok))).status, 200, 'added C -> 200');
|
assert.equal((await jfetch(`/api/agency/playlists/${plC.id}/layout`, auth(atok))).status, 200, 'added C -> 200');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('#73 full-screen guardrail holds at UPLOAD time too (auto-publish has no draft net)', async () => {
|
||||||
|
const auth = (tok) => ({ headers: { Authorization: 'Bearer ' + tok } });
|
||||||
|
const upload = async (tok) => {
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.append('file', new Blob([Buffer.from('x')], { type: 'image/png' }), 't.png');
|
||||||
|
return (await fetch(BASE + '/api/agency/content', { method: 'POST', headers: { Authorization: 'Bearer ' + tok }, body: fd })).json();
|
||||||
|
};
|
||||||
|
const email = 'fs' + crypto.randomBytes(4).toString('hex') + '@x.local';
|
||||||
|
const jwt = (await jfetch('/api/auth/register', reg({ email, password: 'Passw0rd123' }))).body.token;
|
||||||
|
const plFS = (await jfetch('/api/playlists', jpost(jwt, { name: 'FullScreen' }))).body;
|
||||||
|
|
||||||
|
// (1) full-screen playlist -> AUTO-PUBLISH token designation SUCCEEDS (safe at designation)
|
||||||
|
const tokRes = await jfetch('/api/tokens', jpost(jwt, { name: 'AP', scope: 'agency', target_playlist_ids: [plFS.id], auto_publish: true }));
|
||||||
|
assert.equal(tokRes.status, 201, 'full-screen designation OK');
|
||||||
|
const atok = tokRes.body.token;
|
||||||
|
|
||||||
|
// (2) zone the playlist AFTER designation: a layout+zone, then a zone-targeted item via JWT
|
||||||
|
const lid = (await jfetch('/api/layouts', jpost(jwt, { name: 'Z', zones: [{ name: 'Main', x_percent: 0, y_percent: 0, width_percent: 70, height_percent: 100 }] }))).body.id;
|
||||||
|
const zoneId = (await jfetch(`/api/layouts/${lid}`, auth(jwt))).body.zones[0].id;
|
||||||
|
const c1 = await upload(atok);
|
||||||
|
assert.equal((await jfetch(`/api/playlists/${plFS.id}/items`, jpost(jwt, { content_id: c1.id, zone_id: zoneId }))).status, 201, 'playlist is now zoned');
|
||||||
|
|
||||||
|
// (3) THE BITE: agency upload to the now-zoned playlist is BLOCKED (409), NOT auto-published into the zone
|
||||||
|
const c2 = await upload(atok);
|
||||||
|
const add = await jfetch(`/api/agency/playlists/${plFS.id}/items`, jpost(atok, { content_id: c2.id }));
|
||||||
|
assert.equal(add.status, 409, 'upload to a now-zoned playlist blocked (auto-publish cannot slip it into the zone)');
|
||||||
|
|
||||||
|
// (4) and an already-zoned playlist is rejected at DESIGNATION too
|
||||||
|
const reDesig = await jfetch('/api/tokens', jpost(jwt, { name: 'AP2', scope: 'agency', target_playlist_ids: [plFS.id] }));
|
||||||
|
assert.equal(reDesig.status, 400, 'already-zoned playlist rejected at designation');
|
||||||
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue