mirror of https://github.com/actions/toolkit
Merge pull request #1487 from actions/bethanyj28/add-artifact-api-logic
Utilize client to create and finalize artifactpull/1488/head
commit
ab78839e86
|
@ -0,0 +1,65 @@
|
||||||
|
import {Timestamp} from '../src/generated'
|
||||||
|
import * as retention from '../src/internal/upload/retention'
|
||||||
|
|
||||||
|
describe('retention', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
delete process.env['GITHUB_RETENTION_DAYS']
|
||||||
|
})
|
||||||
|
it('should return the inputted retention days if it is less than the max retention days', () => {
|
||||||
|
// setup
|
||||||
|
const mockDate = new Date('2020-01-01')
|
||||||
|
jest.useFakeTimers().setSystemTime(mockDate)
|
||||||
|
process.env['GITHUB_RETENTION_DAYS'] = '90'
|
||||||
|
|
||||||
|
const exp = retention.getExpiration(30)
|
||||||
|
|
||||||
|
expect(exp).toBeDefined()
|
||||||
|
if (exp) {
|
||||||
|
const expDate = Timestamp.toDate(exp)
|
||||||
|
const expected = new Date()
|
||||||
|
expected.setDate(expected.getDate() + 30)
|
||||||
|
|
||||||
|
expect(expDate).toEqual(expected)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should return the max retention days if the inputted retention days is greater than the max retention days', () => {
|
||||||
|
// setup
|
||||||
|
const mockDate = new Date('2020-01-01')
|
||||||
|
jest.useFakeTimers().setSystemTime(mockDate)
|
||||||
|
process.env['GITHUB_RETENTION_DAYS'] = '90'
|
||||||
|
|
||||||
|
const exp = retention.getExpiration(120)
|
||||||
|
|
||||||
|
expect(exp).toBeDefined()
|
||||||
|
if (exp) {
|
||||||
|
const expDate = Timestamp.toDate(exp) // we check whether exp is defined above
|
||||||
|
const expected = new Date()
|
||||||
|
expected.setDate(expected.getDate() + 90)
|
||||||
|
|
||||||
|
expect(expDate).toEqual(expected)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should return undefined if the inputted retention days is undefined', () => {
|
||||||
|
const exp = retention.getExpiration()
|
||||||
|
expect(exp).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should return the inputted retention days if there is no max retention days', () => {
|
||||||
|
// setup
|
||||||
|
const mockDate = new Date('2020-01-01')
|
||||||
|
jest.useFakeTimers().setSystemTime(mockDate)
|
||||||
|
|
||||||
|
const exp = retention.getExpiration(30)
|
||||||
|
|
||||||
|
expect(exp).toBeDefined()
|
||||||
|
if (exp) {
|
||||||
|
const expDate = Timestamp.toDate(exp) // we check whether exp is defined above
|
||||||
|
const expected = new Date()
|
||||||
|
expected.setDate(expected.getDate() + 30)
|
||||||
|
|
||||||
|
expect(expDate).toEqual(expected)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
|
@ -0,0 +1,62 @@
|
||||||
|
import * as config from '../src/internal/shared/config'
|
||||||
|
import * as util from '../src/internal/shared/util'
|
||||||
|
|
||||||
|
describe('get-backend-ids-from-token', () => {
|
||||||
|
it('should return backend ids when the token is valid', () => {
|
||||||
|
jest
|
||||||
|
.spyOn(config, 'getRuntimeToken')
|
||||||
|
.mockReturnValue(
|
||||||
|
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwic2NwIjoiQWN0aW9ucy5FeGFtcGxlIEFjdGlvbnMuQW5vdGhlckV4YW1wbGU6dGVzdCBBY3Rpb25zLlJlc3VsdHM6Y2U3ZjU0YzctNjFjNy00YWFlLTg4N2YtMzBkYTQ3NWY1ZjFhOmNhMzk1MDg1LTA0MGEtNTI2Yi0yY2U4LWJkYzg1ZjY5Mjc3NCIsImlhdCI6MTUxNjIzOTAyMn0.XYnI_wHPBlUi1mqYveJnnkJhp4dlFjqxzRmISPsqfw8'
|
||||||
|
)
|
||||||
|
|
||||||
|
const backendIds = util.getBackendIdsFromToken()
|
||||||
|
expect(backendIds.workflowRunBackendId).toBe(
|
||||||
|
'ce7f54c7-61c7-4aae-887f-30da475f5f1a'
|
||||||
|
)
|
||||||
|
expect(backendIds.workflowJobRunBackendId).toBe(
|
||||||
|
'ca395085-040a-526b-2ce8-bdc85f692774'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should throw an error when the token doesn't have the right scope", () => {
|
||||||
|
jest
|
||||||
|
.spyOn(config, 'getRuntimeToken')
|
||||||
|
.mockReturnValue(
|
||||||
|
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwic2NwIjoiQWN0aW9ucy5FeGFtcGxlIEFjdGlvbnMuQW5vdGhlckV4YW1wbGU6dGVzdCIsImlhdCI6MTUxNjIzOTAyMn0.K0IEoULZteGevF38G94xiaA8zcZ5UlKWfGfqE6q3dhw'
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(util.getBackendIdsFromToken).toThrowError(
|
||||||
|
'Failed to get backend IDs: The provided JWT token is invalid'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should throw an error when the token has a malformed scope', () => {
|
||||||
|
jest
|
||||||
|
.spyOn(config, 'getRuntimeToken')
|
||||||
|
.mockReturnValue(
|
||||||
|
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwic2NwIjoiQWN0aW9ucy5FeGFtcGxlIEFjdGlvbnMuQW5vdGhlckV4YW1wbGU6dGVzdCBBY3Rpb25zLlJlc3VsdHM6Y2U3ZjU0YzctNjFjNy00YWFlLTg4N2YtMzBkYTQ3NWY1ZjFhIiwiaWF0IjoxNTE2MjM5MDIyfQ.7D0_LRfRFRZFImHQ7GxH2S6ZyFjjZ5U0ujjGCfle1XE'
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(util.getBackendIdsFromToken).toThrowError(
|
||||||
|
'Failed to get backend IDs: The provided JWT token is invalid'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should throw an error when the token is in an invalid format', () => {
|
||||||
|
jest.spyOn(config, 'getRuntimeToken').mockReturnValue('token')
|
||||||
|
|
||||||
|
expect(util.getBackendIdsFromToken).toThrowError('Invalid token specified')
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should throw an error when the token doesn't have the right field", () => {
|
||||||
|
jest
|
||||||
|
.spyOn(config, 'getRuntimeToken')
|
||||||
|
.mockReturnValue(
|
||||||
|
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c'
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(util.getBackendIdsFromToken).toThrowError(
|
||||||
|
'Failed to get backend IDs: The provided JWT token is invalid'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
|
@ -12,6 +12,7 @@
|
||||||
"@actions/core": "^1.10.0",
|
"@actions/core": "^1.10.0",
|
||||||
"@actions/http-client": "^2.1.0",
|
"@actions/http-client": "^2.1.0",
|
||||||
"@protobuf-ts/plugin": "^2.2.3-alpha.1",
|
"@protobuf-ts/plugin": "^2.2.3-alpha.1",
|
||||||
|
"jwt-decode": "^3.1.2",
|
||||||
"twirp-ts": "^2.5.0"
|
"twirp-ts": "^2.5.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
@ -198,6 +199,11 @@
|
||||||
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
|
||||||
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="
|
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="
|
||||||
},
|
},
|
||||||
|
"node_modules/jwt-decode": {
|
||||||
|
"version": "3.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/jwt-decode/-/jwt-decode-3.1.2.tgz",
|
||||||
|
"integrity": "sha512-UfpWE/VZn0iP50d8cz9NrZLM9lSWhcJ+0Gt/nm4by88UL+J1SiKN8/5dkjMmbEzwL2CAe+67GsegCbIKtbp75A=="
|
||||||
|
},
|
||||||
"node_modules/lodash": {
|
"node_modules/lodash": {
|
||||||
"version": "4.17.21",
|
"version": "4.17.21",
|
||||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
|
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
|
||||||
|
|
|
@ -42,6 +42,7 @@
|
||||||
"@actions/core": "^1.10.0",
|
"@actions/core": "^1.10.0",
|
||||||
"@actions/http-client": "^2.1.0",
|
"@actions/http-client": "^2.1.0",
|
||||||
"@protobuf-ts/plugin": "^2.2.3-alpha.1",
|
"@protobuf-ts/plugin": "^2.2.3-alpha.1",
|
||||||
|
"jwt-decode": "^3.1.2",
|
||||||
"twirp-ts": "^2.5.0"
|
"twirp-ts": "^2.5.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|
|
@ -1,6 +1,8 @@
|
||||||
import {UploadOptions} from './upload/upload-options'
|
import {UploadOptions} from './upload/upload-options'
|
||||||
import {UploadResponse} from './upload/upload-response'
|
import {UploadResponse} from './upload/upload-response'
|
||||||
import {uploadArtifact} from './upload/upload-artifact'
|
import {uploadArtifact} from './upload/upload-artifact'
|
||||||
|
import {warning} from '@actions/core'
|
||||||
|
import {isGhes} from './shared/config'
|
||||||
|
|
||||||
export interface ArtifactClient {
|
export interface ArtifactClient {
|
||||||
/**
|
/**
|
||||||
|
@ -39,6 +41,28 @@ export class Client implements ArtifactClient {
|
||||||
rootDirectory: string,
|
rootDirectory: string,
|
||||||
options?: UploadOptions | undefined
|
options?: UploadOptions | undefined
|
||||||
): Promise<UploadResponse> {
|
): Promise<UploadResponse> {
|
||||||
return uploadArtifact(name, files, rootDirectory, options)
|
if (isGhes()) {
|
||||||
|
warning(
|
||||||
|
`@actions/artifact v2 and upload-artifact v4 are not currently supported on GHES.`
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
success: false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return uploadArtifact(name, files, rootDirectory, options)
|
||||||
|
} catch (error) {
|
||||||
|
warning(
|
||||||
|
`Artifact upload failed with error: ${error}.
|
||||||
|
|
||||||
|
Errors can be temporary, so please try again and optionally run the action with debug enabled for more information.
|
||||||
|
|
||||||
|
If the error persists, please check whether Actions is running normally at [https://githubstatus.com](https://www.githubstatus.com).`
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
success: false
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -56,8 +56,6 @@ class ArtifactHttpClient implements Rpc {
|
||||||
const headers = {
|
const headers = {
|
||||||
'Content-Type': contentType
|
'Content-Type': contentType
|
||||||
}
|
}
|
||||||
info(`Making request to ${url} with data: ${JSON.stringify(data)}`)
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await this.retryableRequest(async () =>
|
const response = await this.retryableRequest(async () =>
|
||||||
this.httpClient.post(url, JSON.stringify(data), headers)
|
this.httpClient.post(url, JSON.stringify(data), headers)
|
||||||
|
@ -65,7 +63,7 @@ class ArtifactHttpClient implements Rpc {
|
||||||
const body = await response.readBody()
|
const body = await response.readBody()
|
||||||
return JSON.parse(body)
|
return JSON.parse(body)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw new Error(error.message)
|
throw new Error(`Failed to ${method}: ${error.message}`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -13,3 +13,10 @@ export function getResultsServiceUrl(): string {
|
||||||
}
|
}
|
||||||
return resultsUrl
|
return resultsUrl
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function isGhes(): boolean {
|
||||||
|
const ghUrl = new URL(
|
||||||
|
process.env['GITHUB_SERVER_URL'] || 'https://github.com'
|
||||||
|
)
|
||||||
|
return ghUrl.hostname.toUpperCase() !== 'GITHUB.COM'
|
||||||
|
}
|
||||||
|
|
|
@ -0,0 +1,65 @@
|
||||||
|
import {getRuntimeToken} from './config'
|
||||||
|
import jwt_decode from 'jwt-decode'
|
||||||
|
|
||||||
|
export interface BackendIds {
|
||||||
|
workflowRunBackendId: string
|
||||||
|
workflowJobRunBackendId: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ActionsToken {
|
||||||
|
scp: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const InvalidJwtError = new Error(
|
||||||
|
'Failed to get backend IDs: The provided JWT token is invalid'
|
||||||
|
)
|
||||||
|
|
||||||
|
// uses the JWT token claims to get the
|
||||||
|
// workflow run and workflow job run backend ids
|
||||||
|
export function getBackendIdsFromToken(): BackendIds {
|
||||||
|
const token = getRuntimeToken()
|
||||||
|
const decoded = jwt_decode<ActionsToken>(token)
|
||||||
|
if (!decoded.scp) {
|
||||||
|
throw InvalidJwtError
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* example decoded:
|
||||||
|
* {
|
||||||
|
* scp: "Actions.ExampleScope Actions.Results:ce7f54c7-61c7-4aae-887f-30da475f5f1a:ca395085-040a-526b-2ce8-bdc85f692774"
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
|
||||||
|
const scpParts = decoded.scp.split(' ')
|
||||||
|
if (scpParts.length === 0) {
|
||||||
|
throw InvalidJwtError
|
||||||
|
}
|
||||||
|
/*
|
||||||
|
* example scpParts:
|
||||||
|
* ["Actions.ExampleScope", "Actions.Results:ce7f54c7-61c7-4aae-887f-30da475f5f1a:ca395085-040a-526b-2ce8-bdc85f692774"]
|
||||||
|
*/
|
||||||
|
|
||||||
|
for (const scopes of scpParts) {
|
||||||
|
const scopeParts = scopes.split(':')
|
||||||
|
/*
|
||||||
|
* example scopeParts:
|
||||||
|
* ["Actions.Results", "ce7f54c7-61c7-4aae-887f-30da475f5f1a", "ca395085-040a-526b-2ce8-bdc85f692774"]
|
||||||
|
*/
|
||||||
|
if (scopeParts.length !== 3) {
|
||||||
|
// not the Actions.Results scope
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (scopeParts[0] !== 'Actions.Results') {
|
||||||
|
// not the Actions.Results scope
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
workflowRunBackendId: scopeParts[1],
|
||||||
|
workflowJobRunBackendId: scopeParts[2]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw InvalidJwtError
|
||||||
|
}
|
|
@ -0,0 +1,34 @@
|
||||||
|
import {Timestamp} from '../../generated'
|
||||||
|
import * as core from '@actions/core'
|
||||||
|
|
||||||
|
export function getExpiration(retentionDays?: number): Timestamp | undefined {
|
||||||
|
if (!retentionDays) {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxRetentionDays = getRetentionDays()
|
||||||
|
if (maxRetentionDays && maxRetentionDays < retentionDays) {
|
||||||
|
core.warning(
|
||||||
|
`Retention days cannot be greater than the maximum allowed retention set within the repository. Using ${maxRetentionDays} instead.`
|
||||||
|
)
|
||||||
|
retentionDays = maxRetentionDays
|
||||||
|
}
|
||||||
|
|
||||||
|
const expirationDate = new Date()
|
||||||
|
expirationDate.setDate(expirationDate.getDate() + retentionDays)
|
||||||
|
|
||||||
|
return Timestamp.fromDate(expirationDate)
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRetentionDays(): number | undefined {
|
||||||
|
const retentionDays = process.env['GITHUB_RETENTION_DAYS']
|
||||||
|
if (!retentionDays) {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
const days = parseInt(retentionDays)
|
||||||
|
if (isNaN(days)) {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
return days
|
||||||
|
}
|
|
@ -1,18 +1,22 @@
|
||||||
import * as core from '@actions/core'
|
import * as core from '@actions/core'
|
||||||
import {UploadOptions} from './upload-options'
|
import {UploadOptions} from './upload-options'
|
||||||
import {UploadResponse} from './upload-response'
|
import {UploadResponse} from './upload-response'
|
||||||
|
import {getExpiration} from './retention'
|
||||||
import {validateArtifactName} from './path-and-artifact-name-validation'
|
import {validateArtifactName} from './path-and-artifact-name-validation'
|
||||||
|
import {createArtifactTwirpClient} from '../shared/artifact-twirp-client'
|
||||||
import {
|
import {
|
||||||
UploadZipSpecification,
|
UploadZipSpecification,
|
||||||
getUploadZipSpecification,
|
getUploadZipSpecification,
|
||||||
validateRootDirectory
|
validateRootDirectory
|
||||||
} from './upload-zip-specification'
|
} from './upload-zip-specification'
|
||||||
|
import {getBackendIdsFromToken} from '../shared/util'
|
||||||
|
import {CreateArtifactRequest} from 'src/generated'
|
||||||
|
|
||||||
export async function uploadArtifact(
|
export async function uploadArtifact(
|
||||||
name: string,
|
name: string,
|
||||||
files: string[],
|
files: string[],
|
||||||
rootDirectory: string,
|
rootDirectory: string,
|
||||||
options?: UploadOptions | undefined // eslint-disable-line @typescript-eslint/no-unused-vars
|
options?: UploadOptions | undefined
|
||||||
): Promise<UploadResponse> {
|
): Promise<UploadResponse> {
|
||||||
validateArtifactName(name)
|
validateArtifactName(name)
|
||||||
validateRootDirectory(rootDirectory)
|
validateRootDirectory(rootDirectory)
|
||||||
|
@ -28,12 +32,66 @@ export async function uploadArtifact(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// get the IDs needed for the artifact creation
|
||||||
|
const backendIds = getBackendIdsFromToken()
|
||||||
|
if (!backendIds.workflowRunBackendId || !backendIds.workflowJobRunBackendId) {
|
||||||
|
core.warning(`Failed to get backend ids`)
|
||||||
|
return {
|
||||||
|
success: false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
core.debug(`Workflow Run Backend ID: ${backendIds.workflowRunBackendId}`)
|
||||||
|
core.debug(
|
||||||
|
`Workflow Job Run Backend ID: ${backendIds.workflowJobRunBackendId}`
|
||||||
|
)
|
||||||
|
|
||||||
|
// create the artifact client
|
||||||
|
const artifactClient = createArtifactTwirpClient('upload')
|
||||||
|
|
||||||
|
// create the artifact
|
||||||
|
const createArtifactReq: CreateArtifactRequest = {
|
||||||
|
workflowRunBackendId: backendIds.workflowRunBackendId,
|
||||||
|
workflowJobRunBackendId: backendIds.workflowJobRunBackendId,
|
||||||
|
name,
|
||||||
|
version: 4
|
||||||
|
}
|
||||||
|
|
||||||
|
// if there is a retention period, add it to the request
|
||||||
|
const expiresAt = getExpiration(options?.retentionDays)
|
||||||
|
if (expiresAt) {
|
||||||
|
createArtifactReq.expiresAt = expiresAt
|
||||||
|
}
|
||||||
|
|
||||||
|
const createArtifactResp = await artifactClient.CreateArtifact(
|
||||||
|
createArtifactReq
|
||||||
|
)
|
||||||
|
if (!createArtifactResp.ok) {
|
||||||
|
core.warning(`Failed to create artifact`)
|
||||||
|
return {
|
||||||
|
success: false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// TODO - Implement upload functionality
|
// TODO - Implement upload functionality
|
||||||
|
|
||||||
|
// finalize the artifact
|
||||||
|
const finalizeArtifactResp = await artifactClient.FinalizeArtifact({
|
||||||
|
workflowRunBackendId: backendIds.workflowRunBackendId,
|
||||||
|
workflowJobRunBackendId: backendIds.workflowJobRunBackendId,
|
||||||
|
name,
|
||||||
|
size: '0' // TODO - Add size
|
||||||
|
})
|
||||||
|
if (!finalizeArtifactResp.ok) {
|
||||||
|
core.warning(`Failed to finalize artifact`)
|
||||||
|
return {
|
||||||
|
success: false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const uploadResponse: UploadResponse = {
|
const uploadResponse: UploadResponse = {
|
||||||
success: true,
|
success: true,
|
||||||
size: 0,
|
size: 0,
|
||||||
id: 0
|
id: parseInt(finalizeArtifactResp.artifactId) // TODO - will this be a problem due to the id being a bigint?
|
||||||
}
|
}
|
||||||
|
|
||||||
return uploadResponse
|
return uploadResponse
|
||||||
|
|
Loading…
Reference in New Issue