mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-17 05:29:44 +02:00
Merge a0357c14ab into ec074519d9
This commit is contained in:
commit
58c48e85c8
|
|
@ -11,34 +11,110 @@ import { NodeApiError } from 'n8n-workflow';
|
|||
|
||||
import { removeTrailingSlash } from '@utils/utilities';
|
||||
|
||||
export async function getJenkinsCrumb(
|
||||
this: IHookFunctions | IExecuteFunctions | ILoadOptionsFunctions,
|
||||
credentials: IDataObject,
|
||||
): Promise<IDataObject | null> {
|
||||
const baseUrl = (credentials.baseUrl as string) || '';
|
||||
const options: IRequestOptions = {
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
},
|
||||
method: 'GET',
|
||||
auth: {
|
||||
username: credentials.username as string,
|
||||
password: credentials.apiKey as string,
|
||||
},
|
||||
sendCredentialsOnCrossOriginRedirect: false,
|
||||
uri: `${removeTrailingSlash(baseUrl)}/crumbIssuer/api/json`,
|
||||
json: true,
|
||||
resolveWithFullResponse: true,
|
||||
};
|
||||
try {
|
||||
const response = (await this.helpers.request(options)) as {
|
||||
body?: IDataObject;
|
||||
headers?: Record<string, string | string[]>;
|
||||
};
|
||||
const body = (response?.body ?? response) as IDataObject;
|
||||
if (body && body.crumbRequestField && body.crumb) {
|
||||
const crumbHeaders: IDataObject = {
|
||||
[body.crumbRequestField as string]: body.crumb as string,
|
||||
};
|
||||
const setCookie = response.headers?.['set-cookie'];
|
||||
if (setCookie) {
|
||||
crumbHeaders.Cookie = (Array.isArray(setCookie) ? setCookie : [setCookie])
|
||||
.map((cookie) => cookie.split(';', 1)[0])
|
||||
.join('; ');
|
||||
}
|
||||
return crumbHeaders;
|
||||
}
|
||||
} catch {
|
||||
// Crumb issuer is not enabled (e.g. 404) or unavailable; proceed without it
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function jenkinsApiRequest(
|
||||
this: IHookFunctions | IExecuteFunctions | ILoadOptionsFunctions,
|
||||
method: IHttpRequestMethods,
|
||||
uri: string,
|
||||
qs: IDataObject = {},
|
||||
|
||||
body: any = '',
|
||||
option: IDataObject = {},
|
||||
): Promise<any> {
|
||||
const credentials = await this.getCredentials('jenkinsApi');
|
||||
const baseUrl = (credentials.baseUrl as string) || '';
|
||||
const headers: IDataObject = {
|
||||
Accept: 'application/json',
|
||||
};
|
||||
|
||||
if (['POST', 'PUT', 'DELETE'].includes(method)) {
|
||||
const crumb = await getJenkinsCrumb.call(this, credentials);
|
||||
if (crumb) {
|
||||
Object.assign(headers, crumb);
|
||||
}
|
||||
}
|
||||
|
||||
if (option.headers) {
|
||||
Object.assign(headers, option.headers);
|
||||
}
|
||||
|
||||
let options: IRequestOptions = {
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
},
|
||||
headers,
|
||||
method,
|
||||
auth: {
|
||||
username: credentials.username as string,
|
||||
password: credentials.apiKey as string,
|
||||
},
|
||||
uri: `${removeTrailingSlash(credentials.baseUrl as string)}${uri}`,
|
||||
sendCredentialsOnCrossOriginRedirect: false,
|
||||
uri: `${removeTrailingSlash(baseUrl)}${uri}`,
|
||||
json: true,
|
||||
qs,
|
||||
body,
|
||||
};
|
||||
options = Object.assign({}, options, option);
|
||||
options = Object.assign({}, options, option, { headers });
|
||||
try {
|
||||
return await this.helpers.request(options);
|
||||
} catch (error) {
|
||||
const rawStatusCode =
|
||||
error.statusCode ?? error.httpCode ?? error.status ?? error.response?.status;
|
||||
const statusCode = rawStatusCode !== undefined ? Number(rawStatusCode) : undefined;
|
||||
const responseHeaders = (error.response?.headers ?? error.headers ?? {}) as Record<
|
||||
string,
|
||||
string | undefined
|
||||
>;
|
||||
const location = (responseHeaders.location ?? responseHeaders.Location) as string | undefined;
|
||||
|
||||
if (
|
||||
statusCode !== undefined &&
|
||||
[302, 303].includes(statusCode) &&
|
||||
typeof location === 'string' &&
|
||||
/\/queue\/item\/\d+/i.test(location) &&
|
||||
!location.includes('/login')
|
||||
) {
|
||||
return { success: true, queueUrl: location };
|
||||
}
|
||||
|
||||
throw new NodeApiError(this.getNode(), error as JsonObject);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import type { IExecuteFunctions } from 'n8n-workflow';
|
||||
import { mockDeep } from 'vitest-mock-extended';
|
||||
|
||||
import { jenkinsApiRequest } from '../GenericFunctions';
|
||||
import { getJenkinsCrumb, jenkinsApiRequest } from '../GenericFunctions';
|
||||
|
||||
describe('jenkinsApiRequest', () => {
|
||||
it('removes a trailing slash from the credential base URL', async () => {
|
||||
|
|
@ -18,4 +18,390 @@ describe('jenkinsApiRequest', () => {
|
|||
expect.objectContaining({ uri: 'https://jenkins.example.com/api/json' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('handles missing base URL gracefully without throwing', async () => {
|
||||
const executeFunctions = mockDeep<IExecuteFunctions>();
|
||||
executeFunctions.getCredentials.mockResolvedValue({
|
||||
username: 'user',
|
||||
apiKey: 'key',
|
||||
});
|
||||
|
||||
await jenkinsApiRequest.call(executeFunctions, 'GET', '/api/json');
|
||||
|
||||
expect(executeFunctions.helpers.request).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ uri: '/api/json' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('fetches and attaches CSRF crumb and session cookie on POST requests when crumb issuer is available', async () => {
|
||||
const executeFunctions = mockDeep<IExecuteFunctions>();
|
||||
executeFunctions.getCredentials.mockResolvedValue({
|
||||
username: 'user',
|
||||
apiKey: 'key',
|
||||
baseUrl: 'https://jenkins.example.com',
|
||||
});
|
||||
|
||||
executeFunctions.helpers.request.mockImplementation(async (options: any) => {
|
||||
if (options.uri.endsWith('/crumbIssuer/api/json')) {
|
||||
return {
|
||||
body: {
|
||||
crumbRequestField: 'Jenkins-Crumb',
|
||||
crumb: 'test-crumb-value-123',
|
||||
},
|
||||
headers: {
|
||||
'set-cookie': [
|
||||
'JSESSIONID=test-session-cookie; Path=/; HttpOnly',
|
||||
'remember-me=test-token; Path=/; Max-Age=1209600',
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
return { success: true };
|
||||
});
|
||||
|
||||
await jenkinsApiRequest.call(executeFunctions, 'POST', '/job/my-job/build');
|
||||
|
||||
expect(executeFunctions.helpers.request).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
uri: 'https://jenkins.example.com/job/my-job/build',
|
||||
sendCredentialsOnCrossOriginRedirect: false,
|
||||
headers: expect.objectContaining({
|
||||
'Jenkins-Crumb': 'test-crumb-value-123',
|
||||
Cookie: 'JSESSIONID=test-session-cookie; remember-me=test-token',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('preserves caller option headers alongside the CSRF crumb', async () => {
|
||||
const executeFunctions = mockDeep<IExecuteFunctions>();
|
||||
executeFunctions.getCredentials.mockResolvedValue({
|
||||
username: 'user',
|
||||
apiKey: 'key',
|
||||
baseUrl: 'https://jenkins.example.com',
|
||||
});
|
||||
|
||||
executeFunctions.helpers.request.mockImplementation(async (options: any) => {
|
||||
if (options.uri.endsWith('/crumbIssuer/api/json')) {
|
||||
return {
|
||||
body: {
|
||||
crumbRequestField: 'Jenkins-Crumb',
|
||||
crumb: 'test-crumb-value-123',
|
||||
},
|
||||
};
|
||||
}
|
||||
return { success: true };
|
||||
});
|
||||
|
||||
await jenkinsApiRequest.call(
|
||||
executeFunctions,
|
||||
'POST',
|
||||
'/job/my-job/buildWithParameters',
|
||||
{},
|
||||
{},
|
||||
{
|
||||
headers: {
|
||||
'content-type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
expect(executeFunctions.helpers.request).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
headers: expect.objectContaining({
|
||||
Accept: 'application/json',
|
||||
'Jenkins-Crumb': 'test-crumb-value-123',
|
||||
'content-type': 'application/x-www-form-urlencoded',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('fetches and attaches CSRF crumb on PUT and DELETE requests', async () => {
|
||||
const executeFunctions = mockDeep<IExecuteFunctions>();
|
||||
executeFunctions.getCredentials.mockResolvedValue({
|
||||
username: 'user',
|
||||
apiKey: 'key',
|
||||
baseUrl: 'https://jenkins.example.com',
|
||||
});
|
||||
|
||||
executeFunctions.helpers.request.mockImplementation(async (options: any) => {
|
||||
if (options.uri.endsWith('/crumbIssuer/api/json')) {
|
||||
return {
|
||||
crumbRequestField: 'Jenkins-Crumb',
|
||||
crumb: 'test-crumb-value-123',
|
||||
};
|
||||
}
|
||||
return { success: true };
|
||||
});
|
||||
|
||||
await jenkinsApiRequest.call(executeFunctions, 'PUT', '/job/my-job/config.xml');
|
||||
expect(executeFunctions.helpers.request).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
method: 'PUT',
|
||||
headers: expect.objectContaining({
|
||||
'Jenkins-Crumb': 'test-crumb-value-123',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
await jenkinsApiRequest.call(executeFunctions, 'DELETE', '/job/my-job');
|
||||
expect(executeFunctions.helpers.request).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
method: 'DELETE',
|
||||
headers: expect.objectContaining({
|
||||
'Jenkins-Crumb': 'test-crumb-value-123',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('proceeds without crumb on POST requests when crumb issuer returns 404', async () => {
|
||||
const executeFunctions = mockDeep<IExecuteFunctions>();
|
||||
executeFunctions.getCredentials.mockResolvedValue({
|
||||
username: 'user',
|
||||
apiKey: 'key',
|
||||
baseUrl: 'https://jenkins.example.com',
|
||||
});
|
||||
|
||||
executeFunctions.helpers.request.mockImplementation(async (options: any) => {
|
||||
if (options.uri.endsWith('/crumbIssuer/api/json')) {
|
||||
throw new Error('404 Not Found');
|
||||
}
|
||||
return { success: true };
|
||||
});
|
||||
|
||||
const result = await jenkinsApiRequest.call(executeFunctions, 'POST', '/job/my-job/build');
|
||||
|
||||
expect(result).toEqual({ success: true });
|
||||
expect(executeFunctions.helpers.request).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
uri: 'https://jenkins.example.com/job/my-job/build',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('does not attempt to fetch CSRF crumb for GET requests', async () => {
|
||||
const executeFunctions = mockDeep<IExecuteFunctions>();
|
||||
executeFunctions.getCredentials.mockResolvedValue({
|
||||
username: 'user',
|
||||
apiKey: 'key',
|
||||
baseUrl: 'https://jenkins.example.com',
|
||||
});
|
||||
|
||||
executeFunctions.helpers.request.mockResolvedValue({ status: 'ok' });
|
||||
|
||||
await jenkinsApiRequest.call(executeFunctions, 'GET', '/job/my-job/api/json');
|
||||
|
||||
expect(executeFunctions.helpers.request).toHaveBeenCalledTimes(1);
|
||||
expect(executeFunctions.helpers.request).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
method: 'GET',
|
||||
uri: 'https://jenkins.example.com/job/my-job/api/json',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('treats 303 queue item redirect as successful build scheduling', async () => {
|
||||
const executeFunctions = mockDeep<IExecuteFunctions>();
|
||||
executeFunctions.getCredentials.mockResolvedValue({
|
||||
username: 'user',
|
||||
apiKey: 'key',
|
||||
baseUrl: 'https://jenkins.example.com',
|
||||
});
|
||||
|
||||
executeFunctions.helpers.request.mockImplementation(async (options: any) => {
|
||||
if (options.uri.endsWith('/crumbIssuer/api/json')) {
|
||||
return null;
|
||||
}
|
||||
const error: any = new Error('303 - ""');
|
||||
error.statusCode = 303;
|
||||
error.response = {
|
||||
status: 303,
|
||||
headers: {
|
||||
location: 'https://jenkins.example.com/queue/item/42/',
|
||||
},
|
||||
};
|
||||
throw error;
|
||||
});
|
||||
|
||||
const result = await jenkinsApiRequest.call(
|
||||
executeFunctions,
|
||||
'POST',
|
||||
'/job/my-job/buildWithParameters',
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
success: true,
|
||||
queueUrl: 'https://jenkins.example.com/queue/item/42/',
|
||||
});
|
||||
});
|
||||
|
||||
it('treats 302 queue item redirect as successful build scheduling', async () => {
|
||||
const executeFunctions = mockDeep<IExecuteFunctions>();
|
||||
executeFunctions.getCredentials.mockResolvedValue({
|
||||
username: 'user',
|
||||
apiKey: 'key',
|
||||
baseUrl: 'https://jenkins.example.com',
|
||||
});
|
||||
|
||||
executeFunctions.helpers.request.mockImplementation(async (options: any) => {
|
||||
if (options.uri.endsWith('/crumbIssuer/api/json')) {
|
||||
return null;
|
||||
}
|
||||
const error: any = new Error('302 - Found');
|
||||
error.statusCode = 302;
|
||||
error.response = {
|
||||
status: 302,
|
||||
headers: {
|
||||
location: 'https://jenkins.example.com/queue/item/99/',
|
||||
},
|
||||
};
|
||||
throw error;
|
||||
});
|
||||
|
||||
const result302 = await jenkinsApiRequest.call(
|
||||
executeFunctions,
|
||||
'POST',
|
||||
'/job/my-job/build',
|
||||
);
|
||||
expect(result302).toEqual({
|
||||
success: true,
|
||||
queueUrl: 'https://jenkins.example.com/queue/item/99/',
|
||||
});
|
||||
});
|
||||
|
||||
it('returns resolved response when job trigger resolves successfully', async () => {
|
||||
const executeFunctions = mockDeep<IExecuteFunctions>();
|
||||
executeFunctions.getCredentials.mockResolvedValue({
|
||||
username: 'user',
|
||||
apiKey: 'key',
|
||||
baseUrl: 'https://jenkins.example.com',
|
||||
});
|
||||
|
||||
executeFunctions.helpers.request.mockImplementation(async (options: any) => {
|
||||
if (options.uri.endsWith('/crumbIssuer/api/json')) {
|
||||
return null;
|
||||
}
|
||||
return { success: true };
|
||||
});
|
||||
|
||||
const result = await jenkinsApiRequest.call(
|
||||
executeFunctions,
|
||||
'POST',
|
||||
'/job/my-job/build',
|
||||
);
|
||||
expect(result).toEqual({ success: true });
|
||||
});
|
||||
|
||||
it('handles string status codes in redirect responses', async () => {
|
||||
const executeFunctions = mockDeep<IExecuteFunctions>();
|
||||
executeFunctions.getCredentials.mockResolvedValue({
|
||||
username: 'user',
|
||||
apiKey: 'key',
|
||||
baseUrl: 'https://jenkins.example.com',
|
||||
});
|
||||
|
||||
executeFunctions.helpers.request.mockImplementation(async (options: any) => {
|
||||
if (options.uri.endsWith('/crumbIssuer/api/json')) {
|
||||
return null;
|
||||
}
|
||||
const error: any = new Error('303 - ""');
|
||||
error.httpCode = '303';
|
||||
error.headers = {
|
||||
location: 'https://jenkins.example.com/queue/item/77/',
|
||||
};
|
||||
throw error;
|
||||
});
|
||||
|
||||
const result = await jenkinsApiRequest.call(
|
||||
executeFunctions,
|
||||
'POST',
|
||||
'/job/my-job/build',
|
||||
);
|
||||
expect(result).toEqual({
|
||||
success: true,
|
||||
queueUrl: 'https://jenkins.example.com/queue/item/77/',
|
||||
});
|
||||
});
|
||||
|
||||
it('rethrows NodeApiError when 303 redirects to login page instead of queue', async () => {
|
||||
const executeFunctions = mockDeep<IExecuteFunctions>();
|
||||
executeFunctions.getCredentials.mockResolvedValue({
|
||||
username: 'user',
|
||||
apiKey: 'key',
|
||||
baseUrl: 'https://jenkins.example.com',
|
||||
});
|
||||
|
||||
executeFunctions.helpers.request.mockImplementation(async (options: any) => {
|
||||
if (options.uri.endsWith('/crumbIssuer/api/json')) {
|
||||
return null;
|
||||
}
|
||||
const error: any = new Error('303 - ""');
|
||||
error.statusCode = 303;
|
||||
error.response = {
|
||||
status: 303,
|
||||
headers: {
|
||||
location: 'https://jenkins.example.com/login?from=/job/my-job/build',
|
||||
},
|
||||
};
|
||||
throw error;
|
||||
});
|
||||
|
||||
await expect(
|
||||
jenkinsApiRequest.call(executeFunctions, 'POST', '/job/my-job/buildWithParameters'),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('rethrows NodeApiError when redirect contains /login even if /queue/item/ is in query string', async () => {
|
||||
const executeFunctions = mockDeep<IExecuteFunctions>();
|
||||
executeFunctions.getCredentials.mockResolvedValue({
|
||||
username: 'user',
|
||||
apiKey: 'key',
|
||||
baseUrl: 'https://jenkins.example.com',
|
||||
});
|
||||
|
||||
executeFunctions.helpers.request.mockImplementation(async (options: any) => {
|
||||
if (options.uri.endsWith('/crumbIssuer/api/json')) {
|
||||
return null;
|
||||
}
|
||||
const error: any = new Error('302 - Found');
|
||||
error.statusCode = 302;
|
||||
error.response = {
|
||||
status: 302,
|
||||
headers: {
|
||||
location: 'https://jenkins.example.com/login?from=/queue/item/42/',
|
||||
},
|
||||
};
|
||||
throw error;
|
||||
});
|
||||
|
||||
await expect(
|
||||
jenkinsApiRequest.call(executeFunctions, 'POST', '/job/my-job/build'),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('rethrows NodeApiError when a non-redirect error occurs', async () => {
|
||||
const executeFunctions = mockDeep<IExecuteFunctions>();
|
||||
executeFunctions.getCredentials.mockResolvedValue({
|
||||
username: 'user',
|
||||
apiKey: 'key',
|
||||
baseUrl: 'https://jenkins.example.com',
|
||||
});
|
||||
|
||||
executeFunctions.helpers.request.mockImplementation(async (options: any) => {
|
||||
if (options.uri.endsWith('/crumbIssuer/api/json')) {
|
||||
return null;
|
||||
}
|
||||
const error: any = new Error('500 - Internal Server Error');
|
||||
error.statusCode = 500;
|
||||
throw error;
|
||||
});
|
||||
|
||||
await expect(
|
||||
jenkinsApiRequest.call(executeFunctions, 'POST', '/job/my-job/build'),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,5 +1,9 @@
|
|||
import { mockDeep } from 'vitest-mock-extended';
|
||||
import type { ILoadOptionsFunctions, INodePropertyOptions } from 'n8n-workflow';
|
||||
import type {
|
||||
IExecuteFunctions,
|
||||
ILoadOptionsFunctions,
|
||||
INodePropertyOptions,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
import * as GenericFunctions from '../GenericFunctions';
|
||||
import { Jenkins } from '../Jenkins.node';
|
||||
|
|
@ -120,4 +124,61 @@ describe('Jenkins node', () => {
|
|||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('execute', () => {
|
||||
it('triggers a job with parameters successfully when jenkinsApiRequest returns queue redirect', async () => {
|
||||
const executeFunctions = mockDeep<IExecuteFunctions>();
|
||||
executeFunctions.getInputData.mockReturnValue([{ json: {} }]);
|
||||
executeFunctions.helpers.returnJsonArray.mockImplementation((data: any) =>
|
||||
data.map((item: any) => ({ json: item })),
|
||||
);
|
||||
executeFunctions.getNodeParameter.mockImplementation((name: string) => {
|
||||
if (name === 'resource') return 'job';
|
||||
if (name === 'operation') return 'triggerParams';
|
||||
if (name === 'job') return 'demo-job';
|
||||
if (name === 'param.params') return [{ name: 'BRANCH', value: 'main' }];
|
||||
return undefined;
|
||||
});
|
||||
jenkinsApiRequestSpy.mockResolvedValue({
|
||||
success: true,
|
||||
queueUrl: 'https://jenkins.example.com/queue/item/42/',
|
||||
});
|
||||
|
||||
const result = await node.execute.call(executeFunctions);
|
||||
|
||||
expect(jenkinsApiRequestSpy).toHaveBeenCalledWith(
|
||||
'POST',
|
||||
'/job/demo-job/buildWithParameters',
|
||||
{},
|
||||
{},
|
||||
{
|
||||
form: { BRANCH: 'main' },
|
||||
headers: {
|
||||
'content-type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
},
|
||||
);
|
||||
expect(result).toEqual([[{ json: { success: true } }]]);
|
||||
});
|
||||
|
||||
it('triggers a parameterless job successfully', async () => {
|
||||
const executeFunctions = mockDeep<IExecuteFunctions>();
|
||||
executeFunctions.getInputData.mockReturnValue([{ json: {} }]);
|
||||
executeFunctions.helpers.returnJsonArray.mockImplementation((data: any) =>
|
||||
data.map((item: any) => ({ json: item })),
|
||||
);
|
||||
executeFunctions.getNodeParameter.mockImplementation((name: string) => {
|
||||
if (name === 'resource') return 'job';
|
||||
if (name === 'operation') return 'trigger';
|
||||
if (name === 'job') return 'demo-job';
|
||||
return undefined;
|
||||
});
|
||||
jenkinsApiRequestSpy.mockResolvedValue({ success: true });
|
||||
|
||||
const result = await node.execute.call(executeFunctions);
|
||||
|
||||
expect(jenkinsApiRequestSpy).toHaveBeenCalledWith('POST', '/job/demo-job/build');
|
||||
expect(result).toEqual([[{ json: { success: true } }]]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user