diff --git a/README.md b/README.md index c2f8645..0547658 100644 --- a/README.md +++ b/README.md @@ -120,15 +120,25 @@ pict-rs offers four endpoints: which would create a 256x256px JPEG thumbnail and blur it - `DELETE /image/delete/{delete_token}/{file}` or `GET /image/delete/{delete_token}/{file}` to delete a file, where `delete_token` and `file` are from the `/image` endpoint's JSON + + +The following endpoints are protected by an API key via the `X-Api-Token` header, and are disabled +unless the `--api-key` option is passed to the binary or the PICTRS_API_KEY environment variable is +set. + +A secure API key can be generated by any password generator. - `POST /internal/import` for uploading an image while preserving the filename. This should not be exposed to the public internet, as it can cause naming conflicts with saved files. The upload format and response format are the same as the `POST /image` endpoint. +- `POST /internal/purge?...` Purge a file by it's filename or alias. This removes all aliases and + files associated with the query. + - `?file=asdf.png` purge by filename + - `?alias=asdf.png` purge by alias +- `GET /internal/aliases?...` Get the aliases for a file by it's filename or alias + - `?file={filename}` get aliases by filename + - `?alias={alias}` get aliases by alias +- `GET /internal/filename?alias={alias}` Get the filename for a file by it's alias - This endpoint also requires authentication via the `X-Api-Token` header, and is disabled unless - the `--api-key` option is passed to the binary or the PICTRS_API_KEY environment variable is - set. - - A secure API key can be generated by any password generator. ## Contributing Feel free to open issues for anything you find an issue with. Please note that any contributed code will be licensed under the AGPLv3. diff --git a/src/main.rs b/src/main.rs index ec609f2..2334738 100644 --- a/src/main.rs +++ b/src/main.rs @@ -396,6 +396,66 @@ where .streaming(stream.err_into()) } +#[derive(Debug, serde::Deserialize)] +#[serde(untagged)] +enum FileOrAlias { + File { file: String }, + Alias { alias: String }, +} + +async fn purge( + query: web::Query, + upload_manager: web::Data, +) -> Result { + let aliases = match query.into_inner() { + FileOrAlias::File { file } => upload_manager.aliases_by_filename(file).await?, + FileOrAlias::Alias { alias } => upload_manager.aliases_by_alias(alias).await?, + }; + + for alias in aliases.iter() { + upload_manager + .delete_without_token(alias.to_owned()) + .await?; + } + + Ok(HttpResponse::Ok().json(serde_json::json!({ + "msg": "ok", + "aliases": aliases + }))) +} + +async fn aliases( + query: web::Query, + upload_manager: web::Data, +) -> Result { + let aliases = match query.into_inner() { + FileOrAlias::File { file } => upload_manager.aliases_by_filename(file).await?, + FileOrAlias::Alias { alias } => upload_manager.aliases_by_alias(alias).await?, + }; + + Ok(HttpResponse::Ok().json(serde_json::json!({ + "msg": "ok", + "aliases": aliases, + }))) +} + +#[derive(Debug, serde::Deserialize)] +struct ByAlias { + alias: String, +} + +async fn filename_by_alias( + query: web::Query, + upload_manager: web::Data, +) -> Result { + let filename = upload_manager.from_alias(query.into_inner().alias).await?; + + Ok(HttpResponse::Ok().json(serde_json::json!({ + "msg": "ok", + "filename": filename, + }))) +} + #[actix_rt::main] async fn main() -> Result<(), anyhow::Error> { MAGICK_INIT.call_once(|| { @@ -508,7 +568,10 @@ async fn main() -> Result<(), anyhow::Error> { web::resource("/import") .wrap(import_form.clone()) .route(web::post().to(upload)), - ), + ) + .service(web::resource("/purge").route(web::post().to(purge))) + .service(web::resource("/aliases").route(web::get().to(aliases))) + .service(web::resource("/filename").route(web::get().to(filename_by_alias))), ) }) .bind(CONFIG.bind_address())? diff --git a/src/upload_manager.rs b/src/upload_manager.rs index dd23d8e..33d3d64 100644 --- a/src/upload_manager.rs +++ b/src/upload_manager.rs @@ -135,6 +135,60 @@ impl UploadManager { Ok(()) } + /// Get a list of aliases for a given file + pub(crate) async fn aliases_by_filename( + &self, + filename: String, + ) -> Result, UploadError> { + let fname_tree = self.inner.filename_tree.clone(); + let hash = web::block(move || fname_tree.get(filename.as_bytes())) + .await? + .ok_or(UploadError::MissingAlias)?; + + self.aliases_by_hash(&hash).await + } + + /// Get a list of aliases for a given alias + pub(crate) async fn aliases_by_alias(&self, alias: String) -> Result, UploadError> { + let alias_tree = self.inner.alias_tree.clone(); + let hash = web::block(move || alias_tree.get(alias.as_bytes())) + .await? + .ok_or(UploadError::MissingFilename)?; + + self.aliases_by_hash(&hash).await + } + + async fn aliases_by_hash(&self, hash: &sled::IVec) -> Result, UploadError> { + let (start, end) = alias_key_bounds(hash); + let db = self.inner.db.clone(); + let aliases = + web::block(move || db.range(start..end).values().collect::, _>>()) + .await?; + + debug!("Got {} aliases for hash", aliases.len()); + let aliases = aliases + .into_iter() + .filter_map(|s| String::from_utf8(s.to_vec()).ok()) + .collect::>(); + + for alias in aliases.iter() { + debug!("{}", alias); + } + + Ok(aliases) + } + + /// Delete an alias without a delete token + pub(crate) async fn delete_without_token(&self, alias: String) -> Result<(), UploadError> { + let token_key = delete_key(&alias); + let alias_tree = self.inner.alias_tree.clone(); + let token = web::block(move || alias_tree.get(token_key.as_bytes())) + .await? + .ok_or(UploadError::MissingAlias)?; + + self.delete(alias, String::from_utf8(token.to_vec())?).await + } + /// Delete the alias, and the file & variants if no more aliases exist #[instrument(skip(self, alias, token))] pub(crate) async fn delete(&self, alias: String, token: String) -> Result<(), UploadError> {