Compare commits

..

No commits in common. "master" and "v1" have entirely different histories.
master ... v1

29 changed files with 342 additions and 55006 deletions

View File

@ -1,3 +0,0 @@
dist/
lib/
node_modules/

View File

@ -1,57 +0,0 @@
{
"plugins": ["jest", "@typescript-eslint"],
"extends": ["plugin:github/recommended"],
"parser": "@typescript-eslint/parser",
"parserOptions": {
"ecmaVersion": 9,
"sourceType": "module",
"project": "./tsconfig.json"
},
"rules": {
"camelcase": "off",
"eslint-comments/no-use": "off",
"import/no-namespace": "off",
"no-shadow": "off",
"no-unused-vars": "off",
"prefer-template": "off",
"semi": [ "error", "never"],
"@typescript-eslint/explicit-member-accessibility": ["error", {"accessibility": "no-public"}],
"@typescript-eslint/array-type": "error",
"@typescript-eslint/await-thenable": "error",
"@typescript-eslint/ban-ts-comment": "error",
"@typescript-eslint/consistent-type-assertions": "error",
"@typescript-eslint/explicit-function-return-type": "off",
"@typescript-eslint/func-call-spacing": ["error", "never"],
"@typescript-eslint/no-array-constructor": "error",
"@typescript-eslint/no-empty-interface": "error",
"@typescript-eslint/no-explicit-any": "error",
"@typescript-eslint/no-extraneous-class": "error",
"@typescript-eslint/no-for-in-array": "error",
"@typescript-eslint/no-inferrable-types": "error",
"@typescript-eslint/no-misused-new": "error",
"@typescript-eslint/no-namespace": "error",
"@typescript-eslint/no-require-imports": "error",
"@typescript-eslint/no-shadow": "error",
"@typescript-eslint/no-non-null-assertion": "warn",
"@typescript-eslint/no-unnecessary-qualifier": "error",
"@typescript-eslint/no-unnecessary-type-assertion": "error",
"@typescript-eslint/no-unused-vars": ["error", {"varsIgnorePattern": "^_"}],
"@typescript-eslint/no-useless-constructor": "error",
"@typescript-eslint/no-var-requires": "error",
"@typescript-eslint/prefer-for-of": "warn",
"@typescript-eslint/prefer-function-type": "warn",
"@typescript-eslint/prefer-includes": "error",
"@typescript-eslint/prefer-string-starts-ends-with": "error",
"@typescript-eslint/promise-function-async": "error",
"@typescript-eslint/require-array-sort-compare": "error",
"@typescript-eslint/restrict-plus-operands": "error",
"@typescript-eslint/semi": ["error", "never"],
"@typescript-eslint/type-annotation-spacing": "error",
"@typescript-eslint/unbound-method": "error"
},
"env": {
"node": true,
"es6": true,
"jest/globals": true
}
}

3
.gitignore vendored
View File

@ -1,5 +1,3 @@
lib
# Logs # Logs
logs logs
*.log *.log
@ -82,6 +80,7 @@ typings/
# Nuxt.js build / generate output # Nuxt.js build / generate output
.nuxt .nuxt
dist
# Gatsby files # Gatsby files
.cache/ .cache/

View File

@ -1,5 +0,0 @@
dist/
lib/
node_modules/
__tests__/__outputs__
__tests__/__snapshots__

View File

@ -1,10 +0,0 @@
{
"printWidth": 120,
"tabWidth": 2,
"useTabs": false,
"semi": false,
"singleQuote": true,
"trailingComma": "none",
"bracketSpacing": true,
"arrowParens": "avoid"
}

View File

