feat: implement path based operations
This commit is contained in:
11
jest.config.js
Normal file
11
jest.config.js
Normal file
@@ -0,0 +1,11 @@
|
||||
const { createDefaultPreset } = require("ts-jest");
|
||||
|
||||
const tsJestTransformCfg = createDefaultPreset().transform;
|
||||
|
||||
/** @type {import("jest").Config} **/
|
||||
module.exports = {
|
||||
testEnvironment: "node",
|
||||
transform: {
|
||||
...tsJestTransformCfg,
|
||||
},
|
||||
};
|
||||
4159
package-lock.json
generated
4159
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
21
package.json
21
package.json
@@ -5,20 +5,27 @@
|
||||
"main": "dist/index.js",
|
||||
"scripts": {
|
||||
"build": "tsc && cp src/nodes/KDrive/kdrive.svg dist/nodes/KDrive/kdrive.svg",
|
||||
"test": "echo \"Error: no test specified\" && exit 1"
|
||||
"test": "jest"
|
||||
},
|
||||
"keywords": ["n8n", "kdrive", "infomaniak"],
|
||||
"keywords": [
|
||||
"n8n",
|
||||
"kdrive",
|
||||
"infomaniak"
|
||||
],
|
||||
"author": "",
|
||||
"license": "LGPL-3.0",
|
||||
"dependencies": {
|
||||
"@types/node": "^20.0.0",
|
||||
"@types/request": "^2.48.12",
|
||||
"n8n-workflow": "^1.0.0",
|
||||
"request": "^2.88.2",
|
||||
"@types/request": "^2.48.12"
|
||||
"request": "^2.88.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.0.0",
|
||||
"@types/node": "^20.0.0"
|
||||
"@types/jest": "^30.0.0",
|
||||
"@types/node": "^20.0.0",
|
||||
"jest": "^30.2.0",
|
||||
"ts-jest": "^29.4.6",
|
||||
"typescript": "^5.0.0"
|
||||
},
|
||||
"n8n": {
|
||||
"nodes": [
|
||||
@@ -28,4 +35,4 @@
|
||||
"KDrive"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,21 @@ import {
|
||||
|
||||
import * as request from 'request';
|
||||
|
||||
/**
|
||||
* Interface for path resolution cache
|
||||
*/
|
||||
interface PathCacheEntry {
|
||||
fileId: string;
|
||||
fileName: string;
|
||||
parentId: string;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Path cache to store resolved paths for performance
|
||||
*/
|
||||
const pathCache: Record<string, PathCacheEntry> = {};
|
||||
|
||||
/**
|
||||
* Make an API request to kDrive API
|
||||
*/
|
||||
@@ -99,4 +114,180 @@ export function handleApiError(this: IExecuteFunctions, error: any): void {
|
||||
});
|
||||
|
||||
throw new Error(`kDrive API Error: ${errorMessage}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear path cache
|
||||
*/
|
||||
export function clearPathCache(): void {
|
||||
Object.keys(pathCache).forEach(key => delete pathCache[key]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize path - convert to absolute path and handle path separators
|
||||
*/
|
||||
export function normalizePath(path: string): string {
|
||||
// Replace Windows-style backslashes with forward slashes
|
||||
let normalized = path.replace(/\\/g, '/');
|
||||
|
||||
// Remove leading/trailing slashes
|
||||
normalized = normalized.replace(/^\/+|\/+$/g, '');
|
||||
|
||||
// Handle empty path (root)
|
||||
if (!normalized) {
|
||||
return 'root';
|
||||
}
|
||||
|
||||
// Collapse multiple consecutive slashes
|
||||
normalized = normalized.replace(/\/+/g, '/');
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse path into components
|
||||
*/
|
||||
export function parsePath(path: string): string[] {
|
||||
const normalized = normalizePath(path);
|
||||
|
||||
if (normalized === 'root') {
|
||||
return ['root'];
|
||||
}
|
||||
|
||||
// Split by slash and filter out empty segments
|
||||
return normalized.split('/').filter(component => component.length > 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get file info by ID
|
||||
*/
|
||||
export async function getFileInfoById(
|
||||
this: IExecuteFunctions,
|
||||
fileId: string,
|
||||
driveId: string,
|
||||
credentials: IDataObject
|
||||
): Promise<any> {
|
||||
if (fileId === 'root') {
|
||||
return {
|
||||
id: 'root',
|
||||
name: 'root',
|
||||
type: 'directory',
|
||||
parent_id: null
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await kdriveApiRequest.call(
|
||||
this,
|
||||
'GET',
|
||||
`/3/drive/${driveId}/files/${fileId}`,
|
||||
{},
|
||||
credentials
|
||||
);
|
||||
return response;
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to get file info for ID ${fileId}: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve path to file ID
|
||||
*/
|
||||
export async function resolvePathToId(
|
||||
this: IExecuteFunctions,
|
||||
path: string,
|
||||
driveId: string,
|
||||
credentials: IDataObject
|
||||
): Promise<string> {
|
||||
const normalizedPath = normalizePath(path);
|
||||
|
||||
// Check cache first
|
||||
if (pathCache[normalizedPath]) {
|
||||
return pathCache[normalizedPath].fileId;
|
||||
}
|
||||
|
||||
// Handle root path
|
||||
if (normalizedPath === 'root') {
|
||||
return 'root';
|
||||
}
|
||||
|
||||
const pathComponents = parsePath(normalizedPath);
|
||||
let currentId = 'root';
|
||||
|
||||
// Traverse path component by component
|
||||
for (const component of pathComponents) {
|
||||
// Get contents of current directory
|
||||
const endpoint = currentId === 'root'
|
||||
? `/3/drive/${driveId}/files/1/files`
|
||||
: `/3/drive/${driveId}/files/${currentId}/files`;
|
||||
|
||||
const response = await kdriveApiRequest.call(this, 'GET', endpoint, {}, credentials);
|
||||
|
||||
// Find the component in the current directory
|
||||
const foundItem = response.items.find((item: any) =>
|
||||
item.name === component && item.type === 'directory'
|
||||
);
|
||||
|
||||
if (!foundItem) {
|
||||
throw new Error(`Path component not found: ${component} in ${currentId}`);
|
||||
}
|
||||
|
||||
currentId = foundItem.id;
|
||||
}
|
||||
|
||||
// Cache the resolved path
|
||||
pathCache[normalizedPath] = {
|
||||
fileId: currentId,
|
||||
fileName: pathComponents[pathComponents.length - 1],
|
||||
parentId: pathComponents.length === 1 ? 'root' :
|
||||
pathCache[parsePath(normalizedPath).slice(0, -1).join('/')]?.fileId || 'root',
|
||||
timestamp: Date.now()
|
||||
};
|
||||
|
||||
return currentId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve file ID to path
|
||||
*/
|
||||
export async function resolveIdToPath(
|
||||
this: IExecuteFunctions,
|
||||
fileId: string,
|
||||
driveId: string,
|
||||
credentials: IDataObject
|
||||
): Promise<string> {
|
||||
if (fileId === 'root') {
|
||||
return 'root';
|
||||
}
|
||||
|
||||
// Check if we have this in cache
|
||||
const cachedEntry = Object.values(pathCache).find(entry => entry.fileId === fileId);
|
||||
if (cachedEntry) {
|
||||
return Object.keys(pathCache).find(key => pathCache[key].fileId === fileId) || fileId;
|
||||
}
|
||||
|
||||
// Get file info
|
||||
const fileInfo = await getFileInfoById.call(this, fileId, driveId, credentials);
|
||||
|
||||
if (!fileInfo.parent_id) {
|
||||
return fileInfo.name; // Root level file
|
||||
}
|
||||
|
||||
// Recursively get parent path
|
||||
const parentPath = await resolveIdToPath.call(this, fileInfo.parent_id, driveId, credentials);
|
||||
|
||||
// Construct full path
|
||||
const fullPath = parentPath === 'root'
|
||||
? fileInfo.name
|
||||
: `${parentPath}/${fileInfo.name}`;
|
||||
|
||||
// Cache the result
|
||||
pathCache[fullPath] = {
|
||||
fileId: fileInfo.id,
|
||||
fileName: fileInfo.name,
|
||||
parentId: fileInfo.parent_id,
|
||||
timestamp: Date.now()
|
||||
};
|
||||
|
||||
return fullPath;
|
||||
}
|
||||
@@ -9,6 +9,9 @@ import {
|
||||
import {
|
||||
kdriveApiRequest,
|
||||
handleApiError,
|
||||
resolvePathToId,
|
||||
parsePath,
|
||||
normalizePath,
|
||||
} from './GenericFunctions';
|
||||
|
||||
export class KDrive implements INodeType {
|
||||
@@ -80,7 +83,7 @@ export class KDrive implements INodeType {
|
||||
resource: ['file'],
|
||||
},
|
||||
},
|
||||
options: [
|
||||
options: [
|
||||
{
|
||||
name: 'List Files',
|
||||
value: 'listFiles',
|
||||
@@ -91,21 +94,41 @@ export class KDrive implements INodeType {
|
||||
value: 'getFileInfo',
|
||||
description: 'Get information about a file',
|
||||
},
|
||||
{
|
||||
name: 'Get File Info by Path',
|
||||
value: 'getFileInfoByPath',
|
||||
description: 'Get information about a file using path',
|
||||
},
|
||||
{
|
||||
name: 'Upload File',
|
||||
value: 'uploadFile',
|
||||
description: 'Upload a file',
|
||||
},
|
||||
{
|
||||
name: 'Upload File by Path',
|
||||
value: 'uploadFileByPath',
|
||||
description: 'Upload a file to specific path',
|
||||
},
|
||||
{
|
||||
name: 'Download File',
|
||||
value: 'downloadFile',
|
||||
description: 'Download a file',
|
||||
},
|
||||
{
|
||||
name: 'Download File by Path',
|
||||
value: 'downloadFileByPath',
|
||||
description: 'Download a file using path',
|
||||
},
|
||||
{
|
||||
name: 'Delete File',
|
||||
value: 'deleteFile',
|
||||
description: 'Delete a file (move to trash)',
|
||||
},
|
||||
{
|
||||
name: 'Delete File by Path',
|
||||
value: 'deleteFileByPath',
|
||||
description: 'Delete a file using path',
|
||||
},
|
||||
{
|
||||
name: 'Search Files',
|
||||
value: 'searchFiles',
|
||||
@@ -116,6 +139,11 @@ export class KDrive implements INodeType {
|
||||
value: 'getFileVersions',
|
||||
description: 'Get file versions',
|
||||
},
|
||||
{
|
||||
name: 'Get File Versions by Path',
|
||||
value: 'getFileVersionsByPath',
|
||||
description: 'Get file versions using path',
|
||||
},
|
||||
],
|
||||
default: 'listFiles',
|
||||
},
|
||||
@@ -166,12 +194,52 @@ export class KDrive implements INodeType {
|
||||
default: 'root',
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['file'],
|
||||
operation: ['listFiles'],
|
||||
resource: ['directory'],
|
||||
operation: ['createDirectory', 'createFile'],
|
||||
},
|
||||
},
|
||||
description: 'The ID of the parent directory (use "root" for root directory)',
|
||||
},
|
||||
// Path-based parameters for file operations
|
||||
{
|
||||
displayName: 'File Path',
|
||||
name: 'filePath',
|
||||
type: 'string',
|
||||
default: '',
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['file'],
|
||||
operation: ['getFileInfoByPath', 'downloadFileByPath', 'deleteFileByPath', 'getFileVersionsByPath'],
|
||||
},
|
||||
},
|
||||
description: 'The path to the file (e.g., "documents/report.pdf")',
|
||||
},
|
||||
{
|
||||
displayName: 'Directory Path',
|
||||
name: 'directoryPath',
|
||||
type: 'string',
|
||||
default: 'root',
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['file'],
|
||||
operation: ['listFiles'],
|
||||
},
|
||||
},
|
||||
description: 'The path to the directory (use "root" for root, e.g., "documents/project")',
|
||||
},
|
||||
{
|
||||
displayName: 'Upload Path',
|
||||
name: 'uploadPath',
|
||||
type: 'string',
|
||||
default: '',
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['file'],
|
||||
operation: ['uploadFileByPath'],
|
||||
},
|
||||
},
|
||||
description: 'The full path where to upload the file (e.g., "documents/report.pdf")',
|
||||
},
|
||||
// Directory ID for directory operations
|
||||
{
|
||||
displayName: 'Parent Directory ID',
|
||||
@@ -322,9 +390,18 @@ export class KDrive implements INodeType {
|
||||
const driveId = credentials.driveId as string;
|
||||
|
||||
if (operation === 'listFiles') {
|
||||
const parentDirectoryId = this.getNodeParameter('parentDirectoryId', i) as string;
|
||||
let parentDirectoryId = this.getNodeParameter('parentDirectoryId', i) as string;
|
||||
const directoryPath = this.getNodeParameter('directoryPath', i) as string;
|
||||
|
||||
// If directoryPath is provided, resolve it to an ID
|
||||
if (directoryPath && directoryPath !== 'root') {
|
||||
parentDirectoryId = await resolvePathToId.call(this, directoryPath, driveId, credentials);
|
||||
} else if (directoryPath === 'root') {
|
||||
parentDirectoryId = 'root';
|
||||
}
|
||||
|
||||
const endpoint = parentDirectoryId === 'root'
|
||||
? `/3/drive/${driveId}/files/root/files`
|
||||
? `/3/drive/${driveId}/files/1/files`
|
||||
: `/3/drive/${driveId}/files/${parentDirectoryId}/files`;
|
||||
const response = await kdriveApiRequest.call(this, 'GET', endpoint, {}, credentials);
|
||||
returnData.push({ json: response });
|
||||
@@ -364,10 +441,48 @@ export class KDrive implements INodeType {
|
||||
|
||||
const response = await kdriveApiRequest.call(this, 'GET', `/3/drive/${driveId}/files/search`, params, credentials);
|
||||
returnData.push({ json: response });
|
||||
} else if (operation === 'getFileVersions') {
|
||||
} else if (operation === 'getFileVersions') {
|
||||
const fileId = this.getNodeParameter('fileId', i) as string;
|
||||
const response = await kdriveApiRequest.call(this, 'GET', `/3/drive/${driveId}/files/${fileId}/versions`, {}, credentials);
|
||||
returnData.push({ json: response });
|
||||
} else if (operation === 'getFileInfoByPath') {
|
||||
const filePath = this.getNodeParameter('filePath', i) as string;
|
||||
const fileId = await resolvePathToId.call(this, filePath, driveId, credentials);
|
||||
const response = await kdriveApiRequest.call(this, 'GET', `/3/drive/${driveId}/files/${fileId}`, {}, credentials);
|
||||
returnData.push({ json: response });
|
||||
} else if (operation === 'uploadFileByPath') {
|
||||
const fileData = this.getNodeParameter('fileData', i) as string;
|
||||
const uploadPath = this.getNodeParameter('uploadPath', i) as string;
|
||||
|
||||
// Parse path to get directory and filename
|
||||
const pathComponents = parsePath(uploadPath);
|
||||
const fileName = pathComponents[pathComponents.length - 1];
|
||||
const parentPath = pathComponents.slice(0, -1).join('/') || 'root';
|
||||
const parentDirectoryId = await resolvePathToId.call(this, parentPath, driveId, credentials);
|
||||
|
||||
const formData: IDataObject = {
|
||||
file: fileData,
|
||||
filename: fileName,
|
||||
parent_id: parentDirectoryId === 'root' ? 'root' : parentDirectoryId,
|
||||
};
|
||||
|
||||
const response = await kdriveApiRequest.call(this, 'POST', `/3/drive/${driveId}/upload`, formData, credentials);
|
||||
returnData.push({ json: response });
|
||||
} else if (operation === 'downloadFileByPath') {
|
||||
const filePath = this.getNodeParameter('filePath', i) as string;
|
||||
const fileId = await resolvePathToId.call(this, filePath, driveId, credentials);
|
||||
const response = await kdriveApiRequest.call(this, 'GET', `/2/drive/${driveId}/files/${fileId}/download`, {}, credentials, true);
|
||||
returnData.push({ json: {}, binary: { data: { mimeType: 'application/octet-stream', data: response } } });
|
||||
} else if (operation === 'deleteFileByPath') {
|
||||
const filePath = this.getNodeParameter('filePath', i) as string;
|
||||
const fileId = await resolvePathToId.call(this, filePath, driveId, credentials);
|
||||
const response = await kdriveApiRequest.call(this, 'DELETE', `/2/drive/${driveId}/files/${fileId}`, {}, credentials);
|
||||
returnData.push({ json: response });
|
||||
} else if (operation === 'getFileVersionsByPath') {
|
||||
const filePath = this.getNodeParameter('filePath', i) as string;
|
||||
const fileId = await resolvePathToId.call(this, filePath, driveId, credentials);
|
||||
const response = await kdriveApiRequest.call(this, 'GET', `/3/drive/${driveId}/files/${fileId}/versions`, {}, credentials);
|
||||
returnData.push({ json: response });
|
||||
}
|
||||
} else if (resource === 'directory') {
|
||||
const driveId = credentials.driveId as string;
|
||||
|
||||
153
tests/KDrive.node.test.ts
Normal file
153
tests/KDrive.node.test.ts
Normal file
@@ -0,0 +1,153 @@
|
||||
import { KDrive } from '../src/nodes/KDrive/KDrive.node';
|
||||
import {
|
||||
kdriveApiRequest,
|
||||
handleApiError,
|
||||
resolvePathToId,
|
||||
parsePath,
|
||||
normalizePath,
|
||||
resolveIdToPath,
|
||||
clearPathCache
|
||||
} from '../src/nodes/KDrive/GenericFunctions';
|
||||
|
||||
describe('KDrive Node', () => {
|
||||
let kdriveNode: KDrive;
|
||||
|
||||
beforeEach(() => {
|
||||
kdriveNode = new KDrive();
|
||||
});
|
||||
|
||||
describe('Node Description', () => {
|
||||
it('should have correct node description', () => {
|
||||
expect(kdriveNode.description.displayName).toBe('kDrive');
|
||||
expect(kdriveNode.description.name).toBe('kDrive');
|
||||
expect(kdriveNode.description.group).toContain('transform');
|
||||
expect(kdriveNode.description.version).toBe(1);
|
||||
});
|
||||
|
||||
it('should have required credentials', () => {
|
||||
expect(kdriveNode.description.credentials).toBeDefined();
|
||||
if (kdriveNode.description.credentials) {
|
||||
expect(kdriveNode.description.credentials.length).toBe(1);
|
||||
expect(kdriveNode.description.credentials[0].name).toBe('kDriveApi');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Node Properties', () => {
|
||||
it('should have authentication property', () => {
|
||||
const authProperty = kdriveNode.description.properties.find(p => p.name === 'authentication');
|
||||
expect(authProperty).toBeDefined();
|
||||
if (authProperty) {
|
||||
expect(authProperty.type).toBe('options');
|
||||
}
|
||||
});
|
||||
|
||||
it('should have resource property', () => {
|
||||
const resourceProperty = kdriveNode.description.properties.find(p => p.name === 'resource');
|
||||
expect(resourceProperty).toBeDefined();
|
||||
if (resourceProperty) {
|
||||
expect(resourceProperty.type).toBe('options');
|
||||
}
|
||||
});
|
||||
|
||||
it('should have operation property', () => {
|
||||
const operationProperty = kdriveNode.description.properties.find(p => p.name === 'operation');
|
||||
expect(operationProperty).toBeDefined();
|
||||
if (operationProperty) {
|
||||
expect(operationProperty.type).toBe('options');
|
||||
}
|
||||
});
|
||||
|
||||
it('should have path-based operation options', () => {
|
||||
const operationProperty = kdriveNode.description.properties.find(p => p.name === 'operation') as any;
|
||||
expect(operationProperty).toBeDefined();
|
||||
if (operationProperty && operationProperty.options) {
|
||||
const operationValues = operationProperty.options.map((opt: any) => opt.value);
|
||||
expect(operationValues).toContain('getFileInfoByPath');
|
||||
expect(operationValues).toContain('uploadFileByPath');
|
||||
expect(operationValues).toContain('downloadFileByPath');
|
||||
expect(operationValues).toContain('deleteFileByPath');
|
||||
expect(operationValues).toContain('getFileVersionsByPath');
|
||||
}
|
||||
});
|
||||
|
||||
it('should have path-based parameters', () => {
|
||||
const filePathParam = kdriveNode.description.properties.find(p => p.name === 'filePath');
|
||||
expect(filePathParam).toBeDefined();
|
||||
|
||||
const directoryPathParam = kdriveNode.description.properties.find(p => p.name === 'directoryPath');
|
||||
expect(directoryPathParam).toBeDefined();
|
||||
|
||||
const uploadPathParam = kdriveNode.description.properties.find(p => p.name === 'uploadPath');
|
||||
expect(uploadPathParam).toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Generic Functions', () => {
|
||||
describe('kdriveApiRequest', () => {
|
||||
it('should be a function', () => {
|
||||
expect(typeof kdriveApiRequest).toBe('function');
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleApiError', () => {
|
||||
it('should be a function', () => {
|
||||
expect(typeof handleApiError).toBe('function');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Path Resolution Functions', () => {
|
||||
describe('normalizePath', () => {
|
||||
it('should normalize Windows paths', () => {
|
||||
expect(normalizePath('documents\\file.txt')).toBe('documents/file.txt');
|
||||
});
|
||||
|
||||
it('should handle leading/trailing slashes', () => {
|
||||
expect(normalizePath('/documents/file.txt/')).toBe('documents/file.txt');
|
||||
expect(normalizePath('///documents///file.txt///')).toBe('documents/file.txt');
|
||||
});
|
||||
|
||||
it('should handle empty path as root', () => {
|
||||
expect(normalizePath('')).toBe('root');
|
||||
expect(normalizePath('/')).toBe('root');
|
||||
expect(normalizePath('///')).toBe('root');
|
||||
});
|
||||
|
||||
it('should handle root path', () => {
|
||||
expect(normalizePath('root')).toBe('root');
|
||||
});
|
||||
});
|
||||
|
||||
describe('parsePath', () => {
|
||||
it('should parse simple paths', () => {
|
||||
expect(parsePath('file.txt')).toEqual(['file.txt']);
|
||||
});
|
||||
|
||||
it('should parse nested paths', () => {
|
||||
expect(parsePath('documents/project/file.txt')).toEqual(['documents', 'project', 'file.txt']);
|
||||
});
|
||||
|
||||
it('should handle root path', () => {
|
||||
expect(parsePath('root')).toEqual(['root']);
|
||||
});
|
||||
|
||||
it('should normalize before parsing', () => {
|
||||
expect(parsePath('/documents//project///file.txt/')).toEqual(['documents', 'project', 'file.txt']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('clearPathCache', () => {
|
||||
it('should be a function', () => {
|
||||
expect(typeof clearPathCache).toBe('function');
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolvePathToId and resolveIdToPath', () => {
|
||||
it('should be functions', () => {
|
||||
expect(typeof resolvePathToId).toBe('function');
|
||||
expect(typeof resolveIdToPath).toBe('function');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user