1use 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#[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 .map_err(|error| {
78 tracing::error!(?error, "failed to query link destination folder");
79 HttpCommonError::ServerError
80 })?
81 .ok_or(HttpFolderError::UnknownFolder)?;
83
84 let created_by = action_user.store_user(&db).await?;
86
87 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 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#[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 .map_err(|error| {
144 tracing::error!(?error, "failed to query link");
145 HttpCommonError::ServerError
146 })?
147 .ok_or(HttpLinkError::UnknownLink)?;
149
150 Ok(Json(link))
151}
152
153#[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#[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#[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#[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 _ = find_link(&db, &scope, link_id).await?;
347
348 let history = EditHistory::all_by_link(&db, link_id)
349 .await
350 .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#[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 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#[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
454async 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 .map_err(|error| {
465 tracing::error!(?error, "failed to query link");
466 HttpCommonError::ServerError
467 })?
468 .ok_or(HttpLinkError::UnknownLink)?;
470
471 Ok(link)
472}