feat: added file uploading API, Gitea action, and description in README

This commit is contained in:
Kenneth Jao 2025-06-05 05:36:06 -04:00
parent f94ec335dd
commit 446c4dc851
3 changed files with 138 additions and 13 deletions

View File

@ -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`,

51
action.yml Normal file
View File

@ -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"

View File

@ -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<ServerState<'_>>,
}
// 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<ServerState<'_>>,
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<ServerState<'_>>,
@ -615,28 +622,75 @@ pub async fn upload(State(state): State<ServerState<'_>>,
.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)?;
}