mirror of
https://github.com/trympet/nextcloud-artifacts-action.git
synced 2025-07-26 19:03:17 +02:00
add nextcloud support
This commit is contained in:
146
src/FileFinder.ts
Normal file
146
src/FileFinder.ts
Normal file
@@ -0,0 +1,146 @@
|
||||
import * as glob from '@actions/glob'
|
||||
import {stat} from 'fs'
|
||||
import {debug, info} from '@actions/core'
|
||||
import * as path from 'path'
|
||||
import {promisify} from 'util'
|
||||
const stats = promisify(stat)
|
||||
|
||||
export class FileFinder {
|
||||
private static DefaultGlobOptions: glob.GlobOptions = {
|
||||
followSymbolicLinks: true,
|
||||
implicitDescendants: true,
|
||||
omitBrokenSymbolicLinks: true
|
||||
};
|
||||
|
||||
private globOptions: glob.GlobOptions
|
||||
|
||||
public constructor(private searchPath: string, globOptions?: glob.GlobOptions) {
|
||||
this.globOptions = globOptions || FileFinder.DefaultGlobOptions;
|
||||
}
|
||||
|
||||
public async findFiles() {
|
||||
const searchResults: string[] = []
|
||||
const globber = await glob.create(
|
||||
this.searchPath,
|
||||
this.globOptions
|
||||
);
|
||||
|
||||
const rawSearchResults: string[] = await globber.glob()
|
||||
|
||||
/*
|
||||
Files are saved with case insensitivity. Uploading both a.txt and A.txt will files to be overwritten
|
||||
Detect any files that could be overwritten for user awareness
|
||||
*/
|
||||
const set = new Set<string>()
|
||||
|
||||
/*
|
||||
Directories will be rejected if attempted to be uploaded. This includes just empty
|
||||
directories so filter any directories out from the raw search results
|
||||
*/
|
||||
for (const searchResult of rawSearchResults) {
|
||||
const fileStats = await stats(searchResult)
|
||||
// isDirectory() returns false for symlinks if using fs.lstat(), make sure to use fs.stat() instead
|
||||
if (!fileStats.isDirectory()) {
|
||||
debug(`File:${searchResult} was found using the provided searchPath`)
|
||||
searchResults.push(searchResult)
|
||||
|
||||
// detect any files that would be overwritten because of case insensitivity
|
||||
if (set.has(searchResult.toLowerCase())) {
|
||||
info(
|
||||
`Uploads are case insensitive: ${searchResult} was detected that it will be overwritten by another file with the same path`
|
||||
)
|
||||
} else {
|
||||
set.add(searchResult.toLowerCase())
|
||||
}
|
||||
} else {
|
||||
debug(
|
||||
`Removing ${searchResult} from rawSearchResults because it is a directory`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate the root directory for the artifact using the search paths that were utilized
|
||||
const searchPaths: string[] = globber.getSearchPaths()
|
||||
|
||||
if (searchPaths.length > 1) {
|
||||
info(
|
||||
`Multiple search paths detected. Calculating the least common ancestor of all paths`
|
||||
)
|
||||
const lcaSearchPath = this.getMultiPathLCA(searchPaths)
|
||||
info(
|
||||
`The least common ancestor is ${lcaSearchPath}. This will be the root directory of the artifact`
|
||||
)
|
||||
|
||||
return {
|
||||
filesToUpload: searchResults,
|
||||
rootDirectory: lcaSearchPath
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
Special case for a single file artifact that is uploaded without a directory or wildcard pattern. The directory structure is
|
||||
not preserved and the root directory will be the single files parent directory
|
||||
*/
|
||||
if (searchResults.length === 1 && searchPaths[0] === searchResults[0]) {
|
||||
return {
|
||||
filesToUpload: searchResults,
|
||||
rootDirectory: path.dirname(searchResults[0])
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
filesToUpload: searchResults,
|
||||
rootDirectory: searchPaths[0]
|
||||
}
|
||||
}
|
||||
|
||||
private getMultiPathLCA(searchPaths: string[]): string {
|
||||
if (searchPaths.length < 2) {
|
||||
throw new Error('At least two search paths must be provided')
|
||||
}
|
||||
|
||||
const commonPaths = new Array<string>()
|
||||
const splitPaths = new Array<string[]>()
|
||||
let smallestPathLength = Number.MAX_SAFE_INTEGER
|
||||
|
||||
// split each of the search paths using the platform specific separator
|
||||
for (const searchPath of searchPaths) {
|
||||
debug(`Using search path ${searchPath}`)
|
||||
|
||||
const splitSearchPath = path.normalize(searchPath).split(path.sep)
|
||||
|
||||
// keep track of the smallest path length so that we don't accidentally later go out of bounds
|
||||
smallestPathLength = Math.min(smallestPathLength, splitSearchPath.length)
|
||||
splitPaths.push(splitSearchPath)
|
||||
}
|
||||
|
||||
// on Unix-like file systems, the file separator exists at the beginning of the file path, make sure to preserve it
|
||||
if (searchPaths[0].startsWith(path.sep)) {
|
||||
commonPaths.push(path.sep)
|
||||
}
|
||||
|
||||
let splitIndex = 0
|
||||
// function to check if the paths are the same at a specific index
|
||||
function isPathTheSame(): boolean {
|
||||
const compare = splitPaths[0][splitIndex]
|
||||
for (let i = 1; i < splitPaths.length; i++) {
|
||||
if (compare !== splitPaths[i][splitIndex]) {
|
||||
// a non-common index has been reached
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// loop over all the search paths until there is a non-common ancestor or we go out of bounds
|
||||
while (splitIndex < smallestPathLength) {
|
||||
if (!isPathTheSame()) {
|
||||
break
|
||||
}
|
||||
// if all are the same, add to the end result & increment the index
|
||||
commonPaths.push(splitPaths[0][splitIndex])
|
||||
splitIndex++
|
||||
}
|
||||
return path.join(...commonPaths)
|
||||
}
|
||||
}
|
35
src/Inputs.ts
Normal file
35
src/Inputs.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import core from '@actions/core';
|
||||
import { NoFileOption } from './NoFileOption';
|
||||
|
||||
export class Inputs {
|
||||
static get ArtifactName(): string {
|
||||
return core.getInput("name");
|
||||
}
|
||||
|
||||
static get ArtifactPath(): string {
|
||||
return core.getInput("path");
|
||||
}
|
||||
|
||||
static get Retention(): string {
|
||||
return core.getInput("retention-days");
|
||||
}
|
||||
|
||||
static get Endpoint(): string {
|
||||
return core.getInput("retention-days");
|
||||
}
|
||||
|
||||
static get NoFileBehvaior(): NoFileOption {
|
||||
const notFoundAction = core.getInput("if-no-files-found");
|
||||
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;
|
||||
}
|
||||
}
|
16
src/NoFileOption.ts
Normal file
16
src/NoFileOption.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
export enum NoFileOption {
|
||||
/**
|
||||
* Default. Output a warning but do not fail the action
|
||||
*/
|
||||
warn = 'warn',
|
||||
|
||||
/**
|
||||
* Fail the action with an error message
|
||||
*/
|
||||
error = 'error',
|
||||
|
||||
/**
|
||||
* Do not output any warnings or errors, the action does not fail
|
||||
*/
|
||||
ignore = 'ignore',
|
||||
}
|
@@ -1 +1,5 @@
|
||||
console.log("hello world");
|
||||
import { Inputs } from './Inputs';
|
||||
import { NextcloudArtifact } from './nextcloud/NextcloudArtifact';
|
||||
|
||||
var artifact = new NextcloudArtifact(Inputs.ArtifactName, Inputs.ArtifactPath, Inputs.NoFileBehvaior);
|
||||
artifact.run();
|
64
src/nextcloud/NextcloudArtifact.ts
Normal file
64
src/nextcloud/NextcloudArtifact.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import core from '@actions/core';
|
||||
import { FileFinder } from '../FileFinder';
|
||||
import { Inputs } from '../Inputs';
|
||||
import { NextcloudClient } from './NextcloudClient';
|
||||
import { NoFileOption } from '../NoFileOption';
|
||||
|
||||
export class NextcloudArtifact {
|
||||
public constructor(
|
||||
private name: string,
|
||||
private path: string,
|
||||
private errorBehavior: NoFileOption) { }
|
||||
|
||||
public async run() {
|
||||
const fileFinder = new FileFinder(this.path);
|
||||
const files = await fileFinder.findFiles();
|
||||
|
||||
if (files.filesToUpload.length > 0) {
|
||||
await this.uploadFiles(files);
|
||||
}
|
||||
else {
|
||||
this.logNoFilesFound();
|
||||
}
|
||||
}
|
||||
|
||||
private async uploadFiles(files: { filesToUpload: string[]; rootDirectory: string; }) {
|
||||
this.logUpload(files.filesToUpload.length, files.rootDirectory);
|
||||
|
||||
const client = new NextcloudClient(Inputs.Endpoint, this.name, files.rootDirectory);
|
||||
|
||||
await client.uploadFiles(files.filesToUpload);
|
||||
}
|
||||
|
||||
private logUpload(fileCount: number, rootDirectory: string) {
|
||||
const s = fileCount === 1 ? '' : 's';
|
||||
core.info(
|
||||
`With the provided path, there will be ${fileCount} file${s} uploaded`
|
||||
);
|
||||
core.debug(`Root artifact directory is ${rootDirectory}`);
|
||||
|
||||
if (fileCount > 10000) {
|
||||
core.warning(
|
||||
`There are over 10,000 files in this artifact, consider create an archive before upload to improve the upload performance.`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private logNoFilesFound() {
|
||||
const errorMessage = `No files were found with the provided path: ${this.path}. No artifacts will be uploaded.`;
|
||||
switch (this.errorBehavior) {
|
||||
case NoFileOption.warn: {
|
||||
core.warning(errorMessage);
|
||||
break;
|
||||
}
|
||||
case NoFileOption.error: {
|
||||
core.setFailed(errorMessage);
|
||||
break;
|
||||
}
|
||||
case NoFileOption.ignore: {
|
||||
core.info(errorMessage);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
132
src/nextcloud/NextcloudClient.ts
Normal file
132
src/nextcloud/NextcloudClient.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
import * as fsSync from 'fs'
|
||||
import * as fs from 'fs/promises'
|
||||
import * as path from 'path'
|
||||
import core from '@actions/core';
|
||||
import * as os from 'os';
|
||||
import { randomUUID } from 'crypto';
|
||||
import * as archiver from 'archiver';
|
||||
import { URL } from 'url';
|
||||
import fetch from 'node-fetch';
|
||||
|
||||
interface FileSpec {
|
||||
absolutePath: string,
|
||||
uploadPath: string
|
||||
}
|
||||
|
||||
export class NextcloudClient {
|
||||
public constructor(
|
||||
private endpoint: string,
|
||||
private artifact: string,
|
||||
private rootDirectory: string) { }
|
||||
|
||||
public async uploadFiles(files: string[]) {
|
||||
const spec = this.uploadSpec(files);
|
||||
var zip = await this.zipFiles(spec);
|
||||
await this.upload(zip);
|
||||
}
|
||||
|
||||
private uploadSpec(files: string[]): FileSpec[] {
|
||||
const specifications = [];
|
||||
if (!fsSync.existsSync(this.rootDirectory)) {
|
||||
throw new Error(`this.rootDirectory ${this.rootDirectory} does not exist`);
|
||||
}
|
||||
if (!fsSync.lstatSync(this.rootDirectory).isDirectory()) {
|
||||
throw new Error(`this.rootDirectory ${this.rootDirectory} is not a valid directory`);
|
||||
}
|
||||
// Normalize and resolve, this allows for either absolute or relative paths to be used
|
||||
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) {
|
||||
if (!fsSync.existsSync(file)) {
|
||||
throw new Error(`File ${file} does not exist`);
|
||||
}
|
||||
if (!fsSync.lstatSync(file).isDirectory()) {
|
||||
// Normalize and resolve, this allows for either absolute or relative paths to be used
|
||||
file = path.normalize(file);
|
||||
file = path.resolve(file);
|
||||
if (!file.startsWith(root)) {
|
||||
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
|
||||
|
||||
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({
|
||||
absolutePath: file,
|
||||
uploadPath: path.join(this.artifact, uploadPath)
|
||||
});
|
||||
}
|
||||
else {
|
||||
// Directories are rejected by the server during upload
|
||||
core.debug(`Removing ${file} from rawSearchResults because it is a directory`);
|
||||
}
|
||||
}
|
||||
return specifications;
|
||||
}
|
||||
|
||||
|
||||
private async zipFiles(specs: FileSpec[]): Promise<string> {
|
||||
const tempArtifactDir = path.join(os.tmpdir(), randomUUID());
|
||||
const artifactPath = path.join(tempArtifactDir, `artifact-${this.artifact}`);
|
||||
await fs.mkdir(artifactPath, { recursive: true });
|
||||
for (let spec of specs) {
|
||||
await fs.copyFile(spec.absolutePath, path.join(artifactPath, spec.uploadPath));
|
||||
}
|
||||
|
||||
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) {
|
||||
const archive = archiver.create('zip', { zlib: { level: 9 } });
|
||||
const stream = fsSync.createWriteStream(destpath);
|
||||
archive.directory(dirpath, false)
|
||||
.on('error', e => Promise.reject())
|
||||
.on('close', () => Promise.resolve())
|
||||
.pipe(stream);
|
||||
|
||||
return archive.finalize();
|
||||
}
|
||||
|
||||
private async upload(file: string) {
|
||||
const url = new URL(this.endpoint, '/remote.php/dav/files/user/path/to/file');
|
||||
const stream = fsSync.createReadStream(file);
|
||||
await fetch(url.href, {
|
||||
method: 'PUT',
|
||||
body: stream
|
||||
});
|
||||
}
|
||||
|
||||
private shareFile() {
|
||||
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user