Skip to main content

docbox_http/routes/
link.rs

1//! Link related endpoints
2
3use crate::{
4    error::{DynHttpError, HttpCommonError, HttpErrorResponse, HttpResult, HttpStatusResult},
5    middleware::{
6        action_user::{ActionUser, UserParams},
7        tenant::{TenantDb, TenantEvents, TenantParams, TenantSearch},
8    },
9    models::{
10        document_box::DocumentBoxScope,
11        file::BinaryResponse,
12        folder::HttpFolderError,
13        link::{CreateLink, HttpLinkError, LinkMetadataResponse, UpdateLinkRequest},
14    },
15};
16use axum::{
17    Extension, Json,
18    body::Body,
19    extract::Path,
20    http::{Response, StatusCode, header},
21};
22use axum_valid::Garde;
23use docbox_core::{
24    database::models::{
25        edit_history::EditHistory,
26        folder::Folder,
27        link::{Link, LinkId, LinkWithExtra},
28    },
29    links::get_link_metadata::get_link_metadata,
30};
31use docbox_core::{
32    database::{DbPool, models::document_box::DocumentBoxScopeRawRef},
33    links::{
34        create_link::{CreateLinkData, safe_create_link},
35        delete_link::delete_link,
36        get_link_metadata::GetLinkMetadataError,
37        resolve_website::ResolveWebsiteService,
38        update_link::{UpdateLink, UpdateLinkError},
39    },
40};
41use std::sync::Arc;
42
43pub const LINK_TAG: &str = "Link";
44
45/// Create link
46///
47/// Creates a new link within the provided document box
48#[utoipa::path(
49    post,
50    operation_id = "link_create",
51    tag = LINK_TAG,
52    path = "/box/{scope}/link",
53    responses(
54        (status = 201, description = "Link created successfully", body = LinkWithExtra),
55        (status = 404, description = "Destination folder not found", body = HttpErrorResponse),
56        (status = 500, description = "Internal server error", body = HttpErrorResponse)
57    ),
58    params(
59        ("scope" = DocumentBoxScope, Path, description = "Scope to create the link within"),
60        TenantParams,
61        UserParams
62    )
63)]
64#[tracing::instrument(skip_all, fields(%scope))]
65pub async fn create(
66    action_user: ActionUser,
67    TenantDb(db): TenantDb,
68    TenantSearch(search): TenantSearch,
69    TenantEvents(events): TenantEvents,
70    Path(DocumentBoxScope(scope)): Path<DocumentBoxScope>,
71    Garde(Json(req)): Garde<Json<CreateLink>>,
72) -> Result<(StatusCode, Json<LinkWithExtra>), DynHttpError> {
73    let folder_id = req.folder_id;
74    let folder = Folder::find_by_id(&db, &scope, folder_id)
75        .await
76        // Failed to query destination folder
77        .map_err(|error| {
78            tracing::error!(?error, "failed to query link destination folder");
79            HttpCommonError::ServerError
80        })?
81        // Destination folder was not found
82        .ok_or(HttpFolderError::UnknownFolder)?;
83
84    // Update stored editing user data
85    let created_by = action_user.store_user(&db).await?;
86
87    // Make the create query
88    let create = CreateLinkData {
89        folder,
90        name: req.name,
91        value: req.value,
92        created_by: created_by.as_ref().map(|value| value.id.to_string()),
93    };
94
95    // Perform Link creation
96    let link = safe_create_link(&db, search, &events, create)
97        .await
98        .map_err(|error| {
99            tracing::error!(?error, "failed to create link");
100            HttpLinkError::CreateError(error)
101        })?;
102
103    Ok((
104        StatusCode::CREATED,
105        Json(LinkWithExtra {
106            link,
107            created_by,
108            last_modified_at: None,
109            last_modified_by: None,
110        }),
111    ))
112}
113
114/// Get link by ID
115///
116/// Request a specific link by ID
117#[utoipa::path(
118    get,
119    operation_id = "link_get",
120    tag = LINK_TAG,
121    path = "/box/{scope}/link/{link_id}",
122    responses(
123        (status = 200, description = "Link obtained successfully", body = LinkWithExtra),
124        (status = 404, description = "Link not found", body = HttpErrorResponse),
125        (status = 500, description = "Internal server error", body = HttpErrorResponse)
126    ),
127    params(
128        ("scope" = DocumentBoxScope, Path, description = "Scope the link resides within"),
129        ("link_id" = Uuid, Path, description = "ID of the link to request"),
130        TenantParams
131    )
132)]
133#[tracing::instrument(skip_all, fields(%scope, %link_id))]
134pub async fn get(
135    TenantDb(db): TenantDb,
136    Path((scope, link_id)): Path<(DocumentBoxScope, LinkId)>,
137) -> HttpResult<LinkWithExtra> {
138    let DocumentBoxScope(scope) = scope;
139
140    let link = Link::find_with_extra(&db, &scope, link_id)
141        .await
142        // Failed to query link
143        .map_err(|error| {
144            tracing::error!(?error, "failed to query link");
145            HttpCommonError::ServerError
146        })?
147        // Link not found
148        .ok_or(HttpLinkError::UnknownLink)?;
149
150    Ok(Json(link))
151}
152
153/// Get link website metadata
154///
155/// Requests metadata for the link. This will make a request
156/// to the site at the link value to extract metadata from
157/// the website itself such as title, and OGP metadata
158#[utoipa::path(
159    get,
160    operation_id = "link_get_metadata",
161    tag = LINK_TAG,
162    path = "/box/{scope}/link/{link_id}/metadata",
163    responses(
164        (status = 200, description = "Obtained link metadata successfully", body = LinkWithExtra),
165        (status = 404, description = "Link not found or failed to resolve metadata", body = HttpErrorResponse),
166        (status = 500, description = "Internal server error", body = HttpErrorResponse)
167    ),
168    params(
169        ("scope" = DocumentBoxScope, Path, description = "Scope the link resides within"),
170        ("link_id" = Uuid, Path, description = "ID of the link to request"),
171        TenantParams
172    )
173)]
174#[tracing::instrument(skip_all, fields(%scope, %link_id))]
175pub async fn get_metadata(
176    TenantDb(db): TenantDb,
177    Extension(website_service): Extension<Arc<ResolveWebsiteService>>,
178    Path((scope, link_id)): Path<(DocumentBoxScope, LinkId)>,
179) -> HttpResult<LinkMetadataResponse> {
180    let DocumentBoxScope(scope) = scope;
181
182    let link = find_link(&db, &scope, link_id).await?;
183
184    let (_, resolved) = get_link_metadata(&db, &website_service, &link)
185        .await
186        .map_err(|error| match error {
187            GetLinkMetadataError::ParseUrl(_) => HttpLinkError::InvalidLinkUrl,
188            GetLinkMetadataError::FailedResolve => HttpLinkError::FailedResolve,
189        })?;
190
191    Ok(Json(LinkMetadataResponse {
192        title: resolved.title,
193        og_title: resolved.og_title,
194        og_description: resolved.og_description,
195        favicon: resolved.best_favicon.is_some(),
196        image: resolved.og_image.is_some(),
197    }))
198}
199
200/// Get link favicon
201///
202/// Obtain the favicon image for the website that the link points to
203/// the image data is streamed directly from the target website
204#[utoipa::path(
205    get,
206    operation_id = "link_get_favicon",
207    tag = LINK_TAG,
208    path = "/box/{scope}/link/{link_id}/favicon",
209    responses(
210        (status = 200, description = "Streamed link favicon binary data", content_type = "application/octet-stream", body = BinaryResponse),
211        (status = 404, description = "Link not found or no favicon was found", body = HttpErrorResponse),
212        (status = 500, description = "Internal server error", body = HttpErrorResponse)
213    ),
214    params(
215        ("scope" = DocumentBoxScope, Path, description = "Scope the link resides within"),
216        ("link_id" = Uuid, Path, description = "ID of the link to request"),
217        TenantParams
218    )
219)]
220#[tracing::instrument(skip_all, fields(%scope, %link_id))]
221pub async fn get_favicon(
222    TenantDb(db): TenantDb,
223    Extension(website_service): Extension<Arc<ResolveWebsiteService>>,
224    Path((scope, link_id)): Path<(DocumentBoxScope, LinkId)>,
225) -> Result<Response<Body>, DynHttpError> {
226    let DocumentBoxScope(scope) = scope;
227
228    let link = find_link(&db, &scope, link_id).await?;
229
230    let (url, website_metadata) = get_link_metadata(&db, &website_service, &link)
231        .await
232        .map_err(|error| match error {
233            GetLinkMetadataError::ParseUrl(_) => HttpLinkError::InvalidLinkUrl,
234            GetLinkMetadataError::FailedResolve => HttpLinkError::FailedResolve,
235        })?;
236
237    let favicon = website_service
238        .service
239        .resolve_favicon(&url, website_metadata.best_favicon)
240        .await
241        .ok_or(HttpLinkError::NoFavicon)?;
242
243    let body = axum::body::Body::from_stream(favicon.stream);
244
245    Ok(Response::builder()
246        .header(header::CONTENT_TYPE, favicon.content_type.to_string())
247        .header(
248            header::CONTENT_SECURITY_POLICY,
249            "default-src 'none'; img-src 'self' data:;",
250        )
251        .header(
252            header::CACHE_CONTROL,
253            "public, max-age=3600, stale-while-revalidate=86400",
254        )
255        .body(body)?)
256}
257
258/// Get link social image
259///
260/// Obtain the "Social Image" for the website, this resolves the website
261/// metadata and finds the OGP metadata image responding with the image
262/// directly. The image data is streamed directly from the target
263/// website
264#[utoipa::path(
265    get,
266    operation_id = "link_get_image",
267    tag = LINK_TAG,
268    path = "/box/{scope}/link/{link_id}/image",
269    responses(
270        (status = 200, description = "Streamed link social image binary data", content_type = "application/octet-stream", body = BinaryResponse),
271        (status = 404, description = "Link not found or no image was found", body = HttpErrorResponse),
272        (status = 500, description = "Internal server error", body = HttpErrorResponse)
273    ),
274    params(
275        ("scope" = DocumentBoxScope, Path, description = "Scope the link resides within"),
276        ("link_id" = Uuid, Path, description = "ID of the link to request"),
277        TenantParams
278    )
279)]
280#[tracing::instrument(skip_all, fields(%scope, %link_id))]
281pub async fn get_image(
282    TenantDb(db): TenantDb,
283    Extension(website_service): Extension<Arc<ResolveWebsiteService>>,
284    Path((scope, link_id)): Path<(DocumentBoxScope, LinkId)>,
285) -> Result<Response<Body>, DynHttpError> {
286    let DocumentBoxScope(scope) = scope;
287
288    let link = find_link(&db, &scope, link_id).await?;
289
290    let (url, website_metadata) = get_link_metadata(&db, &website_service, &link)
291        .await
292        .map_err(|error| match error {
293            GetLinkMetadataError::ParseUrl(_) => HttpLinkError::InvalidLinkUrl,
294            GetLinkMetadataError::FailedResolve => HttpLinkError::FailedResolve,
295        })?;
296
297    let og_image = website_metadata.og_image.ok_or(HttpLinkError::NoImage)?;
298    let og_image = website_service
299        .service
300        .resolve_image(&url, &og_image)
301        .await
302        .ok_or(HttpLinkError::NoImage)?;
303
304    let body = axum::body::Body::from_stream(og_image.stream);
305
306    Ok(Response::builder()
307        .header(header::CONTENT_TYPE, og_image.content_type.to_string())
308        .header(
309            header::CONTENT_SECURITY_POLICY,
310            "default-src 'none'; img-src 'self' data:;",
311        )
312        .header(
313            header::CACHE_CONTROL,
314            "public, max-age=3600, stale-while-revalidate=86400",
315        )
316        .body(body)?)
317}
318
319/// Get link edit history
320///
321/// Request the edit history for the provided link
322#[utoipa::path(
323    get,
324    operation_id = "link_get_edit_history",
325    tag = LINK_TAG,
326    path = "/box/{scope}/link/{link_id}/edit-history",
327    responses(
328        (status = 200, description = "Obtained edit history", body = [EditHistory]),
329        (status = 404, description = "Link not found", body = HttpErrorResponse),
330        (status = 500, description = "Internal server error", body = HttpErrorResponse)
331    ),
332    params(
333        ("scope" = DocumentBoxScope, Path, description = "Scope the link resides within"),
334        ("link_id" = Uuid, Path, description = "ID of the link to request"),
335        TenantParams
336    )
337)]
338#[tracing::instrument(skip_all, fields(%scope, %link_id))]
339pub async fn get_edit_history(
340    TenantDb(db): TenantDb,
341    Path((scope, link_id)): Path<(DocumentBoxScope, LinkId)>,
342) -> HttpResult<Vec<EditHistory>> {
343    let DocumentBoxScope(scope) = scope;
344
345    // Ensure the link itself exists
346    _ = find_link(&db, &scope, link_id).await?;
347
348    let history = EditHistory::all_by_link(&db, link_id)
349        .await
350        // Failed to query edit history
351        .map_err(|error| {
352            tracing::error!(?error, "failed to query link edit history");
353            HttpCommonError::ServerError
354        })?;
355
356    Ok(Json(history))
357}
358
359/// Update link
360///
361/// Updates a link, can be a name change, value change, a folder move, or all
362#[utoipa::path(
363    put,
364    operation_id = "link_update",
365    tag = LINK_TAG,
366    path = "/box/{scope}/link/{link_id}",
367    responses(
368        (status = 200, description = "Updated link successfully"),
369        (status = 404, description = "Link not found", body = HttpErrorResponse),
370        (status = 500, description = "Internal server error", body = HttpErrorResponse)
371    ),
372    params(
373        ("scope" = DocumentBoxScope, Path, description = "Scope the link resides within"),
374        ("link_id" = Uuid, Path, description = "ID of the link to request"),
375        TenantParams,
376        UserParams
377    )
378)]
379#[tracing::instrument(skip_all, fields(%scope, %link_id, ?req))]
380pub async fn update(
381    action_user: ActionUser,
382    TenantDb(db): TenantDb,
383    TenantSearch(search): TenantSearch,
384    Path((scope, link_id)): Path<(DocumentBoxScope, LinkId)>,
385    Garde(Json(req)): Garde<Json<UpdateLinkRequest>>,
386) -> HttpStatusResult {
387    let DocumentBoxScope(scope) = scope;
388
389    let link = find_link(&db, &scope, link_id).await?;
390
391    // Update stored editing user data
392    let user = action_user.store_user(&db).await?;
393    let user_id = user.as_ref().map(|value| value.id.to_string());
394
395    let update = UpdateLink {
396        folder_id: req.folder_id,
397        name: req.name,
398        value: req.value,
399        pinned: req.pinned,
400    };
401
402    docbox_core::links::update_link::update_link(&db, &search, &scope, link, user_id, update)
403        .await
404        .map_err(|error| match error {
405            UpdateLinkError::UnknownTargetFolder => {
406                DynHttpError::from(HttpFolderError::UnknownTargetFolder)
407            }
408            _ => DynHttpError::from(HttpCommonError::ServerError),
409        })?;
410
411    Ok(StatusCode::OK)
412}
413
414/// Delete a link by ID
415///
416/// Deletes a specific link using its ID
417#[utoipa::path(
418    delete,
419    operation_id = "link_delete",
420    tag = LINK_TAG,
421    path = "/box/{scope}/link/{link_id}",
422    responses(
423        (status = 204, description = "Deleted link successfully"),
424        (status = 404, description = "Link not found", body = HttpErrorResponse),
425        (status = 500, description = "Internal server error", body = HttpErrorResponse)
426    ),
427    params(
428        ("scope" = DocumentBoxScope, Path, description = "Scope the link resides within"),
429        ("link_id" = Uuid, Path, description = "ID of the link to delete"),
430        TenantParams
431    )
432)]
433#[tracing::instrument(skip_all, fields(%scope, %link_id))]
434pub async fn delete(
435    TenantDb(db): TenantDb,
436    TenantSearch(search): TenantSearch,
437    TenantEvents(events): TenantEvents,
438    Path((scope, link_id)): Path<(DocumentBoxScope, LinkId)>,
439) -> HttpStatusResult {
440    let DocumentBoxScope(scope) = scope;
441
442    let link = find_link(&db, &scope, link_id).await?;
443
444    delete_link(&db, &search, &events, link, scope)
445        .await
446        .map_err(|error| {
447            tracing::error!(?error, "failed to delete folder");
448            HttpCommonError::ServerError
449        })?;
450
451    Ok(StatusCode::NO_CONTENT)
452}
453
454/// Resolves a link handles mapping the various link failure
455/// errors into HTTP errors
456async fn find_link(
457    db: &DbPool,
458    scope: DocumentBoxScopeRawRef<'_>,
459    link_id: LinkId,
460) -> Result<Link, DynHttpError> {
461    let link = Link::find(db, scope, link_id)
462        .await
463        // Failed to query link
464        .map_err(|error| {
465            tracing::error!(?error, "failed to query link");
466            HttpCommonError::ServerError
467        })?
468        // Link not found
469        .ok_or(HttpLinkError::UnknownLink)?;
470
471    Ok(link)
472}