use crate::{
error::{DynHttpError, HttpCommonError, HttpErrorResponse, HttpResult, HttpStatusResult},
middleware::{
action_user::{ActionUser, UserParams},
tenant::{TenantDb, TenantEvents, TenantParams, TenantSearch},
},
models::{
document_box::DocumentBoxScope,
file::BinaryResponse,
folder::HttpFolderError,
link::{CreateLink, HttpLinkError, LinkMetadataResponse, UpdateLinkRequest},
},
};
use axum::{
Extension, Json,
body::Body,
extract::Path,
http::{Response, StatusCode, header},
};
use axum_valid::Garde;
use docbox_core::{
database::models::{
edit_history::EditHistory,
folder::Folder,
link::{Link, LinkId, LinkWithExtra},
},
links::get_link_metadata::get_link_metadata,
};
use docbox_core::{
database::{DbPool, models::document_box::DocumentBoxScopeRawRef},
links::{
create_link::{CreateLinkData, safe_create_link},
delete_link::delete_link,
get_link_metadata::GetLinkMetadataError,
resolve_website::ResolveWebsiteService,
update_link::{UpdateLink, UpdateLinkError},
},
};
use std::sync::Arc;
pub const LINK_TAG: &str = "Link";
#[utoipa::path(
post,
operation_id = "link_create",
tag = LINK_TAG,
path = "/box/{scope}/link",
responses(
(status = 201, description = "Link created successfully", body = LinkWithExtra),
(status = 404, description = "Destination folder not found", body = HttpErrorResponse),
(status = 500, description = "Internal server error", body = HttpErrorResponse)
),
params(
("scope" = DocumentBoxScope, Path, description = "Scope to create the link within"),
TenantParams,
UserParams
)
)]
#[tracing::instrument(skip_all, fields(%scope))]
pub async fn create(
action_user: ActionUser,
TenantDb(db): TenantDb,
TenantSearch(search): TenantSearch,
TenantEvents(events): TenantEvents,
Path(DocumentBoxScope(scope)): Path<DocumentBoxScope>,
Garde(Json(req)): Garde<Json<CreateLink>>,
) -> Result<(StatusCode, Json<LinkWithExtra>), DynHttpError> {
let folder_id = req.folder_id;
let folder = Folder::find_by_id(&db, &scope, folder_id)
.await
.map_err(|error| {
tracing::error!(?error, "failed to query link destination folder");
HttpCommonError::ServerError
})?
.ok_or(HttpFolderError::UnknownFolder)?;
let created_by = action_user.store_user(&db).await?;
let create = CreateLinkData {
folder,
name: req.name,
value: req.value,
created_by: created_by.as_ref().map(|value| value.id.to_string()),
};
let link = safe_create_link(&db, search, &events, create)
.await
.map_err(|error| {
tracing::error!(?error, "failed to create link");
HttpLinkError::CreateError(error)
})?;
Ok((
StatusCode::CREATED,
Json(LinkWithExtra {
link,
created_by,
last_modified_at: None,
last_modified_by: None,
}),
))
}
#[utoipa::path(
get,
operation_id = "link_get",
tag = LINK_TAG,
path = "/box/{scope}/link/{link_id}",
responses(
(status = 200, description = "Link obtained successfully", body = LinkWithExtra),
(status = 404, description = "Link not found", body = HttpErrorResponse),
(status = 500, description = "Internal server error", body = HttpErrorResponse)
),
params(
("scope" = DocumentBoxScope, Path, description = "Scope the link resides within"),
("link_id" = Uuid, Path, description = "ID of the link to request"),
TenantParams
)
)]
#[tracing::instrument(skip_all, fields(%scope, %link_id))]
pub async fn get(
TenantDb(db): TenantDb,
Path((scope, link_id)): Path<(DocumentBoxScope, LinkId)>,
) -> HttpResult<LinkWithExtra> {
let DocumentBoxScope(scope) = scope;
let link = Link::find_with_extra(&db, &scope, link_id)
.await
.map_err(|error| {
tracing::error!(?error, "failed to query link");
HttpCommonError::ServerError
})?
.ok_or(HttpLinkError::UnknownLink)?;
Ok(Json(link))
}
#[utoipa::path(
get,
operation_id = "link_get_metadata",
tag = LINK_TAG,
path = "/box/{scope}/link/{link_id}/metadata",
responses(
(status = 200, description = "Obtained link metadata successfully", body = LinkWithExtra),
(status = 404, description = "Link not found or failed to resolve metadata", body = HttpErrorResponse),
(status = 500, description = "Internal server error", body = HttpErrorResponse)
),
params(
("scope" = DocumentBoxScope, Path, description = "Scope the link resides within"),
("link_id" = Uuid, Path, description = "ID of the link to request"),
TenantParams
)
)]
#[tracing::instrument(skip_all, fields(%scope, %link_id))]
pub async fn get_metadata(
TenantDb(db): TenantDb,
Extension(website_service): Extension<Arc<ResolveWebsiteService>>,
Path((scope, link_id)): Path<(DocumentBoxScope, LinkId)>,
) -> HttpResult<LinkMetadataResponse> {
let DocumentBoxScope(scope) = scope;
let link = find_link(&db, &scope, link_id).await?;
let (_, resolved) = get_link_metadata(&db, &website_service, &link)
.await
.map_err(|error| match error {
GetLinkMetadataError::ParseUrl(_) => HttpLinkError::InvalidLinkUrl,
GetLinkMetadataError::FailedResolve => HttpLinkError::FailedResolve,
})?;
Ok(Json(LinkMetadataResponse {
title: resolved.title,
og_title: resolved.og_title,
og_description: resolved.og_description,
favicon: resolved.best_favicon.is_some(),
image: resolved.og_image.is_some(),
}))
}
#[utoipa::path(
get,
operation_id = "link_get_favicon",
tag = LINK_TAG,
path = "/box/{scope}/link/{link_id}/favicon",
responses(
(status = 200, description = "Streamed link favicon binary data", content_type = "application/octet-stream", body = BinaryResponse),
(status = 404, description = "Link not found or no favicon was found", body = HttpErrorResponse),
(status = 500, description = "Internal server error", body = HttpErrorResponse)
),
params(
("scope" = DocumentBoxScope, Path, description = "Scope the link resides within"),
("link_id" = Uuid, Path, description = "ID of the link to request"),
TenantParams
)
)]
#[tracing::instrument(skip_all, fields(%scope, %link_id))]
pub async fn get_favicon(
TenantDb(db): TenantDb,
Extension(website_service): Extension<Arc<ResolveWebsiteService>>,
Path((scope, link_id)): Path<(DocumentBoxScope, LinkId)>,
) -> Result<Response<Body>, DynHttpError> {
let DocumentBoxScope(scope) = scope;
let link = find_link(&db, &scope, link_id).await?;
let (url, website_metadata) = get_link_metadata(&db, &website_service, &link)
.await
.map_err(|error| match error {
GetLinkMetadataError::ParseUrl(_) => HttpLinkError::InvalidLinkUrl,
GetLinkMetadataError::FailedResolve => HttpLinkError::FailedResolve,
})?;
let favicon = website_service
.service
.resolve_favicon(&url, website_metadata.best_favicon)
.await
.ok_or(HttpLinkError::NoFavicon)?;
let body = axum::body::Body::from_stream(favicon.stream);
Ok(Response::builder()
.header(header::CONTENT_TYPE, favicon.content_type.to_string())
.header(
header::CONTENT_SECURITY_POLICY,
"default-src 'none'; img-src 'self' data:;",
)
.header(
header::CACHE_CONTROL,
"public, max-age=3600, stale-while-revalidate=86400",
)
.body(body)?)
}
#[utoipa::path(
get,
operation_id = "link_get_image",
tag = LINK_TAG,
path = "/box/{scope}/link/{link_id}/image",
responses(
(status = 200, description = "Streamed link social image binary data", content_type = "application/octet-stream", body = BinaryResponse),
(status = 404, description = "Link not found or no image was found", body = HttpErrorResponse),
(status = 500, description = "Internal server error", body = HttpErrorResponse)
),
params(
("scope" = DocumentBoxScope, Path, description = "Scope the link resides within"),
("link_id" = Uuid, Path, description = "ID of the link to request"),
TenantParams
)
)]
#[tracing::instrument(skip_all, fields(%scope, %link_id))]
pub async fn get_image(
TenantDb(db): TenantDb,
Extension(website_service): Extension<Arc<ResolveWebsiteService>>,
Path((scope, link_id)): Path<(DocumentBoxScope, LinkId)>,
) -> Result<Response<Body>, DynHttpError> {
let DocumentBoxScope(scope) = scope;
let link = find_link(&db, &scope, link_id).await?;
let (url, website_metadata) = get_link_metadata(&db, &website_service, &link)
.await
.map_err(|error| match error {
GetLinkMetadataError::ParseUrl(_) => HttpLinkError::InvalidLinkUrl,
GetLinkMetadataError::FailedResolve => HttpLinkError::FailedResolve,
})?;
let og_image = website_metadata.og_image.ok_or(HttpLinkError::NoImage)?;
let og_image = website_service
.service
.resolve_image(&url, &og_image)
.await
.ok_or(HttpLinkError::NoImage)?;
let body = axum::body::Body::from_stream(og_image.stream);
Ok(Response::builder()
.header(header::CONTENT_TYPE, og_image.content_type.to_string())
.header(
header::CONTENT_SECURITY_POLICY,
"default-src 'none'; img-src 'self' data:;",
)
.header(
header::CACHE_CONTROL,
"public, max-age=3600, stale-while-revalidate=86400",
)
.body(body)?)
}
#[utoipa::path(
get,
operation_id = "link_get_edit_history",
tag = LINK_TAG,
path = "/box/{scope}/link/{link_id}/edit-history",
responses(
(status = 200, description = "Obtained edit history", body = [EditHistory]),
(status = 404, description = "Link not found", body = HttpErrorResponse),
(status = 500, description = "Internal server error", body = HttpErrorResponse)
),
params(
("scope" = DocumentBoxScope, Path, description = "Scope the link resides within"),
("link_id" = Uuid, Path, description = "ID of the link to request"),
TenantParams
)
)]
#[tracing::instrument(skip_all, fields(%scope, %link_id))]
pub async fn get_edit_history(
TenantDb(db): TenantDb,
Path((scope, link_id)): Path<(DocumentBoxScope, LinkId)>,
) -> HttpResult<Vec<EditHistory>> {
let DocumentBoxScope(scope) = scope;
_ = find_link(&db, &scope, link_id).await?;
let history = EditHistory::all_by_link(&db, link_id)
.await
.map_err(|error| {
tracing::error!(?error, "failed to query link edit history");
HttpCommonError::ServerError
})?;
Ok(Json(history))
}
#[utoipa::path(
put,
operation_id = "link_update",
tag = LINK_TAG,
path = "/box/{scope}/link/{link_id}",
responses(
(status = 200, description = "Updated link successfully"),
(status = 404, description = "Link not found", body = HttpErrorResponse),
(status = 500, description = "Internal server error", body = HttpErrorResponse)
),
params(
("scope" = DocumentBoxScope, Path, description = "Scope the link resides within"),
("link_id" = Uuid, Path, description = "ID of the link to request"),
TenantParams,
UserParams
)
)]
#[tracing::instrument(skip_all, fields(%scope, %link_id, ?req))]
pub async fn update(
action_user: ActionUser,
TenantDb(db): TenantDb,
TenantSearch(search): TenantSearch,
Path((scope, link_id)): Path<(DocumentBoxScope, LinkId)>,
Garde(Json(req)): Garde<Json<UpdateLinkRequest>>,
) -> HttpStatusResult {
let DocumentBoxScope(scope) = scope;
let link = find_link(&db, &scope, link_id).await?;
let user = action_user.store_user(&db).await?;
let user_id = user.as_ref().map(|value| value.id.to_string());
let update = UpdateLink {
folder_id: req.folder_id,
name: req.name,
value: req.value,
pinned: req.pinned,
};
docbox_core::links::update_link::update_link(&db, &search, &scope, link, user_id, update)
.await
.map_err(|error| match error {
UpdateLinkError::UnknownTargetFolder => {
DynHttpError::from(HttpFolderError::UnknownTargetFolder)
}
_ => DynHttpError::from(HttpCommonError::ServerError),
})?;
Ok(StatusCode::OK)
}
#[utoipa::path(
delete,
operation_id = "link_delete",
tag = LINK_TAG,
path = "/box/{scope}/link/{link_id}",
responses(
(status = 204, description = "Deleted link successfully"),
(status = 404, description = "Link not found", body = HttpErrorResponse),
(status = 500, description = "Internal server error", body = HttpErrorResponse)
),
params(
("scope" = DocumentBoxScope, Path, description = "Scope the link resides within"),
("link_id" = Uuid, Path, description = "ID of the link to delete"),
TenantParams
)
)]
#[tracing::instrument(skip_all, fields(%scope, %link_id))]
pub async fn delete(
TenantDb(db): TenantDb,
TenantSearch(search): TenantSearch,
TenantEvents(events): TenantEvents,
Path((scope, link_id)): Path<(DocumentBoxScope, LinkId)>,
) -> HttpStatusResult {
let DocumentBoxScope(scope) = scope;
let link = find_link(&db, &scope, link_id).await?;
delete_link(&db, &search, &events, link, scope)
.await
.map_err(|error| {
tracing::error!(?error, "failed to delete folder");
HttpCommonError::ServerError
})?;
Ok(StatusCode::NO_CONTENT)
}
async fn find_link(
db: &DbPool,
scope: DocumentBoxScopeRawRef<'_>,
link_id: LinkId,
) -> Result<Link, DynHttpError> {
let link = Link::find(db, scope, link_id)
.await
.map_err(|error| {
tracing::error!(?error, "failed to query link");
HttpCommonError::ServerError
})?
.ok_or(HttpLinkError::UnknownLink)?;
Ok(link)
}