@ -1,29 +1 @@
# Nextcloud Artifact Upload Action # nextcloud-artifacts-action
Upload artifacts to nextcloud and output a shareable URL.
### How it looks:
![image](https://user-images.githubusercontent.com/23460729/120891750-7f247380-c60a-11eb-9998-3b3b7f61066f.png)
### Example:
Simple example. Globbing is supported.
```yaml
on:
pull_request:
push:
jobs:
build-test:
name: Build & Test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2 # checkout the repo
- name: Nextcloud Artifact
uses: trympet/nextcloud-artifacts-action@v2
with:
name: 'my-artifact' # Name of the artifact
path: 'bin/**/*.exe' # Globbing supported
nextcloud-url: 'https://nextcloud.example.com' # Nextcloud URL
nextcloud-username: ${{ secrets.NEXTCLOUD_USERNAME }} # Username from repository secret
nextcloud-password: ${{ secrets.NEXTCLOUD_PASSWORD }} # Password from repository secret
```

View File

@ -1,37 +0,0 @@
/* eslint-disable @typescript-eslint/no-non-null-assertion */
import { Inputs } from '../../src/Inputs'
import { NoFileOption } from '../../src/NoFileOption'
export class InputsDouble implements Inputs {
get ArtifactName(): string {
return process.env['ARTIFACT_NAME']!
}
get ArtifactPath(): string {
return process.env['ARTIFACT_PATH']!
}
get Retention(): string {
return ''
}
get Endpoint(): URL {
return new URL(process.env['ENDPOINT']!)
}
get Username(): string {
return process.env['USERNAME']!
}
get Password(): string {
return process.env['PASSWORD']!
}
get Token(): string {
return process.env['TOKEN']!
}
get NoFileBehvaior(): NoFileOption {
return NoFileOption.error
}
}

View File

@ -1,9 +0,0 @@
import { NextcloudArtifact } from '../src/nextcloud/NextcloudArtifact'
import { InputsDouble } from './doubles/InputsDouble'
describe('integration tests', () => {
it('works', async () => {
const artifact = new NextcloudArtifact(new InputsDouble())
await artifact.run()
})
})

View File

@ -1 +0,0 @@
require('dotenv').config()

View File

@ -11,12 +11,6 @@ inputs:
nextcloud-url: nextcloud-url:
description: 'The URL for the nextcloud server' description: 'The URL for the nextcloud server'
required: true required: true
nextcloud-username:
description: 'The username for the nextcloud user'
required: true
nextcloud-password:
description: 'The password for the nextcloud user'
required: true
if-no-files-found: if-no-files-found:
description: > description: >
The desired behavior if no files are found using the provided path. The desired behavior if no files are found using the provided path.
@ -25,10 +19,11 @@ inputs:
error: Fail the action with an error message error: Fail the action with an error message
ignore: Do not output any warnings or errors, the action does not fail ignore: Do not output any warnings or errors, the action does not fail
default: 'warn' default: 'warn'
token: retention-days:
description: GitHub Access Token description: >
required: false Duration after which artifact will expire in days. 0 means using default retention.
default: ${{ github.token }} Minimum 1 day.
Maximum 90 days unless changed from the repository settings page.
runs: runs:
using: 'node20' using: 'node12'
main: 'dist/index.js' main: 'dist/index.js'

45372
dist/index.js vendored

File diff suppressed because one or more lines are too long

1
dist/index.js.map vendored

File diff suppressed because one or more lines are too long

2386
dist/licenses.txt vendored

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

View File

@ -1,12 +0,0 @@
module.exports = {
clearMocks: true,
moduleFileExtensions: ['js', 'ts'],
testEnvironment: 'node',
testMatch: ['**/*.test.ts'],
testRunner: 'jest-circus/runner',
transform: {
'^.+\\.ts$': 'ts-jest'
},
verbose: true,
setupFiles: ["./__tests__/setup.ts"]
}

6533
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,70 +1,32 @@
{ {
"name": "nextcloud-artifacts-action", "name": "nextcloud-artifacts-action",
"version": "2.0.0", "version": "1.0.0",
"description": "", "description": "",
"main": "lib/nextcloud-artifacts.js", "main": "index.js",
"scripts": { "scripts": {
"build": "tsc", "test": "echo \"Error: no test specified\" && exit 1"
"format": "prettier --write **/*.ts",
"format-check": "prettier --check **/*.ts",
"lint": "eslint src/**/*.ts",
"package": "ncc build --source-map --license licenses.txt",
"test": "jest --ci"
}, },
"repository": { "repository": {
"type": "git", "type": "git",
"url": "git+https://github.com/trympet/nextcloud-artifacts-action.git" "url": "git+https://github.com/trympet/nextcloud-artifacts-action.git"
}, },
"keywords": [], "keywords": [],
"author": "Trym Lund Flogard <trym@flogard.no>", "author": "",
"license": "GPL-2.0-only", "license": "ISC",
"bugs": { "bugs": {
"url": "https://github.com/trympet/nextcloud-artifacts-action/issues" "url": "https://github.com/trympet/nextcloud-artifacts-action/issues"
}, },
"homepage": "https://github.com/trympet/nextcloud-artifacts-action#readme", "homepage": "https://github.com/trympet/nextcloud-artifacts-action#readme",
"dependencies": { "dependencies": {
"@actions/core": "^1.3.0", "@actions/core": "^1.3.0",
"@actions/exec": "^1.0.4",
"@actions/github": "^5.0.0",
"@actions/glob": "^0.1.2", "@actions/glob": "^0.1.2",
"archiver": "^5.3.0", "archiver": "^5.3.0",
"btoa": "^1.2.1", "node-fetch": "^2.6.1"
"node-fetch": "^2.6.1",
"uuid": "^8.3.2",
"webdav": "^4.6.0"
}, },
"devDependencies": { "devDependencies": {
"@octokit/types": "^6.16.2",
"@octokit/webhooks": "^9.6.3",
"@types/archiver": "^5.1.0", "@types/archiver": "^5.1.0",
"@types/btoa": "^1.2.3",
"@types/jest": "^26.0.23",
"@types/node": "^15.6.2", "@types/node": "^15.6.2",
"@types/node-fetch": "^2.5.10", "@types/node-fetch": "^2.5.10",
"@types/uuid": "^8.3.0",
"@typescript-eslint/eslint-plugin": "^4.16.1",
"@typescript-eslint/parser": "^4.16.1",
"@vercel/ncc": "^0.28.6",
"dotenv": "^10.0.0",
"eslint": "^7.21.0",
"eslint-plugin-github": "^4.1.3",
"eslint-plugin-jest": "^24.1.7",
"jest": "^26.6.3",
"jest-circus": "^26.6.3",
"jest-junit": "^12.0.0",
"js-yaml": "^4.0.0",
"prettier": "2.2.1",
"ts-jest": "^26.5.3",
"typescript": "^4.3.2" "typescript": "^4.3.2"
},
"jest-junit": {
"suiteName": "jest tests",
"outputDirectory": "__tests__/__results__",
"outputName": "jest-junit.xml",
"ancestorSeparator": " ",
"uniqueOutputName": "false",
"suiteNameTemplate": "{filepath}",
"classNameTemplate": "{classname}",
"titleTemplate": "{title}"
} }
} }

View File

@ -1,45 +0,0 @@
import * as core from '@actions/core'
import { NoFileOption } from './NoFileOption'
import { Inputs } from './Inputs'
import { URL } from 'url'
export class ActionInputs implements Inputs {
get ArtifactName(): string {
return core.getInput('name', { required: false }) || 'Nextcloud Artifact'
}
get ArtifactPath(): string {
return core.getInput('path', { required: true })
}
get Endpoint(): URL {
return new URL(core.getInput('nextcloud-url', { required: true }))
}
get Username(): string {
return core.getInput('nextcloud-username', { required: true })
}
get Password(): string {
return core.getInput('nextcloud-password', { required: true })
}
get Token(): string {
return core.getInput('token', { required: true })
}
get NoFileBehvaior(): NoFileOption {
const notFoundAction = core.getInput('if-no-files-found', { required: false }) || NoFileOption.warn
const noFileBehavior: NoFileOption = NoFileOption[notFoundAction as keyof typeof NoFileOption]
if (!noFileBehavior) {
core.setFailed(
`Unrecognized ${'ifNoFilesFound'} input. Provided: ${notFoundAction}. Available options: ${Object.keys(
NoFileOption
)}`
)
}
return noFileBehavior
}
}

View File

@ -1,8 +1,8 @@
import * as glob from '@actions/glob' import * as glob from '@actions/glob'
import { stat } from 'fs' import {stat} from 'fs'
import { debug, info } from '@actions/core' import {debug, info} from '@actions/core'
import * as path from 'path' import * as path from 'path'
import { promisify } from 'util' import {promisify} from 'util'
const stats = promisify(stat) const stats = promisify(stat)
export class FileFinder { export class FileFinder {
@ -10,17 +10,20 @@ export class FileFinder {
followSymbolicLinks: true, followSymbolicLinks: true,
implicitDescendants: true, implicitDescendants: true,
omitBrokenSymbolicLinks: true omitBrokenSymbolicLinks: true
} };
private globOptions: glob.GlobOptions private globOptions: glob.GlobOptions
constructor(private searchPath: string, globOptions?: glob.GlobOptions) { public constructor(private searchPath: string, globOptions?: glob.GlobOptions) {
this.globOptions = globOptions || FileFinder.DefaultGlobOptions this.globOptions = globOptions || FileFinder.DefaultGlobOptions;
} }
async findFiles() { public async findFiles() {
const searchResults: string[] = [] const searchResults: string[] = []
const globber = await glob.create(this.searchPath, this.globOptions) const globber = await glob.create(
this.searchPath,
this.globOptions
);
const rawSearchResults: string[] = await globber.glob() const rawSearchResults: string[] = await globber.glob()
@ -50,7 +53,9 @@ export class FileFinder {
set.add(searchResult.toLowerCase()) set.add(searchResult.toLowerCase())
} }
} else { } else {
debug(`Removing ${searchResult} from rawSearchResults because it is a directory`) debug(
`Removing ${searchResult} from rawSearchResults because it is a directory`
)
} }
} }
@ -58,9 +63,13 @@ export class FileFinder {
const searchPaths: string[] = globber.getSearchPaths() const searchPaths: string[] = globber.getSearchPaths()
if (searchPaths.length > 1) { if (searchPaths.length > 1) {
info(`Multiple search paths detected. Calculating the least common ancestor of all paths`) info(
`Multiple search paths detected. Calculating the least common ancestor of all paths`
)
const lcaSearchPath = this.getMultiPathLCA(searchPaths) const lcaSearchPath = this.getMultiPathLCA(searchPaths)
info(`The least common ancestor is ${lcaSearchPath}. This will be the root directory of the artifact`) info(
`The least common ancestor is ${lcaSearchPath}. This will be the root directory of the artifact`
)
return { return {
filesToUpload: searchResults, filesToUpload: searchResults,

View File

@ -1,18 +1,35 @@
import { URL } from 'url' import core from '@actions/core';
import { NoFileOption } from './NoFileOption' import { NoFileOption } from './NoFileOption';
export interface Inputs { export class Inputs {
readonly ArtifactName: string static get ArtifactName(): string {
return core.getInput("name");
}
readonly ArtifactPath: string static get ArtifactPath(): string {
return core.getInput("path");
}
readonly Endpoint: URL static get Retention(): string {
return core.getInput("retention-days");
}
readonly Username: string static get Endpoint(): string {
return core.getInput("nextcloud-url");
}
readonly Password: string static get NoFileBehvaior(): NoFileOption {
const notFoundAction = core.getInput("if-no-files-found");
const noFileBehavior: NoFileOption = NoFileOption[notFoundAction as keyof typeof NoFileOption];
readonly Token: string if (!noFileBehavior) {
core.setFailed(
`Unrecognized ${"ifNoFilesFound"} input. Provided: ${notFoundAction}. Available options: ${Object.keys(
NoFileOption
)}`
);
}
readonly NoFileBehvaior: NoFileOption return noFileBehavior;
}
} }

View File

@ -12,5 +12,5 @@ export enum NoFileOption {
/** /**
* Do not output any warnings or errors, the action does not fail * Do not output any warnings or errors, the action does not fail
*/ */
ignore = 'ignore' ignore = 'ignore',
} }

5
src/index.ts Normal file
View File

@ -0,0 +1,5 @@
import { Inputs } from './Inputs';
import { NextcloudArtifact } from './nextcloud/NextcloudArtifact';
var artifact = new NextcloudArtifact(Inputs.ArtifactName, Inputs.ArtifactPath, Inputs.NoFileBehvaior);
artifact.run();

View File

@ -1,15 +0,0 @@
import { NextcloudArtifact } from './nextcloud/NextcloudArtifact'
import * as core from '@actions/core'
import { ActionInputs } from './ActionInputs'
async function run() {
try {
const artifact = new NextcloudArtifact(new ActionInputs())
await artifact.run()
core.info('Finished')
} catch (error) {
core.setFailed(error.message)
}
}
run()

View File

@ -1,154 +1,63 @@
import * as core from '@actions/core' import core from '@actions/core';
import * as github from '@actions/github' import { FileFinder } from '../FileFinder';
import { GitHub } from '@actions/github/lib/utils' import { Inputs } from '../Inputs';
import { NextcloudClient } from './NextcloudClient';
import { FileFinder } from '../FileFinder' import { NoFileOption } from '../NoFileOption';
import { Inputs } from '../Inputs'
import { NextcloudClient } from './NextcloudClient'
import { NoFileOption } from '../NoFileOption'
export class NextcloudArtifact { export class NextcloudArtifact {
readonly octokit: InstanceType<typeof GitHub> public constructor(
readonly context = NextcloudArtifact.getCheckRunContext() private name: string,
readonly token: string private path: string,
readonly name: string private errorBehavior: NoFileOption) { }
readonly artifactTitle: string
readonly path: string
readonly errorBehavior: NoFileOption
constructor(private inputs: Inputs) { public async run() {
this.token = inputs.Token const fileFinder = new FileFinder(this.path);
this.name = inputs.ArtifactName const files = await fileFinder.findFiles();
this.artifactTitle = `Nextcloud - ${this.name}`
this.path = inputs.ArtifactPath
this.errorBehavior = inputs.NoFileBehvaior
this.name = inputs.ArtifactName
this.octokit = github.getOctokit(this.token)
}
async run() {
const fileFinder = new FileFinder(this.path)
const files = await fileFinder.findFiles()
if (files.filesToUpload.length > 0) { if (files.filesToUpload.length > 0) {
await this.uploadFiles(files) await this.uploadFiles(files);
} else { }
this.logNoFilesFound() else {
this.logNoFilesFound();
} }
} }
private static getCheckRunContext(): { sha: string; runId: number } { private async uploadFiles(files: { filesToUpload: string[]; rootDirectory: string; }) {
if (github.context.eventName === 'workflow_run') { this.logUpload(files.filesToUpload.length, files.rootDirectory);
core.info('Action was triggered by workflow_run: using SHA and RUN_ID from triggering workflow')
const event = github.context.payload
if (!event.workflow_run) {
throw new Error("Event of type 'workflow_run' is missing 'workflow_run' field")
}
return {
sha: event.workflow_run.head_commit.id,
runId: event.workflow_run.id
}
}
const runId = github.context.runId const client = new NextcloudClient(Inputs.Endpoint, this.name, files.rootDirectory);
if (github.context.payload.pull_request) {
core.info(`Action was triggered by ${github.context.eventName}: using SHA from head of source branch`)
const pr = github.context.payload.pull_request
return { sha: pr.head.sha, runId }
}
return { sha: github.context.sha, runId } await client.uploadFiles(files.filesToUpload);
}
private async uploadFiles(files: { filesToUpload: string[]; rootDirectory: string }) {
this.logUpload(files.filesToUpload.length, files.rootDirectory)
const createResp = await this.octokit.rest.checks.create({
head_sha: this.context.sha,
name: this.artifactTitle,
status: 'in_progress',
output: {
title: `Nextcloud - ${this.name}`,
summary: 'Uploading...'
},
...github.context.repo
})
const client = new NextcloudClient(
this.inputs.Endpoint,
this.name,
files.rootDirectory,
this.inputs.Username,
this.inputs.Password
)
try {
const shareableUrl = await client.uploadFiles(files.filesToUpload)
core.setOutput('SHAREABLE_URL', shareableUrl)
core.info(`Nextcloud shareable URL: ${shareableUrl}`)
const resp = await this.octokit.rest.checks.update({
check_run_id: createResp.data.id,
conclusion: 'success',
status: 'completed',
output: {
title: this.artifactTitle,
summary: shareableUrl
},
...github.context.repo
})
core.info(`Check run create response: ${resp.status}`)
core.info(`Check run URL: ${resp.data.url}`)
core.info(`Check run HTML: ${resp.data.html_url}`)
} catch (error) {
await this.trySetFailed(createResp.data.id)
core.setFailed(error)
}
}
private async trySetFailed(checkId: number) {
try {
await this.octokit.rest.checks.update({
check_run_id: checkId,
conclusion: 'failure',
status: 'completed',
output: {
title: this.artifactTitle,
summary: 'Check failed.'
},
...github.context.repo
})
return true
} catch (error) {
core.error(`Failed to update check status to failure`)
return false
}
} }
private logUpload(fileCount: number, rootDirectory: string) { private logUpload(fileCount: number, rootDirectory: string) {
const s = fileCount === 1 ? '' : 's' const s = fileCount === 1 ? '' : 's';
core.info(`With the provided path, there will be ${fileCount} file${s} uploaded`) core.info(
core.debug(`Root artifact directory is ${rootDirectory}`) `With the provided path, there will be ${fileCount} file${s} uploaded`
);
core.debug(`Root artifact directory is ${rootDirectory}`);
if (fileCount > 10000) { if (fileCount > 10000) {
core.warning( core.warning(
`There are over 10,000 files in this artifact, consider create an archive before upload to improve the upload performance.` `There are over 10,000 files in this artifact, consider create an archive before upload to improve the upload performance.`
) );
} }
} }
private logNoFilesFound() { private logNoFilesFound() {
const errorMessage = `No files were found with the provided path: ${this.path}. No artifacts will be uploaded.` const errorMessage = `No files were found with the provided path: ${this.path}. No artifacts will be uploaded.`;
switch (this.errorBehavior) { switch (this.errorBehavior) {
case NoFileOption.warn: { case NoFileOption.warn: {
core.warning(errorMessage) core.warning(errorMessage);
break break;
} }
case NoFileOption.error: { case NoFileOption.error: {
core.setFailed(errorMessage) core.setFailed(errorMessage);
break break;
} }
case NoFileOption.ignore: { case NoFileOption.ignore: {
core.info(errorMessage) core.info(errorMessage);
break break;
} }
} }
} }

View File

@ -1,167 +1,132 @@
import * as fsSync from 'fs' import * as fsSync from 'fs'
import * as fs from 'fs/promises'
import * as path from 'path' import * as path from 'path'
import * as core from '@actions/core' import core from '@actions/core';
import * as os from 'os' import * as os from 'os';
import * as archiver from 'archiver' import { randomUUID } from 'crypto';
import fetch, { HeadersInit } from 'node-fetch' import * as archiver from 'archiver';
import btoa from 'btoa' import { URL } from 'url';
import { v4 as uuidv4 } from 'uuid' import fetch from 'node-fetch';
import * as webdav from 'webdav'
import { URL } from 'url'
const fs = fsSync.promises
interface FileSpec { interface FileSpec {
absolutePath: string absolutePath: string,
uploadPath: string uploadPath: string
} }
export class NextcloudClient { export class NextcloudClient {
private guid: string public constructor(
private headers: HeadersInit private endpoint: string,
private davClient
constructor(
private endpoint: URL,
private artifact: string, private artifact: string,
private rootDirectory: string, private rootDirectory: string) { }
private username: string,
private password: string
) {
this.guid = uuidv4()
this.headers = { Authorization: 'Basic ' + Buffer.from(`${this.username}:${this.password}`).toString('base64') }
this.davClient = webdav.createClient(`${this.endpoint.href}remote.php/dav/files/${this.username}`, {
username: this.username,
password: this.password,
maxBodyLength: 1024 ** 3
})
}
async uploadFiles(files: string[]): Promise<string> { public async uploadFiles(files: string[]) {
core.info('Preparing upload...') const spec = this.uploadSpec(files);
const spec = this.uploadSpec(files) var zip = await this.zipFiles(spec);
core.info('Zipping files...') await this.upload(zip);
const zip = await this.zipFiles(spec)
try {
core.info('Uploading to Nextcloud...')
const filePath = await this.upload(zip)
core.info(`Remote file path: ${filePath}`)
return await this.shareFile(filePath)
} finally {
await fs.unlink(zip)
}
} }
private uploadSpec(files: string[]): FileSpec[] { private uploadSpec(files: string[]): FileSpec[] {
const specifications = [] const specifications = [];
if (!fsSync.existsSync(this.rootDirectory)) { if (!fsSync.existsSync(this.rootDirectory)) {
throw new Error(`this.rootDirectory ${this.rootDirectory} does not exist`) throw new Error(`this.rootDirectory ${this.rootDirectory} does not exist`);
} }
if (!fsSync.lstatSync(this.rootDirectory).isDirectory()) { if (!fsSync.lstatSync(this.rootDirectory).isDirectory()) {
throw new Error(`this.rootDirectory ${this.rootDirectory} is not a valid directory`) throw new Error(`this.rootDirectory ${this.rootDirectory} is not a valid directory`);
} }
let root = path.normalize(this.rootDirectory) // Normalize and resolve, this allows for either absolute or relative paths to be used
root = path.resolve(root) let root = path.normalize(this.rootDirectory);
root = path.resolve(root);
/*
Example to demonstrate behavior
Input:
artifactName: my-artifact
rootDirectory: '/home/user/files/plz-upload'
artifactFiles: [
'/home/user/files/plz-upload/file1.txt',
'/home/user/files/plz-upload/file2.txt',
'/home/user/files/plz-upload/dir/file3.txt'
]
Output:
specifications: [
['/home/user/files/plz-upload/file1.txt', 'my-artifact/file1.txt'],
['/home/user/files/plz-upload/file1.txt', 'my-artifact/file2.txt'],
['/home/user/files/plz-upload/file1.txt', 'my-artifact/dir/file3.txt']
]
*/
for (let file of files) { for (let file of files) {
if (!fsSync.existsSync(file)) { if (!fsSync.existsSync(file)) {
throw new Error(`File ${file} does not exist`) throw new Error(`File ${file} does not exist`);
} }
if (!fsSync.lstatSync(file).isDirectory()) { if (!fsSync.lstatSync(file).isDirectory()) {
file = path.normalize(file) // Normalize and resolve, this allows for either absolute or relative paths to be used
file = path.resolve(file) file = path.normalize(file);
file = path.resolve(file);
if (!file.startsWith(root)) { if (!file.startsWith(root)) {
throw new Error(`The rootDirectory: ${root} is not a parent directory of the file: ${file}`) throw new Error(`The rootDirectory: ${root} is not a parent directory of the file: ${file}`);
} }
// Check for forbidden characters in file paths that will be rejected during upload
const uploadPath = file.replace(root, '');
/*
uploadFilePath denotes where the file will be uploaded in the file container on the server. During a run, if multiple artifacts are uploaded, they will all
be saved in the same container. The artifact name is used as the root directory in the container to separate and distinguish uploaded artifacts
const uploadPath = file.replace(root, '') path.join handles all the following cases and would return 'artifact-name/file-to-upload.txt
join('artifact-name/', 'file-to-upload.txt')
join('artifact-name/', '/file-to-upload.txt')
join('artifact-name', 'file-to-upload.txt')
join('artifact-name', '/file-to-upload.txt')
*/
specifications.push({ specifications.push({
absolutePath: file, absolutePath: file,
uploadPath: path.join(this.artifact, uploadPath) uploadPath: path.join(this.artifact, uploadPath)
}) });
} else { }
core.debug(`Removing ${file} from rawSearchResults because it is a directory`) else {
// Directories are rejected by the server during upload
core.debug(`Removing ${file} from rawSearchResults because it is a directory`);
} }
} }
return specifications return specifications;
} }
private async zipFiles(specs: FileSpec[]): Promise<string> { private async zipFiles(specs: FileSpec[]): Promise<string> {
const tempArtifactDir = path.join(os.tmpdir(), this.guid) const tempArtifactDir = path.join(os.tmpdir(), randomUUID());
const artifactPath = path.join(tempArtifactDir, `artifact-${this.artifact}`) const artifactPath = path.join(tempArtifactDir, `artifact-${this.artifact}`);
await fs.mkdir(path.join(artifactPath, this.artifact), { recursive: true }) await fs.mkdir(artifactPath, { recursive: true });
const copies = [] for (let spec of specs) {
for (const spec of specs) { await fs.copyFile(spec.absolutePath, path.join(artifactPath, spec.uploadPath));
const dstpath = path.join(artifactPath, spec.uploadPath)
const dstDir = path.dirname(dstpath)
if (!fsSync.existsSync(dstDir)) {
await fs.mkdir(dstDir, { recursive: true })
} }
copies.push(fs.copyFile(spec.absolutePath, dstpath)) const archivePath = path.join(artifactPath, `${this.artifact}.zip`);
} await this.zip(path.join(artifactPath, this.artifact), archivePath);
await Promise.all(copies) return archivePath;
core.info(`files: ${await fs.readdir(path.join(artifactPath, this.artifact))}`)
const archivePath = path.join(artifactPath, `${this.artifact}.zip`)
await this.zip(path.join(artifactPath, this.artifact), archivePath)
return archivePath
} }
private async zip(dirpath: string, destpath: string) { private async zip(dirpath: string, destpath: string) {
const archive = archiver.create('zip', { zlib: { level: 9 } }) const archive = archiver.create('zip', { zlib: { level: 9 } });
const stream = archive.directory(dirpath, false).pipe(fsSync.createWriteStream(destpath)) const stream = fsSync.createWriteStream(destpath);
archive.directory(dirpath, false)
.on('error', e => Promise.reject())
.on('close', () => Promise.resolve())
.pipe(stream);
await archive.finalize() return archive.finalize();
return await new Promise<void>((resolve, reject) => {
stream.on('error', e => reject(e)).on('close', () => resolve())
})
} }
private async upload(file: string): Promise<string> { private async upload(file: string) {
const remoteFileDir = `/artifacts/${this.guid}` const url = new URL(this.endpoint, '/remote.php/dav/files/user/path/to/file');
if (!(await this.davClient.exists(remoteFileDir))) { const stream = fsSync.createReadStream(file);
await this.davClient.createDirectory(remoteFileDir, { recursive: true }) await fetch(url.href, {
method: 'PUT',
body: stream
});
} }
const remoteFilePath = `${remoteFileDir}/${this.artifact}.zip` private shareFile() {
core.debug(`Transferring file... (${file})`)
await this.davClient.putFileContents(remoteFilePath, await fs.readFile(file))
return remoteFilePath
}
private async shareFile(remoteFilePath: string): Promise<string> {
const url = `${this.endpoint.href}ocs/v2.php/apps/files_sharing/api/v1/shares`
const body = {
path: remoteFilePath,
shareType: 3,
publicUpload: 'false',
permissions: 1
}
const res = await fetch(url, {
method: 'POST',
headers: Object.assign(this.headers, {
'OCS-APIRequest': true,
'Content-Type': 'application/json'
}),
body: JSON.stringify(body)
})
const result = await res.text()
core.debug(`Share response: ${result}`)
const re = /<url>(?<share_url>.*)<\/url>/
const match = re.exec(result)
core.debug(`Match groups:\n${JSON.stringify(match?.groups)}`)
const sharableUrl = (match?.groups || {})['share_url']
if (!sharableUrl) {
throw new Error(`Failed to parse or find sharable URL:\n${result}`)
}
return sharableUrl
} }
} }

View File

@ -1,16 +1,14 @@
{ {
"compilerOptions": { "compilerOptions": {
"target": "ES2019", /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019', 'ES2020', 'ES2021', or 'ESNEXT'. */ "target": "es6", /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019', 'ES2020', 'ES2021', or 'ESNEXT'. */
"module": "commonjs", /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', 'es2020', or 'ESNext'. */ "module": "commonjs", /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', 'es2020', or 'ESNext'. */
"strict": true, /* Enable all strict type-checking options. */ "strict": true, /* Enable all strict type-checking options. */
"esModuleInterop": true, /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */ "esModuleInterop": true, /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */
"skipLibCheck": true, /* Skip type checking of declaration files. */ "skipLibCheck": true, /* Skip type checking of declaration files. */
"forceConsistentCasingInFileNames": true, /* Disallow inconsistently-cased references to the same file. */ "forceConsistentCasingInFileNames": true, /* Disallow inconsistently-cased references to the same file. */
"noImplicitAny": true,
"rootDir": "src", "rootDir": "src",
"outDir": "lib", "outDir": "dist",
"sourceMap": true, "sourceMap": true,
"lib": ["ES2019"] "lib": ["es6"]
}, }
"exclude": ["node_modules", "__tests__/**/*.ts"]
} }

View File

@ -1,8 +0,0 @@
{
"folders": [
{
"path": "."
}
],
"settings": {}
}