Skip to main content

max / makenotwork

24.3 KB · 645 lines History Blame Raw
1 //! Media library upload, listing, and deletion handlers.
2 //!
3 //! Provides a user-scoped media library for embedding images and videos
4 //! in markdown content (item bodies, sections, blog posts). Files are
5 //! stored in S3 under `{user_id}/media/{folder}/{filename}` and served
6 //! via `cdn.makenot.work`.
7
8 use axum::{
9 Json,
10 extract::{Path, Query, State},
11 response::IntoResponse,
12 };
13 use serde::{Deserialize, Serialize};
14 use sqlx::PgPool;
15
16 use crate::{
17 AppStorage, Scanning,
18 auth::AuthUser,
19 config::Config,
20 db::{self, MediaFileId},
21 error::{AppError, Result, ResultExt},
22 storage::{CACHE_CONTROL_IMMUTABLE, FileType, S3Client, sanitize_filename, sanitize_folder},
23 };
24
25 use super::{CommitTarget, ConfirmUploadResponse, PresignUploadResponse, commit_upload};
26
27 // Request / Response Types
28
29 #[derive(Debug, Deserialize)]
30 pub(crate) struct MediaPresignRequest {
31 pub file_name: String,
32 pub content_type: String,
33 #[serde(default)]
34 pub folder: String,
35 /// Optional declared size; when present it is signed into the presigned
36 /// URL's `Content-Length` so S3 rejects oversized PUTs at the protocol
37 /// level (media video is the largest upload class, up to 20 GB).
38 #[serde(default)]
39 pub file_size_bytes: Option<i64>,
40 }
41
42 #[derive(Debug, Deserialize)]
43 pub(crate) struct MediaConfirmRequest {
44 pub s3_key: String,
45 pub file_name: String,
46 pub content_type: String,
47 // The logical library name (folder + filename) is carried on the confirm.
48 // Under scan-then-promote the physical key is a content hash that no longer
49 // encodes the name, so there is nothing in the key for a client-supplied
50 // folder to disagree with (the Run #22 mismatch concern is gone), the
51 // `(user_id, folder, filename)` unique index guards the logical namespace,
52 // decoupled from the content-addressed object. Both are re-sanitized at
53 // confirm. Defaulted so a client omitting it lands the file in the root.
54 #[serde(default)]
55 pub folder: String,
56 }
57
58 #[derive(Debug, Deserialize)]
59 pub(crate) struct MediaListQuery {
60 pub folder: Option<String>,
61 }
62
63 #[derive(Debug, Serialize)]
64 pub(crate) struct MediaFileResponse {
65 pub id: MediaFileId,
66 pub folder: String,
67 pub filename: String,
68 pub content_type: String,
69 pub file_size_bytes: i64,
70 pub media_type: String,
71 pub cdn_url: String,
72 pub markdown_ref: String,
73 pub created_at: String,
74 }
75
76 #[derive(Debug, Serialize)]
77 pub(crate) struct MediaListResponse {
78 pub files: Vec<MediaFileResponse>,
79 pub folders: Vec<String>,
80 }
81
82 #[derive(Debug, Serialize)]
83 pub(crate) struct MediaFoldersResponse {
84 pub folders: Vec<String>,
85 }
86
87 // Helpers
88
89 /// Determine the media file type (image or video) from content type.
90 fn classify_media(content_type: &str) -> Result<(&'static str, FileType)> {
91 if content_type.starts_with("image/") {
92 Ok(("image", FileType::MediaImage))
93 } else if content_type.starts_with("video/") {
94 Ok(("video", FileType::MediaVideo))
95 } else {
96 Err(AppError::BadRequest(format!(
97 "Unsupported content type: {content_type}. Only images and videos are allowed."
98 )))
99 }
100 }
101
102 fn file_to_response(f: &db::DbMediaFile, cdn_base: &str) -> MediaFileResponse {
103 let cdn_url = format!("{}/{}", cdn_base, f.s3_key);
104 // Folder-relative embed path, built from the logical name on the row, not
105 // the physical key, which under scan-then-promote is a content hash
106 // (`{user}/c/{sha}.ext`) that no longer encodes folder/filename.
107 let markdown_ref = if f.folder.is_empty() {
108 format!("![]({})", f.filename)
109 } else {
110 format!("![]({}/{})", f.folder, f.filename)
111 };
112 MediaFileResponse {
113 id: f.id,
114 folder: f.folder.clone(),
115 filename: f.filename.clone(),
116 content_type: f.content_type.clone(),
117 file_size_bytes: f.file_size_bytes,
118 media_type: f.media_type.clone(),
119 cdn_url,
120 markdown_ref,
121 created_at: f.created_at.to_rfc3339(),
122 }
123 }
124
125 // Handlers
126
127 /// Generate a presigned URL for uploading a media file.
128 ///
129 /// POST /api/media/presign
130 #[tracing::instrument(skip_all, name = "media::presign", fields(user_id = %user.id))]
131 pub(super) async fn media_presign(
132 State(db): State<PgPool>,
133 State(storage): State<AppStorage>,
134 AuthUser(user): AuthUser,
135 Json(req): Json<MediaPresignRequest>,
136 ) -> Result<impl IntoResponse> {
137 user.check_not_suspended()?;
138 let s3 = storage.require_s3()?;
139
140 let (media_type, file_type) = classify_media(&req.content_type)?;
141 let _ = media_type; // used at confirm time
142
143 // Validate content type and extension
144 S3Client::validate_content_type(file_type, &req.content_type)?;
145 S3Client::validate_extension(file_type, &req.file_name)?;
146
147 let folder = sanitize_folder(&req.folder);
148
149 // Check for path traversal in folder
150 if req.folder.contains("..") {
151 return Err(AppError::BadRequest("Invalid folder name".to_string()));
152 }
153
154 // Early quota check, images bypass tier, video requires BigFiles+
155 db::creator_tiers::check_presign_allowed(&db, user.id, file_type).await?;
156
157 // Validate the declared size against the static per-type cap AND the user's
158 // tier per-file cap before signing it into Content-Length, so an oversize
159 // media upload is rejected at presign instead of after the bytes are spent
160 // and only caught at confirm. `get_effective_max_file_bytes` returns None for
161 // images (they bypass the tier cap) and the tier limit for video.
162 let max_file_bytes =
163 db::creator_tiers::get_effective_max_file_bytes(&db, user.id, file_type).await?;
164 super::validate_declared_upload_size(req.file_size_bytes, file_type, max_file_bytes)?;
165
166 // Filename uniqueness is enforced at confirm time by the
167 // `idx_media_files_user_folder_name` unique index, see `media_confirm`,
168 // which catches the duplicate-INSERT error, rolls back the storage
169 // credit, and returns the clean "already exists" message. It deliberately
170 // does NOT delete the S3 object on that path, see the 23505 branch there
171 // for why. The pre-check we used to do here at presign time was racy (two
172 // concurrent presigns both pass the SELECT, then both try to upload and
173 // one wastes bandwidth) and the confirm-time path is authoritative either
174 // way.
175
176 // Staging key (unserved); the scan worker promotes it to the content key on a
177 // Clean verdict (C1). The logical library name (folder + filename) is no
178 // longer encoded in the physical key, it is carried on the row and re-derived
179 // from the (sanitized) request at confirm.
180 let _ = &folder; // validated above for early rejection; not woven into the key
181 let s3_key = S3Client::generate_staging_key(&req.file_name);
182
183 // Track the pending upload so the reaper can clean it up if never confirmed
184 db::pending_uploads::record_pending_upload(&db, user.id, &s3_key, "main").await?;
185
186 let expires_in = 3600;
187 let upload_url = s3
188 .presign_upload(
189 &s3_key,
190 &req.content_type,
191 Some(expires_in),
192 Some(CACHE_CONTROL_IMMUTABLE),
193 req.file_size_bytes,
194 )
195 .await
196 .context("presign upload for media file")?;
197
198 Ok(Json(PresignUploadResponse {
199 upload_url,
200 s3_key: s3_key.into_string(),
201 expires_in,
202 cache_control: Some(CACHE_CONTROL_IMMUTABLE.to_string()),
203 max_file_bytes: None,
204 }))
205 }
206
207 /// Confirm a completed media file upload.
208 ///
209 /// POST /api/media/confirm
210 #[tracing::instrument(skip_all, name = "media::confirm", fields(user_id = %user.id))]
211 pub(super) async fn media_confirm(
212 State(db): State<PgPool>,
213 State(storage): State<AppStorage>,
214 State(scanning): State<Scanning>,
215 AuthUser(user): AuthUser,
216 Json(req): Json<MediaConfirmRequest>,
217 ) -> Result<impl IntoResponse> {
218 user.check_not_suspended()?;
219 let s3 = storage.require_s3()?;
220
221 let (media_type, file_type) = classify_media(&req.content_type)?;
222
223 // Re-validate content type and extension at confirm time (may differ from presign)
224 S3Client::validate_content_type(file_type, &req.content_type)?;
225 S3Client::validate_extension(file_type, &req.file_name)?;
226
227 // Authorize the staging key: a `staging/{uuid}` key has no user in its path,
228 // so ownership is proved via the `pending_uploads` row recorded at presign,
229 // not a prefix check. Gate before the sniff/size-reject paths so an unowned
230 // (at most another user's in-flight) staging object is never enqueued for
231 // deletion.
232 if !db::pending_uploads::is_owned(&db, user.id, &req.s3_key, "main").await? {
233 return Err(AppError::BadRequest("Invalid upload key".to_string()));
234 }
235
236 // Verify the object exists in S3
237 if !s3.object_exists(&req.s3_key).await? {
238 return Err(AppError::BadRequest(
239 "Upload not found. Please try uploading again.".to_string(),
240 ));
241 }
242
243 // Get file size
244 let file_size_bytes = s3.object_size(&req.s3_key).await?.ok_or_else(|| {
245 AppError::BadRequest(
246 "Could not determine file size. Please try uploading again.".to_string(),
247 )
248 })?;
249 if file_size_bytes as u64 > file_type.max_size() {
250 super::enqueue_s3_orphan(
251 &db,
252 &req.s3_key,
253 crate::storage::S3Bucket::Main,
254 "media_upload_rejected",
255 )
256 .await;
257 return Err(AppError::BadRequest(format!(
258 "File exceeds maximum size of {} MB",
259 file_type.max_size() / (1024 * 1024)
260 )));
261 }
262
263 // Reconcile the real media category against the declared content_type before
264 // tier enforcement. The declared type is client-controlled, it is bound into
265 // the presigned PUT and merely echoed back by S3 metadata, so trusting it
266 // lets a video be declared `image/png` and dodge the BigFiles+ video-tier
267 // gate (Run #22 Storage MED). Sniff the object's leading bytes; if it is
268 // detectably a different audio/visual category than declared, reject + orphan.
269 {
270 // Ranged read of just the header, a 4 KB sniff must not transfer the
271 // whole (up to 20 GB) object. Production issues `Range: bytes=0-4095`.
272 let head = s3.download_head(&req.s3_key, 4096).await?;
273 let detected = infer::get(&head).map(|kind| kind.matcher_type());
274 // `media_type` is only ever "image" or "video" (see `classify_media`).
275 let mismatch = match media_type {
276 // Every allowed image format (jpeg/png/webp/gif) is positively
277 // detected by `infer`, so a declared image MUST sniff as an image.
278 // Requiring a positive image signature, rather than only rejecting
279 // a *detected* video, closes the bypass where a video `infer`
280 // cannot name its container is declared `image/png` to dodge the
281 // BigFiles+ video-tier gate (Run #22 / Run #5 Storage): a video
282 // never sniffs as Image, so it is rejected here either way.
283 "image" => detected != Some(infer::MatcherType::Image),
284 // Declared video: do NOT require a positive video signature,
285 // `infer` cannot classify every valid container (fragmented mp4,
286 // some mov/webm), and there is no tier-evasion incentive to declare
287 // a video as a video. Only reject a still image mislabeled as video.
288 "video" => detected == Some(infer::MatcherType::Image),
289 _ => false,
290 };
291 if mismatch {
292 super::enqueue_s3_orphan(
293 &db,
294 &req.s3_key,
295 crate::storage::S3Bucket::Main,
296 "media_content_type_mismatch",
297 )
298 .await;
299 let sniffed = match detected {
300 Some(infer::MatcherType::Image) => "image",
301 Some(infer::MatcherType::Video) => "video",
302 _ => "an unrecognized format",
303 };
304 return Err(AppError::BadRequest(format!(
305 "Uploaded file does not match the declared {media_type} type (detected: {sniffed})."
306 )));
307 }
308 }
309
310 // Tier enforcement
311 let max_storage =
312 match db::creator_tiers::check_upload_allowed(&db, user.id, file_type, file_size_bytes)
313 .await
314 {
315 Ok(max) => max,
316 Err(e) => {
317 super::enqueue_s3_orphan(
318 &db,
319 &req.s3_key,
320 crate::storage::S3Bucket::Main,
321 "media_upload_rejected",
322 )
323 .await;
324 return Err(e);
325 }
326 };
327
328 // Derive the logical library name (folder + filename) from the request,
329 // re-applying the same sanitizers presign used. Under scan-then-promote the
330 // physical key is a content hash that no longer encodes the name (the Run #22
331 // key-vs-name mismatch it guarded against is gone, the name is now a purely
332 // logical namespace, enforced by the `(user_id, folder, filename)` unique
333 // index, decoupled from the content-addressed object).
334 if req.folder.contains("..") {
335 return Err(AppError::BadRequest("Invalid folder name".to_string()));
336 }
337 let folder = sanitize_folder(&req.folder);
338 let safe_filename = sanitize_filename(&req.file_name);
339 if safe_filename.is_empty() {
340 return Err(AppError::BadRequest("Invalid file name".to_string()));
341 }
342
343 // Wrap storage credit + pending_uploads clear + media_files INSERT in a
344 // single transaction. The Run #5 audit flagged the previous non-atomic
345 // three-write sequence: a process interruption between writes could leave
346 // a charged storage counter with no row to refund against (storage credit
347 // leak), or a removed pending_uploads row with no media_files row + no
348 // tracker for the reaper (orphan S3 object + over-charge). With the tx,
349 // any rollback restores all three table states; only the S3 object needs
350 // explicit cleanup on failure.
351 //
352 // The unique index on (user_id, folder, filename) raises 23505 inside the
353 // tx; we catch the typed error after rollback and report a clean message.
354 let tx_result: Result<db::DbMediaFile> = async {
355 let mut tx = db.begin().await?;
356 db::creator_tiers::try_increment_storage_on(&mut tx, user.id, file_size_bytes, max_storage)
357 .await?;
358 db::pending_uploads::remove_pending_upload(&mut *tx, user.id, &req.s3_key, "main").await?;
359 let row = db::media_files::create(
360 &mut *tx,
361 user.id,
362 &folder,
363 &safe_filename,
364 &req.s3_key,
365 &req.content_type,
366 file_size_bytes,
367 media_type,
368 db::FileScanStatus::Pending.to_string().as_str(),
369 )
370 .await?;
371 tx.commit().await?;
372 Ok(row)
373 }
374 .await;
375
376 let inserted = match tx_result {
377 Ok(row) => row,
378 Err(e) => {
379 tracing::warn!(error = ?e, "media_confirm transaction failed");
380 // Detect the duplicate case via the structured Postgres SQLSTATE
381 // (23505). The previous `e.to_string()` substring check broke when
382 // the AppError wrapper changed how the inner sqlx error rendered.
383 if let AppError::Database(sqlx::Error::Database(db_err)) = &e
384 && db_err.code().as_deref() == Some("23505")
385 {
386 // Two different situations raise 23505 here and this branch
387 // cannot tell them apart, so it fails safe and never deletes
388 // (Run #11 HIGH):
389 //
390 // - A retried or concurrent confirm of THIS upload. The first
391 // confirm committed a row pointing at exactly this
392 // `req.s3_key`, so deleting the object would torpedo the
393 // file that live row serves.
394 // - A genuinely different upload that collides on
395 // (user, folder, filename). Staging keys are per-presign
396 // UUIDs, so this object is an orphan, but the tx rolled
397 // back and left its `pending_uploads` row intact, which is
398 // what the reaper collects it by.
399 //
400 // The tx already rolled back the storage charge either way.
401 // Reject the duplicate without touching S3.
402 return Err(AppError::BadRequest(format!(
403 "A file named '{}' already exists in folder '{}'.",
404 safe_filename,
405 if folder.is_empty() { "(root)" } else { &folder }
406 )));
407 }
408 // Any other failure: the tx rolled back and no row references this
409 // freshly-uploaded object, so it's a genuine orphan, clean it up.
410 super::enqueue_s3_orphan(
411 &db,
412 &req.s3_key,
413 crate::storage::S3Bucket::Main,
414 "media_upload_rejected",
415 )
416 .await;
417 return Err(e);
418 }
419 };
420
421 // Scan enqueue + scan_status flip AFTER the INSERT commits via the shared
422 // `commit_upload` helper. Always flips status (worker-or-now), so a no-scanner
423 // dev/test environment doesn't leave the row Pending forever.
424 let scan_status = commit_upload(
425 &db,
426 scanning.scanner.as_ref(),
427 CommitTarget::Media(inserted.id),
428 &req.s3_key,
429 file_type,
430 user.id,
431 file_size_bytes,
432 )
433 .await?;
434
435 tracing::info!(
436 "Media upload confirmed: user={}, folder={}, file={}, size={}",
437 user.id,
438 folder,
439 safe_filename,
440 file_size_bytes
441 );
442
443 let pending_review = if scan_status == db::FileScanStatus::HeldForReview {
444 Some(true)
445 } else {
446 None
447 };
448 Ok(Json(ConfirmUploadResponse {
449 success: true,
450 pending_review,
451 }))
452 }
453
454 /// List media files for the authenticated user.
455 ///
456 /// GET /api/media?folder={folder}
457 #[tracing::instrument(skip_all, name = "media::list", fields(user_id = %user.id))]
458 pub(super) async fn media_list(
459 State(db): State<PgPool>,
460 State(config): State<Config>,
461 AuthUser(user): AuthUser,
462 Query(query): Query<MediaListQuery>,
463 ) -> Result<impl IntoResponse> {
464 let cdn_base = config.cdn_base_url.as_str();
465
466 let files = db::media_files::list_by_user_folder(&db, user.id, query.folder.as_deref()).await?;
467
468 let folders = db::media_files::list_folders(&db, user.id).await?;
469
470 let file_responses: Vec<MediaFileResponse> = files
471 .iter()
472 .map(|f| file_to_response(f, cdn_base))
473 .collect();
474
475 Ok(Json(MediaListResponse {
476 files: file_responses,
477 folders,
478 }))
479 }
480
481 /// List distinct folder names for the authenticated user.
482 ///
483 /// GET /api/media/folders
484 #[tracing::instrument(skip_all, name = "media::folders", fields(user_id = %user.id))]
485 pub(super) async fn media_folders(
486 State(db): State<PgPool>,
487 AuthUser(user): AuthUser,
488 ) -> Result<impl IntoResponse> {
489 let folders = db::media_files::list_folders(&db, user.id).await?;
490 Ok(Json(MediaFoldersResponse { folders }))
491 }
492
493 /// Delete a media file.
494 ///
495 /// DELETE /api/media/{id}
496 #[tracing::instrument(skip_all, name = "media::delete", fields(user_id = %user.id, media_id = %id))]
497 pub(super) async fn media_delete(
498 State(db): State<PgPool>,
499 State(storage): State<AppStorage>,
500 AuthUser(user): AuthUser,
501 Path(id): Path<MediaFileId>,
502 ) -> Result<impl IntoResponse> {
503 user.check_not_suspended()?;
504 // Require S3 to be configured, but the actual delete goes through the
505 // durable queue (the sanctioned deletion path) rather than a direct call.
506 storage.require_s3()?;
507
508 let file = db::media_files::get_by_id(&db, id)
509 .await?
510 .ok_or(AppError::NotFound)?;
511
512 // Verify ownership
513 if file.user_id != user.id {
514 return Err(AppError::Forbidden);
515 }
516
517 // Commit the DB delete + storage refund together. Doing the DB delete
518 // FIRST (and only refunding when it commits) avoids the previous race
519 // where a failed inline S3 delete still decremented the counter.
520 //
521 // Refund ONLY when the DELETE actually removed a row: `get_by_id` above is
522 // outside the tx, so a concurrent double-delete (double-click / retry) can
523 // let both requests past it; gating the decrement on `delete(...).is_some()`
524 // stops the second one from decrementing storage a second time and
525 // under-counting `storage_used_bytes` in the creator's favor (Run #12 LOW,
526 // the delete-side mirror of the confirm handlers' rows-affected discipline).
527 // Row delete + storage refund + S3-deletion enqueue all in ONE transaction.
528 // Enqueueing inside the tx (rather than after commit) closes the crash window
529 // where a commit followed by a failed post-commit enqueue orphaned the object
530 // with no durable record (Run #18 Storage B6, the same in-tx ordering
531 // delete_version adopted). The refund + enqueue use the DELETE's own returned
532 // row (`deleted`), not the pre-tx `get_by_id` read.
533 let mut tx = db.begin().await?;
534 let deleted = db::media_files::delete(&mut *tx, id, user.id).await?;
535 if let Some(ref row) = deleted {
536 db::creator_tiers::decrement_storage_used(&mut *tx, user.id, row.file_size_bytes).await?;
537 db::pending_s3_deletions::enqueue_deletions(
538 &mut *tx,
539 &[(row.s3_key.clone(), "main".to_string())],
540 "media_delete",
541 )
542 .await?;
543 }
544 tx.commit().await?;
545 // The durable queue entry committed above is the source of truth; the
546 // queue worker performs the actual S3 delete (the only sanctioned path).
547
548 tracing::info!("Media file deleted: id={}, user={}", id, user.id);
549
550 Ok(Json(ConfirmUploadResponse {
551 success: true,
552 pending_review: None,
553 }))
554 }
555
556 #[cfg(test)]
557 mod tests {
558 use super::*;
559 use chrono::Utc;
560
561 fn make_media_file(folder: &str, filename: &str, s3_key: &str) -> db::DbMediaFile {
562 db::DbMediaFile {
563 id: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa".parse().unwrap(),
564 user_id: "11111111-1111-1111-1111-111111111111".parse().unwrap(),
565 folder: folder.to_string(),
566 filename: filename.to_string(),
567 s3_key: s3_key.to_string(),
568 content_type: "image/png".to_string(),
569 file_size_bytes: 1024,
570 media_type: "image".to_string(),
571 scan_status: "clean".to_string(),
572 created_at: Utc::now(),
573 }
574 }
575
576 #[test]
577 fn classify_media_image() {
578 let (media_type, file_type) = classify_media("image/png").unwrap();
579 assert_eq!(media_type, "image");
580 assert_eq!(file_type, FileType::MediaImage);
581 }
582
583 #[test]
584 fn classify_media_video() {
585 let (media_type, file_type) = classify_media("video/mp4").unwrap();
586 assert_eq!(media_type, "video");
587 assert_eq!(file_type, FileType::MediaVideo);
588 }
589
590 #[test]
591 fn classify_media_rejects_audio() {
592 assert!(classify_media("audio/mpeg").is_err());
593 }
594
595 #[test]
596 fn classify_media_rejects_text() {
597 assert!(classify_media("text/plain").is_err());
598 }
599
600 #[test]
601 fn classify_media_rejects_empty() {
602 assert!(classify_media("").is_err());
603 }
604
605 #[test]
606 fn file_to_response_root_folder() {
607 let f = make_media_file(
608 "",
609 "photo.png",
610 "11111111-1111-1111-1111-111111111111/media/photo.png",
611 );
612 let resp = file_to_response(&f, "https://cdn.example.com");
613 assert_eq!(
614 resp.cdn_url,
615 "https://cdn.example.com/11111111-1111-1111-1111-111111111111/media/photo.png"
616 );
617 assert_eq!(resp.markdown_ref, "![](photo.png)");
618 }
619
620 #[test]
621 fn file_to_response_with_folder() {
622 let f = make_media_file(
623 "screenshots",
624 "shot.png",
625 "11111111-1111-1111-1111-111111111111/media/screenshots/shot.png",
626 );
627 let resp = file_to_response(&f, "https://cdn.example.com");
628 assert_eq!(resp.markdown_ref, "![](screenshots/shot.png)");
629 }
630
631 #[test]
632 fn file_to_response_preserves_metadata() {
633 let f = make_media_file(
634 "docs",
635 "img.png",
636 "11111111-1111-1111-1111-111111111111/media/docs/img.png",
637 );
638 let resp = file_to_response(&f, "https://cdn.test");
639 assert_eq!(resp.folder, "docs");
640 assert_eq!(resp.filename, "img.png");
641 assert_eq!(resp.file_size_bytes, 1024);
642 assert_eq!(resp.media_type, "image");
643 }
644 }
645