chore: modernize APT runtime and CI
This commit is contained in:
@@ -0,0 +1,36 @@
|
||||
name: APT build checks
|
||||
|
||||
on:
|
||||
push:
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
build-checks:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout source
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Node.js 22
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: npm
|
||||
|
||||
- name: Install locked dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Run source policy and tests
|
||||
run: npm run check
|
||||
|
||||
- name: Audit production dependencies
|
||||
run: npm run audit:prod
|
||||
|
||||
- name: Build unpacked Windows application
|
||||
run: npm run build-test
|
||||
|
||||
- name: Verify unpacked build and extension policy
|
||||
run: node scripts/verify-kit.js --build dist/win-unpacked
|
||||
@@ -14,7 +14,7 @@ APT is a Windows Electron desktop app that imports an existing Alta Video sessio
|
||||
## Requirements
|
||||
|
||||
- Windows (current supported runtime scope)
|
||||
- Node.js and npm for development
|
||||
- Node.js 22.12 or newer and npm for development
|
||||
- Chrome with the bundled extension loaded unpacked
|
||||
- `aware-cam-proxy.exe` beside the development app or packaged portable executable
|
||||
- An active Alta Video login in Chrome
|
||||
@@ -39,6 +39,7 @@ Use **Generate / Rotate** if a pairing may have been exposed, then update the ex
|
||||
```bash
|
||||
npm test # Node test suite, including synthetic runtime contracts
|
||||
npm run check # Syntax checks plus tests
|
||||
npm run audit:prod
|
||||
npm run build-test
|
||||
```
|
||||
|
||||
@@ -62,4 +63,6 @@ Core files:
|
||||
npm run build
|
||||
```
|
||||
|
||||
The output remains Windows-only because the external camera helper is Windows-specific. Copy `aware-cam-proxy.exe` beside the portable APT executable before use. Dependencies are intentionally unchanged in this hardening release.
|
||||
The output remains Windows-only because the external camera helper is Windows-specific. Copy `aware-cam-proxy.exe` beside the portable APT executable before use.
|
||||
|
||||
CI produces an unpacked Windows build only for verification. It does not publish, release, push, deploy, or sign artifacts. Windows code signing and verification with the approved certificate remain mandatory manual release gates; a successful build must not be represented as signed.
|
||||
|
||||
Generated
+808
-2636
File diff suppressed because it is too large
Load Diff
+22
-14
@@ -1,15 +1,16 @@
|
||||
{
|
||||
"name": "alta-api-client",
|
||||
"version": "1.0.0",
|
||||
"description": "Electron app for connecting to Alta API",
|
||||
"version": "1.1.0",
|
||||
"description": "Secure Windows Electron client for the Alta Camera Proxy",
|
||||
"main": "main.js",
|
||||
"scripts": {
|
||||
"start": "electron .",
|
||||
"dev": "electron . --dev",
|
||||
"test": "node --test test/*.test.js",
|
||||
"check": "node --check main.js && node --check preload.js && node --check renderer.js && node --check src/electron-runtime.js && node --check chrome-extension/popup.js && npm test",
|
||||
"check": "node scripts/verify-kit.js && npm test",
|
||||
"audit:prod": "npm audit --omit=dev --audit-level=low",
|
||||
"build": "electron-builder --win --publish=never",
|
||||
"build-test": "electron-builder --win --dir",
|
||||
"build-test": "electron-builder --win --dir --publish=never",
|
||||
"build-kit": "powershell -ExecutionPolicy Bypass -File build-kit.ps1",
|
||||
"prebuild": "echo Checking build requirements..."
|
||||
},
|
||||
@@ -19,15 +20,24 @@
|
||||
"directories": {
|
||||
"output": "dist"
|
||||
},
|
||||
"files": [
|
||||
"main.js",
|
||||
"preload.js",
|
||||
"renderer.js",
|
||||
"index.html",
|
||||
"styles.css",
|
||||
"src/**/*",
|
||||
"chrome-extension/**/*",
|
||||
"assets/**/*",
|
||||
"package.json"
|
||||
],
|
||||
"win": {
|
||||
"target": "portable",
|
||||
"icon": "assets/icon.png",
|
||||
"signAndEditExecutable": false
|
||||
"icon": "assets/icon.png"
|
||||
},
|
||||
"portable": {
|
||||
"artifactName": "AltaCameraProxy-${version}-portable.exe"
|
||||
},
|
||||
"forceCodeSigning": false
|
||||
}
|
||||
},
|
||||
"keywords": [
|
||||
"electron",
|
||||
@@ -35,15 +45,13 @@
|
||||
"api",
|
||||
"avigilon"
|
||||
],
|
||||
"author": "Your Name",
|
||||
"author": "Peji",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"electron": "^28.0.0",
|
||||
"electron-builder": "^26.4.0",
|
||||
"electron-packager": "^17.1.2"
|
||||
"electron": "43.4.1",
|
||||
"electron-builder": "26.15.3"
|
||||
},
|
||||
"dependencies": {
|
||||
"axios": "^1.6.0",
|
||||
"crypto-js": "^4.2.0"
|
||||
"axios": "1.19.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
'use strict';
|
||||
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const zlib = require('node:zlib');
|
||||
const { spawnSync } = require('node:child_process');
|
||||
|
||||
const ROOT = path.resolve(__dirname, '..');
|
||||
const REQUIRED_EXTENSION_FILES = Object.freeze([
|
||||
'manifest.json', 'popup.html', 'popup.js', 'popup.css',
|
||||
'options.html', 'options.js', 'options.css',
|
||||
]);
|
||||
const TEXT_EXTENSIONS = new Set(['.css', '.html', '.js', '.json', '.md', '.ps1', '.yml', '.yaml']);
|
||||
const FORBIDDEN_PATTERNS = Object.freeze([
|
||||
['legacy bridge token', /apt-local-bridge-token/i],
|
||||
['legacy pairing header', /X-APT-Token/i],
|
||||
['legacy cookie proxy', /cookie-proxy-/i],
|
||||
['GitHub release API', /api\.github\.com/i],
|
||||
['executable updater', /download-and-install-update/i],
|
||||
['broad process termination', /taskkill/i],
|
||||
]);
|
||||
|
||||
function fail(message) {
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
function normalizeRelative(name) {
|
||||
if (typeof name !== 'string' || name.length === 0 || name.includes('\0')) fail(`Unsafe empty or NUL path: ${name}`);
|
||||
const unix = name.replaceAll('\\', '/');
|
||||
if (unix.startsWith('/') || /^[A-Za-z]:\//.test(unix)) fail(`Unsafe absolute path: ${name}`);
|
||||
const normalized = path.posix.normalize(unix);
|
||||
if (normalized === '..' || normalized.startsWith('../')) fail(`Unsafe parent path: ${name}`);
|
||||
return normalized.replace(/^\.\//, '');
|
||||
}
|
||||
|
||||
function assertTextPolicy(name, content) {
|
||||
for (const [label, pattern] of FORBIDDEN_PATTERNS) {
|
||||
if (pattern.test(content)) fail(`Forbidden ${label} string in ${name}`);
|
||||
}
|
||||
}
|
||||
|
||||
function listDirectory(root, { ignoreTopLevel = [] } = {}) {
|
||||
const absoluteRoot = path.resolve(root);
|
||||
if (!fs.statSync(absoluteRoot).isDirectory()) fail(`Expected a directory: ${root}`);
|
||||
const files = new Map();
|
||||
const ignored = new Set(ignoreTopLevel);
|
||||
const walk = (directory) => {
|
||||
for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
|
||||
const absolute = path.join(directory, entry.name);
|
||||
const relative = normalizeRelative(path.relative(absoluteRoot, absolute));
|
||||
if (!relative.includes('/') && ignored.has(relative)) continue;
|
||||
if (entry.isSymbolicLink()) fail(`Unsafe symbolic link: ${relative}`);
|
||||
if (entry.isDirectory()) walk(absolute);
|
||||
else if (entry.isFile()) files.set(relative, () => fs.readFileSync(absolute));
|
||||
else fail(`Unsupported filesystem entry: ${relative}`);
|
||||
}
|
||||
};
|
||||
walk(absoluteRoot);
|
||||
return files;
|
||||
}
|
||||
|
||||
function findEndOfCentralDirectory(buffer) {
|
||||
const minimum = Math.max(0, buffer.length - 65_557);
|
||||
for (let offset = buffer.length - 22; offset >= minimum; offset -= 1) {
|
||||
if (buffer.readUInt32LE(offset) === 0x06054b50) return offset;
|
||||
}
|
||||
fail('Invalid ZIP: end-of-central-directory record not found');
|
||||
}
|
||||
|
||||
function listZip(zipPath) {
|
||||
const zip = fs.readFileSync(zipPath);
|
||||
const end = findEndOfCentralDirectory(zip);
|
||||
const entryCount = zip.readUInt16LE(end + 10);
|
||||
let offset = zip.readUInt32LE(end + 16);
|
||||
const files = new Map();
|
||||
|
||||
for (let index = 0; index < entryCount; index += 1) {
|
||||
if (zip.readUInt32LE(offset) !== 0x02014b50) fail('Invalid ZIP central-directory entry');
|
||||
const flags = zip.readUInt16LE(offset + 8);
|
||||
const method = zip.readUInt16LE(offset + 10);
|
||||
const compressedSize = zip.readUInt32LE(offset + 20);
|
||||
const uncompressedSize = zip.readUInt32LE(offset + 24);
|
||||
const nameLength = zip.readUInt16LE(offset + 28);
|
||||
const extraLength = zip.readUInt16LE(offset + 30);
|
||||
const commentLength = zip.readUInt16LE(offset + 32);
|
||||
const externalAttributes = zip.readUInt32LE(offset + 38);
|
||||
const localOffset = zip.readUInt32LE(offset + 42);
|
||||
const name = normalizeRelative(zip.subarray(offset + 46, offset + 46 + nameLength).toString('utf8'));
|
||||
offset += 46 + nameLength + extraLength + commentLength;
|
||||
if (name.endsWith('/')) continue;
|
||||
const unixMode = externalAttributes >>> 16;
|
||||
if ((unixMode & 0o170000) === 0o120000) fail(`Unsafe ZIP symbolic link: ${name}`);
|
||||
if ((flags & 1) !== 0) fail(`Encrypted ZIP entries are unsupported: ${name}`);
|
||||
if (files.has(name)) fail(`Duplicate ZIP entry: ${name}`);
|
||||
|
||||
files.set(name, () => {
|
||||
if (zip.readUInt32LE(localOffset) !== 0x04034b50) fail(`Invalid ZIP local entry: ${name}`);
|
||||
const localNameLength = zip.readUInt16LE(localOffset + 26);
|
||||
const localExtraLength = zip.readUInt16LE(localOffset + 28);
|
||||
const start = localOffset + 30 + localNameLength + localExtraLength;
|
||||
const compressed = zip.subarray(start, start + compressedSize);
|
||||
const content = method === 0 ? compressed : method === 8 ? zlib.inflateRawSync(compressed) : fail(`Unsupported ZIP method ${method}: ${name}`);
|
||||
if (content.length !== uncompressedSize) fail(`Invalid ZIP size for ${name}`);
|
||||
return content;
|
||||
});
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
function listAsar(asarPath) {
|
||||
const asar = require('@electron/asar');
|
||||
const files = new Map();
|
||||
for (const archiveName of asar.listPackage(asarPath)) {
|
||||
if (!archiveName.startsWith('/')) fail(`Unsafe ASAR path: ${archiveName}`);
|
||||
const name = normalizeRelative(archiveName.slice(1));
|
||||
let stat;
|
||||
try {
|
||||
stat = asar.statFile(asarPath, name);
|
||||
} catch (error) {
|
||||
fail(`Invalid ASAR entry ${name}: ${error.message}`);
|
||||
}
|
||||
if (!Number.isSafeInteger(stat.size)) continue;
|
||||
if (!name.startsWith('node_modules/')) files.set(name, () => asar.extractFile(asarPath, name));
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
function findFile(files, suffix) {
|
||||
const matches = [...files.keys()].filter((name) => name === suffix || name.endsWith(`/${suffix}`));
|
||||
if (matches.length !== 1) fail(`Expected exactly one ${suffix}, found ${matches.length}`);
|
||||
return matches[0];
|
||||
}
|
||||
|
||||
function verifyExtension(files, expectedVersion) {
|
||||
const manifestName = findFile(files, 'chrome-extension/manifest.json');
|
||||
const extensionRoot = manifestName.slice(0, -'manifest.json'.length);
|
||||
for (const required of REQUIRED_EXTENSION_FILES) {
|
||||
if (!files.has(`${extensionRoot}${required}`)) fail(`Missing expected extension file: ${required}`);
|
||||
}
|
||||
let manifest;
|
||||
try {
|
||||
manifest = JSON.parse(files.get(manifestName)().toString('utf8'));
|
||||
} catch (error) {
|
||||
fail(`Invalid extension manifest: ${error.message}`);
|
||||
}
|
||||
if (manifest.version !== expectedVersion) {
|
||||
fail(`Extension version ${manifest.version} does not match application version ${expectedVersion}`);
|
||||
}
|
||||
}
|
||||
|
||||
function verifyTextFiles(files) {
|
||||
for (const [name, load] of files) {
|
||||
if (TEXT_EXTENSIONS.has(path.extname(name).toLowerCase())) assertTextPolicy(name, load().toString('utf8'));
|
||||
}
|
||||
}
|
||||
|
||||
function verifySyntax(files) {
|
||||
const javascript = [...files.keys()].filter((name) => name.endsWith('.js'));
|
||||
javascript.push('scripts/verify-kit.js');
|
||||
for (const name of javascript) {
|
||||
const result = spawnSync(process.execPath, ['--check', path.join(ROOT, name)], { encoding: 'utf8' });
|
||||
if (result.status !== 0) fail(`JavaScript syntax check failed for ${name}: ${result.stderr.trim()}`);
|
||||
}
|
||||
}
|
||||
|
||||
function verifySource() {
|
||||
const pkg = JSON.parse(fs.readFileSync(path.join(ROOT, 'package.json'), 'utf8'));
|
||||
const lock = JSON.parse(fs.readFileSync(path.join(ROOT, 'package-lock.json'), 'utf8'));
|
||||
const files = listDirectory(ROOT, { ignoreTopLevel: ['.git', 'dist', 'node_modules'] });
|
||||
verifyExtension(files, pkg.version);
|
||||
for (const [label, version] of [['lockfile', lock.version], ['lockfile root package', lock.packages?.['']?.version]]) {
|
||||
if (version !== pkg.version) fail(`${label} version ${version} does not match application version ${pkg.version}`);
|
||||
}
|
||||
const productionRoots = ['main.js', 'preload.js', 'renderer.js', 'index.html', 'src/', 'chrome-extension/'];
|
||||
const productionFiles = new Map([...files].filter(([name]) =>
|
||||
productionRoots.some((root) => name === root || name.startsWith(root))
|
||||
));
|
||||
verifyTextFiles(productionFiles);
|
||||
verifySyntax(productionFiles);
|
||||
return pkg.version;
|
||||
}
|
||||
|
||||
function verifySupplied(target, expectedVersion, mode) {
|
||||
const absolute = path.resolve(target);
|
||||
if (!fs.existsSync(absolute)) fail(`Supplied ${mode} does not exist: ${target}`);
|
||||
const isDirectory = fs.statSync(absolute).isDirectory();
|
||||
const files = isDirectory ? listDirectory(absolute) : listZip(absolute);
|
||||
const hostNames = [...files.keys()];
|
||||
if (mode === 'build' && !hostNames.includes('chrome-extension/manifest.json')) {
|
||||
const asarName = hostNames.find((name) => /(?:^|\/)resources\/app\.asar$/i.test(name));
|
||||
if (!asarName || !isDirectory) fail('Missing packaged app.asar in supplied build');
|
||||
for (const [name, load] of listAsar(path.join(absolute, asarName))) files.set(name, load);
|
||||
}
|
||||
verifyExtension(files, expectedVersion);
|
||||
verifyTextFiles(files);
|
||||
if (!hostNames.some((name) => /(?:^|\/)Alta(?:CameraProxy| Camera Proxy)(?:-[^/]*)?\.exe$/i.test(name))) {
|
||||
fail(`Missing expected APT executable in supplied ${mode}`);
|
||||
}
|
||||
if (mode === 'kit' && !hostNames.some((name) => /(?:^|\/)aware-cam-proxy\.exe$/i.test(name))) {
|
||||
fail('Missing expected aware-cam-proxy.exe in supplied kit');
|
||||
}
|
||||
}
|
||||
|
||||
function main(argv) {
|
||||
const version = verifySource();
|
||||
if (argv.length > 0) {
|
||||
const buildMode = argv[0] === '--build';
|
||||
const target = buildMode ? argv[1] : argv[0];
|
||||
if (!target || argv.length !== (buildMode ? 2 : 1)) fail('Usage: node scripts/verify-kit.js [--build <directory>|<kit-directory-or-zip>]');
|
||||
verifySupplied(target, version, buildMode ? 'build' : 'kit');
|
||||
}
|
||||
process.stdout.write(`APT source${argv.length ? ' and supplied artifact' : ''} verified at version ${version}.\n`);
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
try {
|
||||
main(process.argv.slice(2));
|
||||
} catch (error) {
|
||||
process.stderr.write(`Verification failed: ${error.message}\n`);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { normalizeRelative, verifySource, verifySupplied };
|
||||
@@ -0,0 +1,97 @@
|
||||
'use strict';
|
||||
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const { spawnSync } = require('node:child_process');
|
||||
|
||||
const ROOT = path.join(__dirname, '..');
|
||||
const read = (name) => fs.readFileSync(path.join(ROOT, name), 'utf8');
|
||||
const readJson = (name) => JSON.parse(read(name));
|
||||
|
||||
function writeKit(root, { version = '1.1.0', legacy = false } = {}) {
|
||||
const extension = path.join(root, 'chrome-extension');
|
||||
fs.mkdirSync(extension, { recursive: true });
|
||||
fs.writeFileSync(path.join(root, 'AltaCameraProxy.exe'), 'synthetic executable');
|
||||
fs.writeFileSync(path.join(root, 'aware-cam-proxy.exe'), 'synthetic helper');
|
||||
for (const name of ['popup.html', 'popup.js', 'popup.css', 'options.html', 'options.js', 'options.css']) {
|
||||
fs.writeFileSync(path.join(extension, name), legacy && name === 'popup.js' ? 'apt-local-bridge-token' : `'use strict';`);
|
||||
}
|
||||
fs.writeFileSync(path.join(extension, 'manifest.json'), JSON.stringify({
|
||||
manifest_version: 3,
|
||||
name: 'Alta Proxy Tool Bridge',
|
||||
version,
|
||||
}));
|
||||
}
|
||||
|
||||
test('application and extension versions and supported dependencies stay coordinated', () => {
|
||||
const pkg = readJson('package.json');
|
||||
const lock = readJson('package-lock.json');
|
||||
const manifest = readJson('chrome-extension/manifest.json');
|
||||
|
||||
assert.equal(pkg.version, '1.1.0');
|
||||
assert.equal(manifest.version, pkg.version);
|
||||
assert.equal(lock.version, pkg.version);
|
||||
assert.equal(lock.packages[''].version, pkg.version);
|
||||
assert.deepEqual(pkg.dependencies, { axios: '1.19.0' });
|
||||
assert.deepEqual(pkg.devDependencies, {
|
||||
electron: '43.4.1',
|
||||
'electron-builder': '26.15.3',
|
||||
});
|
||||
for (const removed of ['crypto-js', 'electron-packager']) {
|
||||
assert.equal(pkg.dependencies?.[removed], undefined);
|
||||
assert.equal(pkg.devDependencies?.[removed], undefined);
|
||||
}
|
||||
});
|
||||
|
||||
test('build remains Windows portable without misleading signing overrides', () => {
|
||||
const pkg = readJson('package.json');
|
||||
assert.equal(pkg.build.win.target, 'portable');
|
||||
assert.equal(pkg.build.portable.artifactName, 'AltaCameraProxy-${version}-portable.exe');
|
||||
assert.equal(Object.hasOwn(pkg.build.win, 'signAndEditExecutable'), false);
|
||||
assert.equal(Object.hasOwn(pkg.build, 'forceCodeSigning'), false);
|
||||
assert.equal(pkg.scripts['audit:prod'], 'npm audit --omit=dev --audit-level=low');
|
||||
assert.match(pkg.scripts.check, /verify-kit/);
|
||||
});
|
||||
|
||||
test('GitPeji CI is build-only and runs all hardening gates on Node 22', () => {
|
||||
const workflow = read('.gitea/workflows/ci.yml');
|
||||
assert.match(workflow, /actions\/checkout@v4/);
|
||||
assert.match(workflow, /actions\/setup-node@v4/);
|
||||
assert.match(workflow, /node-version:\s*['"]?22['"]?/);
|
||||
for (const command of ['npm ci', 'npm run check', 'npm run audit:prod', 'npm run build-test']) {
|
||||
assert.ok(workflow.includes(command), `missing CI command: ${command}`);
|
||||
}
|
||||
assert.match(workflow, /verify-kit\.js\s+--build\s+dist\/win-unpacked/);
|
||||
assert.doesNotMatch(workflow, /npm\s+(?:publish|run\s+(?:release|deploy))|actions\/(?:upload-release|deploy)|git\s+push/i);
|
||||
assert.doesNotMatch(workflow, /secrets\./);
|
||||
});
|
||||
|
||||
test('kit verifier accepts a complete synthetic kit and rejects version drift and legacy strings', () => {
|
||||
const valid = fs.mkdtempSync(path.join(os.tmpdir(), 'apt-kit-valid-'));
|
||||
writeKit(valid);
|
||||
const accepted = spawnSync(process.execPath, ['scripts/verify-kit.js', valid], { cwd: ROOT, encoding: 'utf8' });
|
||||
assert.equal(accepted.status, 0, accepted.stderr || accepted.stdout);
|
||||
|
||||
const drifted = fs.mkdtempSync(path.join(os.tmpdir(), 'apt-kit-drift-'));
|
||||
writeKit(drifted, { version: '1.0.0' });
|
||||
const rejectedDrift = spawnSync(process.execPath, ['scripts/verify-kit.js', drifted], { cwd: ROOT, encoding: 'utf8' });
|
||||
assert.notEqual(rejectedDrift.status, 0);
|
||||
assert.match(rejectedDrift.stderr, /version/i);
|
||||
|
||||
const legacy = fs.mkdtempSync(path.join(os.tmpdir(), 'apt-kit-legacy-'));
|
||||
writeKit(legacy, { legacy: true });
|
||||
const rejectedLegacy = spawnSync(process.execPath, ['scripts/verify-kit.js', legacy], { cwd: ROOT, encoding: 'utf8' });
|
||||
assert.notEqual(rejectedLegacy.status, 0);
|
||||
assert.match(rejectedLegacy.stderr, /forbidden/i);
|
||||
});
|
||||
|
||||
test('kit verifier rejects unsafe relative archive paths', () => {
|
||||
const { normalizeRelative } = require('../scripts/verify-kit');
|
||||
for (const unsafe of ['../escape', 'folder/../../escape', '/absolute', 'C:\\absolute']) {
|
||||
assert.throws(() => normalizeRelative(unsafe), /unsafe/i);
|
||||
}
|
||||
assert.equal(normalizeRelative('chrome-extension\\manifest.json'), 'chrome-extension/manifest.json');
|
||||
});
|
||||
Reference in New Issue
Block a user