From 446c4dc851c363728cb9a00aabd55bca2673b548 Mon Sep 17 00:00:00 2001 From: Kenneth Jao Date: Thu, 5 Jun 2025 05:36:06 -0400 Subject: [PATCH] feat: added file uploading API, Gitea action, and description in README --- README.md | 20 ++++++++++++++ action.yml | 51 ++++++++++++++++++++++++++++++++++ src/api.rs | 80 +++++++++++++++++++++++++++++++++++++++++++++--------- 3 files changed, 138 insertions(+), 13 deletions(-) create mode 100644 action.yml diff --git a/README.md b/README.md index 9c96972..9523d6c 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,25 @@ permissions, and make sure the upload path has permissions `750`. Indeed, all subdirectories are created with this permissions, and all files are created with `640`. +## Actions +The repository also includes a Gitea action. The arguments are + +```yaml +inputs: + file: ... # The file to upload. + feature: ... # The feature category for pack, i.e docs. + secret: ${{ secrets.PackRepo }} # The authorization secret for this repository. + prefix: ... # An optional prefix for the filename. + is_folder: ... # Specify as 'true' if you are deliberately uploading a folder. +``` + +Pack chooses to enforce naming schemes depending on the type of trigger, +looking at the `gitea.ref` context variable, which is the fully formed +reference. In the case this is a git reference, +the filename will be `{prefix}-{ref_name}-{sha}`. For actions triggered by +releases, the `gitea.ref` is defined as the release tag, and the filename becomes +`{prefix}-{release}`. + ## API The only API endpoint intended to be exposed is `/api/files/{owner}/{repo}/{*path}`, which @@ -36,6 +55,7 @@ is shown below. }, ... ] +} ``` If the requested path is a file, it will redirect to download the file. If using `curl`, diff --git a/action.yml b/action.yml new file mode 100644 index 0000000..d7934ab --- /dev/null +++ b/action.yml @@ -0,0 +1,51 @@ +name: 'pack' +description: 'Use pack to upload a package' +inputs: + file: + description: 'the file to upload' + required: true + feature: + description: 'the feature to upload to, i.e docs, nightly' + required: true + secret: + description: 'the secret for pack' + required: true + prefix: + description: 'an optional prefix for the filename' + required: false + default: '' + is_folder: + description: 'specify as "true" if uploading a folder' + required: false + default: false +runs: + using: 'composite' + steps: + - run: if [ ${{ inputs.is_folder }} == true ] && [ ! -d ${{ inputs.file }} ]; then echo "Not a folder!"; exit 1; fi + - run: if [ ${{ inputs.is_folder }} == false ] && [ ! -f ${{ inputs.file }} ]; then echo "Not a file!"; exit 1; fi + - run: | + if [ ${{ inputs.is_folder }} == true ]; then \ + echo "Packaging folder..." && + cd ${{ inputs.file }} && + tar -czf file.tgz * && + echo "Uploading '${{ inputs.file }}' to '${{ vars.PACK_HOST }}/api/upload/${{ gitea.repository }}/${{ inputs.feature }}'" && + curl -v -H 'Authorization: Bearer ${{ inputs.secret }}' \ + -F sha=${{ gitea.sha }} \ + -F ref='${{ gitea.ref }}' \ + -F prefix='${{ inputs.prefix }}' \ + -F filename=file.tgz \ + -F data=@file.tgz \ + -F is_folder='${{ inputs.is_folder }}' \ + ${{ vars.PACK_HOST }}/api/upload/${{ gitea.repository }}/${{ inputs.feature }} + else + echo "Uploading '${{ inputs.file }}' to '${{ vars.PACK_HOST }}/api/upload/${{ gitea.repository }}/${{ inputs.feature }}'" && + curl -v -H 'Authorization: Bearer ${{ inputs.secret }}' \ + -F sha=${{ gitea.sha }} \ + -F ref='${{ gitea.ref }}' \ + -F prefix='${{ inputs.prefix }}' \ + -F filename='${{ inputs.file }}' \ + -F data=@'${{ inputs.file }}' \ + -F is_folder='${{ inputs.is_folder }}' \ + ${{ vars.PACK_HOST }}/api/upload/${{ gitea.repository }}/${{ inputs.feature }} + fi + - run: echo "Upload complete" \ No newline at end of file diff --git a/src/api.rs b/src/api.rs index 7c18a2d..09c3274 100644 --- a/src/api.rs +++ b/src/api.rs @@ -119,6 +119,8 @@ pub enum ApiError { #[error("A token was not provided or is malformed")] Tokenless, #[error("{0}")] + InvalidUpload(&'static str), + #[error("{0}")] CustomError(#[from] CustomError) } @@ -130,6 +132,7 @@ static ERR_FILE_CALL_UTF: CustomError = CustomError("file command output failed static ERR_FILE_CALL_UNEXPECTED: CustomError = CustomError("file command output had unexpected output", ErrorCode::Filesystem); pub static ERR_CONTENT_FOLDER_RENDER: CustomError = CustomError("Unable to process 'ContentFolder' template", ErrorCode::Filesystem); static ERR_IMPOSSIBLE_ENTRY: CustomError = CustomError("Entry is neither file, directory or symlink", ErrorCode::Filesystem); +static ERR_FILE_EXISTS: CustomError = CustomError("File already exists, cannot upload", ErrorCode::BadRequest); #[derive(Serialize)] pub struct ErrorResponse { @@ -158,6 +161,7 @@ impl IntoResponse for ApiError { ApiError::DisabledFeature{..} => (StatusCode::NOT_FOUND, ErrorCode::BadRequest), ApiError::Unauthorized{..} => (StatusCode::FORBIDDEN, ErrorCode::BadRequest), ApiError::Tokenless => (StatusCode::FORBIDDEN, ErrorCode::InvalidToken), + ApiError::InvalidUpload(_) => (StatusCode::BAD_REQUEST, ErrorCode::BadRequest), ApiError::CustomError(ref e) => (StatusCode::INTERNAL_SERVER_ERROR, e.1), }; (status, Json(ErrorResponse{ @@ -368,7 +372,7 @@ pub async fn create_repo(State(state): State>, } // Add secret to Gitea secrets. - let secret_url = format!("{}/api/v1/repos/{}/actions/secrets/PACK_REPO_SECRET", &state.config.gitea_host, &repo); + let secret_url = format!("{}/api/v1/repos/{}/actions/secrets/PackRepo", &state.config.gitea_host, &repo); let data = GiteaSetSecret { data: secret }; let _: Empty = gitea_api(secret_url.as_str(), Method::PUT, &data, &token).await?; @@ -423,7 +427,7 @@ async fn update_secret(state: &ServerState<'_>, repo: &str, token: &str) -> Resu .map(|_| 0) .sum(); // Evaluate statement. - let secret_url = format!("{}/api/v1/repos/{}/actions/secrets/PACK_REPO_SECRET", &state.config.gitea_host, repo); + let secret_url = format!("{}/api/v1/repos/{}/actions/secrets/PackRepo", &state.config.gitea_host, repo); let data = GiteaSetSecret { data: secret }; let _: Empty = gitea_api(secret_url.as_str(), Method::PUT, &data, token).await?; Ok(()) @@ -582,11 +586,14 @@ pub async fn delete_entry(State(state): State>, Ok(StatusCode::OK) } -#[derive(Default)] +#[derive(Default, Debug)] struct UploadFile { - name: String, + prefix: String, + git_ref: String, + sha: String, + extension: String, data: Bytes, - folder: String, + is_folder: bool, } pub async fn upload(State(state): State>, @@ -615,28 +622,75 @@ pub async fn upload(State(state): State>, .ok_or(ApiError::DisabledFeature{ feature: feature.clone() })?; // Process multipart. - let mut file = UploadFile{ folder: "".to_owned(), ..Default::default() }; + let mut file = UploadFile{ ..Default::default() }; while let Some(field) = multipart.next_field().await? { let name = match field.name() { Some(x) => x, None => continue, }; - match name { - "name" => file.name = field.text().await?, - "file" => file.data = field.bytes().await?, - "folder" => file.folder = field.text().await?, + match name { // Verify valid inputs. + "ref" => file.git_ref = field.text().await?.trim().to_owned(), + "sha" => file.sha = { + let sha = field.text().await?.trim().to_owned(); + (sha.len() >= 10).then_some(0).ok_or(ApiError::InvalidUpload("SHA improperly formatted"))?; + u64::from_str_radix(&sha[..10], 16).map_err(|_| ApiError::InvalidUpload("SHA improperly formatted"))?; + sha[..10].to_owned() + }, + "prefix" => file.prefix = field.text().await?.trim().to_owned(), + "filename" => file.extension = path::Path::new(&field.text().await?.trim()).extension() + .map_or_else(|| "", |v| v.to_str().unwrap()) + .to_owned(), + "data" => file.data = field.bytes().await?, + "is_folder" => file.is_folder = field.text().await?.trim() == "true", _ => continue, } } + (!file.git_ref.is_empty()).then_some(0).ok_or(ApiError::InvalidUpload("Missing ref"))?; + (!file.sha.is_empty()).then_some(0).ok_or(ApiError::InvalidUpload("Missing SHA"))?; + + let mut is_release = false; + let ref_name = if file.git_ref.starts_with("refs/heads/") { // Branches + file.git_ref.replace("refs/heads/", "").replace("/", "_") + } else if file.git_ref.starts_with("refs/tags/") { // Tags + file.git_ref.replace("refs/tags/", "").replace("/", "_") + } else if file.git_ref.starts_with("refs/") { // Other + return Err(ApiError::InvalidUpload("Cannot handle files uploaded through this action trigger")); + } else { // Release + is_release = true; + file.git_ref + }; + + // Name and folder processing + let mut name = match is_release { + true => ref_name, + false => format!("{}-{}", ref_name, file.sha) + }; + if !file.prefix.is_empty() { + name = format!("{}-{}", file.prefix, name); + } + + let name_ext = if !file.extension.is_empty() { + format!("{}.{}", name, file.extension) + } else { + (!file.is_folder) + .then_some(0) + .ok_or(ApiError::InvalidUpload("Uploading folder should be zipped"))?; // Should not happen if uploading a folder. + name.clone() + }; + let dir = path::Path::new(&state.config.upload_path).join(&repo).join(feature); - let filepath = dir.join(&file.name); + let filepath = dir.join(&name_ext); + if fs::exists(&filepath)? { // File should not exist. + return Err(ERR_FILE_EXISTS)?; + } + fs::write(&filepath, file.data)?; fs::set_permissions(&filepath, Permissions::from_mode(FILE_PERM))?; - let tardir = dir.join(&file.folder); - if file.folder != *"" { + if file.is_folder { + let tardir = dir.join(&name); if fs::exists(&tardir)? { fs::remove_dir_all(&tardir)?; }