Remove Docker Hub registry support and references
Eliminates support for Docker Hub ('cr-docker') from platform configuration, request handling logic, and all related tests. Updates terminology and test cases to use other container registries (e.g., GHCR) and removes Docker-specific path and scope transformations. This simplifies container registry support and focuses on currently supported platforms.
This commit is contained in:
1 parent
2a1f5c820c
commit
6d0fc73d20
4 files changed
+61
-164
No files matched your search
@@ -13,7 +13,6 @@ export const PLATFORMS = {
|
||||
'conda-community': 'https://conda.anaconda.org',
|
||||
|
||||
// Container Registries
|
||||
'cr-docker': 'https://registry-1.docker.io',
|
||||
'cr-quay': 'https://quay.io',
|
||||
'cr-gcr': 'https://gcr.io',
|
||||
'cr-mcr': 'https://mcr.microsoft.com',
|
||||
|
||||
+17
-74
@@ -34,13 +34,13 @@ class PerformanceMonitor {
|
||||
}
|
||||
|
||||
/**
|
||||
* Detects if a request is a Docker registry operation
|
||||
* Detects if a request is a container registry operation
|
||||
* @param {Request} request - The incoming request object
|
||||
* @param {URL} url - Parsed URL object
|
||||
* @returns {boolean} True if this is a Docker registry operation
|
||||
* @returns {boolean} True if this is a container registry operation
|
||||
*/
|
||||
function isDockerRequest(request, url) {
|
||||
// Check for Docker registry API endpoints
|
||||
// Check for container registry API endpoints
|
||||
if (url.pathname.startsWith('/v2/')) {
|
||||
return true;
|
||||
}
|
||||
@@ -160,7 +160,7 @@ function parseAuthenticate(authenticateStr) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches authentication token from Docker registry
|
||||
* Fetches authentication token from container registry
|
||||
* @param {{realm: string, service: string}} wwwAuthenticate - Authentication info
|
||||
* @param {string} scope - The scope for the token
|
||||
* @param {string} authorization - Authorization header value
|
||||
@@ -182,16 +182,13 @@ async function fetchToken(wwwAuthenticate, scope, authorization) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates unauthorized response for Docker registry
|
||||
* Creates unauthorized response for container registry
|
||||
* @param {URL} url - Request URL
|
||||
* @returns {Response} Unauthorized response
|
||||
*/
|
||||
function responseUnauthorized(url) {
|
||||
const headers = new Headers();
|
||||
headers.set(
|
||||
'WWW-Authenticate',
|
||||
`Bearer realm="https://${url.hostname}/v2/auth",service="Xget"`
|
||||
);
|
||||
headers.set('WWW-Authenticate', `Bearer realm="https://${url.hostname}/v2/auth",service="Xget"`);
|
||||
return new Response(JSON.stringify({ message: 'UNAUTHORIZED' }), {
|
||||
status: 401,
|
||||
headers: headers
|
||||
@@ -240,17 +237,17 @@ async function handleRequest(request, env, ctx) {
|
||||
let platform;
|
||||
let effectivePath = url.pathname;
|
||||
|
||||
// Handle Docker registry paths specially
|
||||
// Handle container registry paths specially
|
||||
if (isDocker) {
|
||||
// For Docker requests (excluding version check which is handled above),
|
||||
// check if they have /cr/ prefix
|
||||
if (!url.pathname.startsWith('/cr/') && !url.pathname.startsWith('/v2/cr/')) {
|
||||
return new Response('Docker registry requests must use /cr/ prefix', {
|
||||
return new Response('container registry requests must use /cr/ prefix', {
|
||||
status: 400,
|
||||
headers: addSecurityHeaders(new Headers())
|
||||
});
|
||||
}
|
||||
// Remove /v2 from the path for Docker registry API consistency if present
|
||||
// Remove /v2 from the path for container registry API consistency if present
|
||||
effectivePath = url.pathname.replace(/^\/v2/, '');
|
||||
}
|
||||
|
||||
@@ -277,27 +274,15 @@ async function handleRequest(request, env, ctx) {
|
||||
// Transform URL based on platform using unified logic
|
||||
const targetPath = transformPath(effectivePath, platform);
|
||||
|
||||
// For Docker registries, ensure we add the /v2 prefix for the Docker API
|
||||
// For container registries, ensure we add the /v2 prefix for the Docker API
|
||||
let finalTargetPath;
|
||||
if (platform.startsWith('cr-')) {
|
||||
finalTargetPath = `/v2${targetPath}`;
|
||||
|
||||
// Handle Docker Hub library image path transformation
|
||||
// Example: /v2/nginx/manifests/latest => /v2/library/nginx/manifests/latest
|
||||
if (platform === 'cr-docker') {
|
||||
const pathParts = finalTargetPath.split('/');
|
||||
// Check if this is a library image path (no namespace)
|
||||
if (pathParts.length === 5 && (pathParts[3] === 'manifests' || pathParts[3] === 'blobs')) {
|
||||
pathParts.splice(2, 0, 'library');
|
||||
finalTargetPath = pathParts.join('/');
|
||||
}
|
||||
}
|
||||
} else {
|
||||
finalTargetPath = targetPath;
|
||||
}
|
||||
|
||||
const targetUrl = `${CONFIG.PLATFORMS[platform]}${finalTargetPath}${url.search}`;
|
||||
const isDockerHub = platform === 'cr-docker';
|
||||
const authorization = request.headers.get('Authorization');
|
||||
|
||||
// Handle Docker authentication
|
||||
@@ -316,29 +301,9 @@ async function handleRequest(request, env, ctx) {
|
||||
}
|
||||
const wwwAuthenticate = parseAuthenticate(authenticateStr);
|
||||
let scope = url.searchParams.get('scope');
|
||||
// autocomplete repo part into scope for DockerHub library images
|
||||
// Example: repository:busybox:pull => repository:library/busybox:pull
|
||||
if (scope && isDockerHub) {
|
||||
let scopeParts = scope.split(':');
|
||||
if (scopeParts.length == 3 && !scopeParts[1].includes('/')) {
|
||||
scopeParts[1] = 'library/' + scopeParts[1];
|
||||
scope = scopeParts.join(':');
|
||||
}
|
||||
}
|
||||
return await fetchToken(wwwAuthenticate, scope || '', authorization || '');
|
||||
}
|
||||
|
||||
// Handle DockerHub library image redirects before making the request
|
||||
if (isDocker && isDockerHub) {
|
||||
const pathParts = url.pathname.split('/');
|
||||
if (pathParts.length == 5) {
|
||||
pathParts.splice(2, 0, 'library');
|
||||
const redirectUrl = new URL(url);
|
||||
redirectUrl.pathname = pathParts.join('/');
|
||||
return Response.redirect(redirectUrl, 301);
|
||||
}
|
||||
}
|
||||
|
||||
// Check if this is a Git operation
|
||||
const isGit = isGitRequest(request, url);
|
||||
|
||||
@@ -360,8 +325,7 @@ async function handleRequest(request, env, ctx) {
|
||||
const fetchOptions = {
|
||||
method: request.method,
|
||||
headers: new Headers(),
|
||||
// For Docker Hub, use manual redirect to handle blob redirects properly
|
||||
redirect: isDockerHub ? 'manual' : 'follow'
|
||||
redirect: 'follow'
|
||||
};
|
||||
|
||||
// Add body for POST/PUT/PATCH requests (Git/Docker operations)
|
||||
@@ -449,41 +413,29 @@ async function handleRequest(request, env, ctx) {
|
||||
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
// Handle Docker Hub blob redirects manually
|
||||
if (isDocker && isDockerHub && response.status === 307) {
|
||||
const location = response.headers.get('Location');
|
||||
if (location) {
|
||||
const redirectResponse = await fetch(location, {
|
||||
method: 'GET',
|
||||
redirect: 'follow'
|
||||
});
|
||||
response = redirectResponse;
|
||||
}
|
||||
}
|
||||
|
||||
if (response.ok || response.status === 206) {
|
||||
monitor.mark('success');
|
||||
break;
|
||||
}
|
||||
|
||||
// For Docker registry, handle authentication challenges more intelligently
|
||||
// For container registry, handle authentication challenges more intelligently
|
||||
if (isDocker && response.status === 401) {
|
||||
monitor.mark('docker_auth_challenge');
|
||||
|
||||
// For Docker registries, first check if we can get a token without credentials
|
||||
// For container registries, first check if we can get a token without credentials
|
||||
// This allows access to public repositories
|
||||
const authenticateStr = response.headers.get('WWW-Authenticate');
|
||||
if (authenticateStr) {
|
||||
try {
|
||||
const wwwAuthenticate = parseAuthenticate(authenticateStr);
|
||||
|
||||
// Infer scope from the request path for Docker registry requests
|
||||
// Infer scope from the request path for container registry requests
|
||||
let scope = '';
|
||||
const pathParts = url.pathname.split('/');
|
||||
if (pathParts.length >= 4 && pathParts[1] === 'v2') {
|
||||
// Extract repository name from path like /v2/cr/docker/library/nginx/manifests/latest
|
||||
// Extract repository name from path like /v2/cr/ghcr/library/nginx/manifests/latest
|
||||
// Remove /v2 and platform prefix to get the repo path
|
||||
const repoPath = pathParts.slice(4).join('/'); // Skip /v2/cr/docker
|
||||
const repoPath = pathParts.slice(4).join('/'); // Skip /v2/cr/[registry]
|
||||
const repoParts = repoPath.split('/');
|
||||
if (repoParts.length >= 1) {
|
||||
const repoName = repoParts.slice(0, -2).join('/'); // Remove /manifests/tag or /blobs/sha
|
||||
@@ -493,15 +445,6 @@ async function handleRequest(request, env, ctx) {
|
||||
}
|
||||
}
|
||||
|
||||
// For Docker Hub library images, adjust the scope
|
||||
if (isDockerHub && scope) {
|
||||
let scopeParts = scope.split(':');
|
||||
if (scopeParts.length === 3 && !scopeParts[1].includes('/')) {
|
||||
scopeParts[1] = 'library/' + scopeParts[1];
|
||||
scope = scopeParts.join(':');
|
||||
}
|
||||
}
|
||||
|
||||
// Try to get a token for public access (without authorization)
|
||||
const tokenResponse = await fetchToken(wwwAuthenticate, scope || '', '');
|
||||
if (tokenResponse.ok) {
|
||||
@@ -578,7 +521,7 @@ async function handleRequest(request, env, ctx) {
|
||||
if (isDocker && response.status === 401) {
|
||||
const errorText = await response.text().catch(() => '');
|
||||
return new Response(
|
||||
`Authentication required for this Docker registry resource. This may be a private repository. Original error: ${errorText}`,
|
||||
`Authentication required for this container registry resource. This may be a private repository. Original error: ${errorText}`,
|
||||
{
|
||||
status: 401,
|
||||
headers: addSecurityHeaders(new Headers())
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { SELF } from 'cloudflare:test';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
describe('Docker Registry Support', () => {
|
||||
describe('Container Registry Support', () => {
|
||||
describe('Docker API Version Check', () => {
|
||||
it('should handle /v2/ endpoint correctly', async () => {
|
||||
const response = await SELF.fetch('https://example.com/v2/');
|
||||
@@ -22,23 +22,7 @@ describe('Docker Registry Support', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('Docker Registry URL Transformation', () => {
|
||||
it('should handle Docker Hub manifest requests', async () => {
|
||||
const testUrl = 'https://example.com/cr/docker/v2/library/nginx/manifests/latest';
|
||||
const response = await SELF.fetch(testUrl, { method: 'HEAD' });
|
||||
|
||||
// Should attempt to proxy to Docker Hub
|
||||
expect(response.status).not.toBe(400);
|
||||
});
|
||||
|
||||
it('should handle Docker Hub without library prefix', async () => {
|
||||
const testUrl = 'https://example.com/cr/docker/v2/nginx/manifests/latest';
|
||||
const response = await SELF.fetch(testUrl, { method: 'HEAD' });
|
||||
|
||||
// Should attempt to proxy to Docker Hub with library prefix added
|
||||
expect(response.status).not.toBe(400);
|
||||
});
|
||||
|
||||
describe('Container Registry URL Transformation', () => {
|
||||
it('should handle Quay.io registry requests', async () => {
|
||||
const testUrl = 'https://example.com/cr/quay/v2/bitnami/nginx/manifests/latest';
|
||||
const response = await SELF.fetch(testUrl, { method: 'HEAD' });
|
||||
@@ -67,7 +51,7 @@ describe('Docker Registry Support', () => {
|
||||
describe('Docker Authentication', () => {
|
||||
it('should pass through 401 authentication challenges', async () => {
|
||||
// This test simulates an upstream 401 response which should be passed through
|
||||
const testUrl = 'https://example.com/cr/docker/v2/private/repo/manifests/latest';
|
||||
const testUrl = 'https://example.com/cr/ghcr/v2/private/repo/manifests/latest';
|
||||
const response = await SELF.fetch(testUrl, {
|
||||
headers: {
|
||||
Accept: 'application/vnd.docker.distribution.manifest.v2+json'
|
||||
@@ -81,8 +65,8 @@ describe('Docker Registry Support', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('should handle Docker registry token requests', async () => {
|
||||
const testUrl = 'https://example.com/cr/docker/v2/auth';
|
||||
it('should handle container registry token requests', async () => {
|
||||
const testUrl = 'https://example.com/cr/ghcr/v2/auth';
|
||||
const response = await SELF.fetch(testUrl, {
|
||||
headers: {
|
||||
Authorization: 'Basic dGVzdDp0ZXN0'
|
||||
@@ -97,7 +81,7 @@ describe('Docker Registry Support', () => {
|
||||
describe('Docker Request Detection', () => {
|
||||
it('should detect Docker requests by path', async () => {
|
||||
const response = await SELF.fetch(
|
||||
'https://example.com/cr/docker/v2/library/nginx/manifests/latest',
|
||||
'https://example.com/cr/ghcr/v2/library/nginx/manifests/latest',
|
||||
{
|
||||
method: 'GET'
|
||||
}
|
||||
@@ -108,28 +92,22 @@ describe('Docker Registry Support', () => {
|
||||
});
|
||||
|
||||
it('should detect Docker requests by Accept header', async () => {
|
||||
const response = await SELF.fetch(
|
||||
'https://example.com/cr/docker/v2/test/repo/manifests/tag',
|
||||
{
|
||||
headers: {
|
||||
Accept: 'application/vnd.docker.distribution.manifest.v2+json'
|
||||
}
|
||||
const response = await SELF.fetch('https://example.com/cr/ghcr/v2/test/repo/manifests/tag', {
|
||||
headers: {
|
||||
Accept: 'application/vnd.docker.distribution.manifest.v2+json'
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
// Should not reject with 405 (method not allowed)
|
||||
expect(response.status).not.toBe(405);
|
||||
});
|
||||
|
||||
it('should detect Docker requests by User-Agent', async () => {
|
||||
const response = await SELF.fetch(
|
||||
'https://example.com/cr/docker/v2/test/repo/manifests/tag',
|
||||
{
|
||||
headers: {
|
||||
'User-Agent': 'docker/20.10.7'
|
||||
}
|
||||
const response = await SELF.fetch('https://example.com/cr/ghcr/v2/test/repo/manifests/tag', {
|
||||
headers: {
|
||||
'User-Agent': 'docker/20.10.7'
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
// Should not reject with 405 (method not allowed)
|
||||
expect(response.status).not.toBe(405);
|
||||
@@ -139,7 +117,7 @@ describe('Docker Registry Support', () => {
|
||||
describe('Docker HTTP Methods', () => {
|
||||
it('should allow GET for manifest requests', async () => {
|
||||
const response = await SELF.fetch(
|
||||
'https://example.com/cr/docker/v2/library/nginx/manifests/latest',
|
||||
'https://example.com/cr/ghcr/v2/library/nginx/manifests/latest',
|
||||
{
|
||||
method: 'GET'
|
||||
}
|
||||
@@ -150,7 +128,7 @@ describe('Docker Registry Support', () => {
|
||||
|
||||
it('should allow HEAD for manifest requests', async () => {
|
||||
const response = await SELF.fetch(
|
||||
'https://example.com/cr/docker/v2/library/nginx/manifests/latest',
|
||||
'https://example.com/cr/ghcr/v2/library/nginx/manifests/latest',
|
||||
{
|
||||
method: 'HEAD'
|
||||
}
|
||||
@@ -160,63 +138,48 @@ describe('Docker Registry Support', () => {
|
||||
});
|
||||
|
||||
it('should allow PUT for manifest uploads', async () => {
|
||||
const response = await SELF.fetch(
|
||||
'https://example.com/cr/docker/v2/test/repo/manifests/tag',
|
||||
{
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/vnd.docker.distribution.manifest.v2+json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
schemaVersion: 2,
|
||||
mediaType: 'application/vnd.docker.distribution.manifest.v2+json'
|
||||
})
|
||||
}
|
||||
);
|
||||
const response = await SELF.fetch('https://example.com/cr/ghcr/v2/test/repo/manifests/tag', {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/vnd.docker.distribution.manifest.v2+json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
schemaVersion: 2,
|
||||
mediaType: 'application/vnd.docker.distribution.manifest.v2+json'
|
||||
})
|
||||
});
|
||||
|
||||
expect(response.status).not.toBe(405);
|
||||
});
|
||||
|
||||
it('should allow POST for blob uploads', async () => {
|
||||
const response = await SELF.fetch(
|
||||
'https://example.com/cr/docker/v2/test/repo/blobs/uploads/',
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/octet-stream'
|
||||
}
|
||||
const response = await SELF.fetch('https://example.com/cr/ghcr/v2/test/repo/blobs/uploads/', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/octet-stream'
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
expect(response.status).not.toBe(405);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Docker Registry Error Handling', () => {
|
||||
describe('Container Registry Error Handling', () => {
|
||||
it('should reject non-cr prefixed Docker requests', async () => {
|
||||
const response = await SELF.fetch('https://example.com/v2/library/nginx/manifests/latest');
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(await response.text()).toContain('Docker registry requests must use /cr/ prefix');
|
||||
});
|
||||
|
||||
it('should handle Docker Hub blob redirects', async () => {
|
||||
// This test would verify that 307 redirects from Docker Hub are handled
|
||||
const testUrl = 'https://example.com/cr/docker/v2/library/nginx/blobs/sha256:abc123';
|
||||
const response = await SELF.fetch(testUrl);
|
||||
|
||||
// The request should be processed (not fail with a redirect error)
|
||||
expect([200, 301, 302, 307, 404]).toContain(response.status);
|
||||
expect(await response.text()).toContain('container registry requests must use /cr/ prefix');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Docker Registry Headers', () => {
|
||||
it('should preserve Docker-specific headers', async () => {
|
||||
describe('Container Registry Headers', () => {
|
||||
it('should preserve container-specific headers', async () => {
|
||||
const response = await SELF.fetch(
|
||||
'https://example.com/cr/docker/v2/library/nginx/manifests/latest',
|
||||
'https://example.com/cr/ghcr/v2/library/nginx/manifests/latest',
|
||||
{
|
||||
headers: {
|
||||
'Docker-Content-Digest': 'sha256:abc123',
|
||||
'Container-Content-Digest': 'sha256:abc123',
|
||||
Accept: 'application/vnd.docker.distribution.manifest.v2+json',
|
||||
Authorization: 'Bearer token123'
|
||||
}
|
||||
@@ -227,12 +190,12 @@ describe('Docker Registry Support', () => {
|
||||
expect(response.status).not.toBe(400);
|
||||
});
|
||||
|
||||
it('should not cache Docker registry responses', async () => {
|
||||
const testUrl = 'https://example.com/cr/docker/v2/library/nginx/manifests/latest';
|
||||
it('should not cache container registry responses', async () => {
|
||||
const testUrl = 'https://example.com/cr/ghcr/v2/library/nginx/manifests/latest';
|
||||
|
||||
const response = await SELF.fetch(testUrl);
|
||||
|
||||
// Docker registry responses should not be cached
|
||||
// Container registry responses should not be cached
|
||||
const cacheControl = response.headers.get('Cache-Control');
|
||||
if (cacheControl) {
|
||||
expect(cacheControl).not.toContain('max-age=1800');
|
||||
@@ -242,7 +205,6 @@ describe('Docker Registry Support', () => {
|
||||
|
||||
describe('Container Registry Platform Support', () => {
|
||||
const containerRegistries = [
|
||||
{ name: 'Docker Hub', prefix: 'cr/docker', expectedStatus: [200, 301, 302, 401, 404] },
|
||||
{ name: 'Quay.io', prefix: 'cr/quay', expectedStatus: [200, 301, 302, 401, 404] },
|
||||
{
|
||||
name: 'Google Container Registry',
|
||||
+4
-11
@@ -84,10 +84,6 @@ describe('Platform Configuration', () => {
|
||||
});
|
||||
|
||||
it('should transform container registry paths correctly', () => {
|
||||
expect(transformPath('/cr/docker/v2/library/nginx/manifests/latest', 'cr-docker')).toBe(
|
||||
'/v2/library/nginx/manifests/latest'
|
||||
);
|
||||
|
||||
expect(transformPath('/cr/ghcr/v2/microsoft/vscode/manifests/latest', 'cr-ghcr')).toBe(
|
||||
'/v2/microsoft/vscode/manifests/latest'
|
||||
);
|
||||
@@ -129,7 +125,6 @@ describe('Platform Configuration', () => {
|
||||
});
|
||||
|
||||
it('should have correct container registry base URLs', () => {
|
||||
expect(PLATFORMS['cr-docker']).toBe('https://registry-1.docker.io');
|
||||
expect(PLATFORMS['cr-ghcr']).toBe('https://ghcr.io');
|
||||
expect(PLATFORMS['cr-gcr']).toBe('https://gcr.io');
|
||||
expect(PLATFORMS['cr-mcr']).toBe('https://mcr.microsoft.com');
|
||||
@@ -181,19 +176,18 @@ describe('Platform Configuration', () => {
|
||||
});
|
||||
|
||||
it('should handle container registry URL construction', () => {
|
||||
const testPath = '/cr/docker/v2/library/nginx/manifests/latest';
|
||||
const transformedPath = transformPath(testPath, 'cr-docker');
|
||||
const fullUrl = PLATFORMS['cr-docker'] + transformedPath;
|
||||
const testPath = '/cr/ghcr/v2/microsoft/vscode/manifests/latest';
|
||||
const transformedPath = transformPath(testPath, 'cr-ghcr');
|
||||
const fullUrl = PLATFORMS['cr-ghcr'] + transformedPath;
|
||||
|
||||
expect(() => new URL(fullUrl)).not.toThrow();
|
||||
expect(fullUrl).toBe('https://registry-1.docker.io/v2/library/nginx/manifests/latest');
|
||||
expect(fullUrl).toBe('https://ghcr.io/v2/microsoft/vscode/manifests/latest');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Container Registry Support', () => {
|
||||
it('should have all major container registries defined', () => {
|
||||
const containerRegistries = [
|
||||
'cr-docker',
|
||||
'cr-quay',
|
||||
'cr-gcr',
|
||||
'cr-mcr',
|
||||
@@ -222,7 +216,6 @@ describe('Platform Configuration', () => {
|
||||
|
||||
it('should transform all container registry paths correctly', () => {
|
||||
const containerRegistries = [
|
||||
'cr-docker',
|
||||
'cr-quay',
|
||||
'cr-gcr',
|
||||
'cr-mcr',
|
||||
|
||||
Reference in new issue
Block a user