Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 3 additions & 7 deletions packages/api-server/src/domains/posts/service.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2272,6 +2272,8 @@ pub async fn list_tries_by_spot(
/// 까지 한 트랜잭션으로 묶을 수 있도록 변경 (#350).
pub async fn create_post_from_raw<C: sea_orm::ConnectionTrait>(
db: &C,
post_id: Uuid,
image_url: String,
admin_id: Uuid,
raw_post: &crate::entities::assets_raw_posts::Model,
dto: crate::domains::raw_posts::dto::VerifyRawPostDto,
Expand All @@ -2282,12 +2284,6 @@ pub async fn create_post_from_raw<C: sea_orm::ConnectionTrait>(
subject_style_tags: Option<&[String]>,
) -> AppResult<PostResponse> {
let now = chrono::Utc::now().fixed_offset();
let image_url = raw_post.image_url.clone().ok_or_else(|| {
AppError::BadRequest(format!(
"raw_post {} has no image_url — cannot create post",
raw_post.id
))
})?;

// title 우선순위: admin 입력 (or compose_title 결과 호출자가 미리 주입) →
// pin caption (보통 비영어/길어 마지막 fallback). subject_parser.title_en 은
Expand Down Expand Up @@ -2331,7 +2327,7 @@ pub async fn create_post_from_raw<C: sea_orm::ConnectionTrait>(
};

let post = ActiveModel {
id: Set(Uuid::new_v4()),
id: Set(post_id),
user_id: Set(admin_id),
image_url: Set(image_url),
media_type: Set("image".to_string()),
Expand Down
1 change: 1 addition & 0 deletions packages/api-server/src/domains/raw_posts/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@

pub mod dto;
pub mod handlers;
pub(crate) mod relocate;
pub mod service;

pub use handlers::router;
229 changes: 229 additions & 0 deletions packages/api-server/src/domains/raw_posts/relocate.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,229 @@
//! verify-time R2 relocation (#466 follow-up).
//!
//! Verify 시점에 raw bucket (assets storage) 의 hero / item thumbnail R2 객체를
//! operation bucket 으로 복사한다. CopyObject 권한 이슈 회피 위해
//! `download → upload` 두 단계로 처리 — bytes 가 우리 머신을 잠시 통과하지만
//! 평균 1MB 이미지라 비용 무시.
//!
//! caller 책임:
//! - relocate 성공 후 raw 객체 best-effort delete (단일 책임 분리)
//! - new_key 의 사전 생성 (e.g. `posts/{post_id}`, `items/{solution_id}`)

use crate::error::{AppError, AppResult};
use crate::services::storage::StorageClient;

use super::service::extract_assets_r2_key;

/// raw bucket 의 객체를 operation bucket 의 새 키로 복사한다.
///
/// `raw_url` 이 `raw_public_url` prefix 가 아니면 `BadRequest` — verify 흐름은
/// raw bucket URL 만 가정 (외부 hotlink 은 별도 PR).
///
/// 반환: operation public URL (`{op_public_url}/{new_key}`).
pub(crate) async fn relocate_raw_to_operation(
raw_storage: &dyn StorageClient,
op_storage: &dyn StorageClient,
raw_url: &str,
raw_public_url: &str,
op_public_url: &str,
new_key: &str,
fallback_content_type: &str,
) -> AppResult<String> {
let raw_key = extract_assets_r2_key(raw_url, raw_public_url).ok_or_else(|| {
AppError::BadRequest(format!(
"non-raw URL cannot be relocated: {raw_url:?} (raw_public_url={raw_public_url:?})"
))
})?;

let (bytes, ct) = raw_storage.download(&raw_key).await?;
let content_type = ct.as_deref().unwrap_or(fallback_content_type);

op_storage
.upload(new_key, bytes, content_type)
.await
.map_err(|e| {
AppError::ExternalService(format!(
"relocate: upload to operation failed (key={new_key}): {e}"
))
})?;

Ok(format!(
"{}/{}",
op_public_url.trim_end_matches('/'),
new_key
))
}

#[cfg(test)]
#[allow(clippy::disallowed_methods)]
mod tests {
use super::*;

use async_trait::async_trait;
use std::collections::HashMap;
use std::sync::Mutex;

/// 테스트용 in-memory StorageClient — download 가 받은 bytes 를 upload 가
/// 받았는지 검증할 수 있도록 호출 기록 보존.
struct InMemoryStorage {
objects: Mutex<HashMap<String, (Vec<u8>, Option<String>)>>,
upload_log: Mutex<Vec<(String, Vec<u8>, String)>>,
public_url: String,
}

impl InMemoryStorage {
fn new(public_url: &str) -> Self {
Self {
objects: Mutex::new(HashMap::new()),
upload_log: Mutex::new(Vec::new()),
public_url: public_url.to_string(),
}
}

fn put(&self, key: &str, bytes: Vec<u8>, ct: Option<String>) {
self.objects
.lock()
.unwrap()
.insert(key.to_string(), (bytes, ct));
}

fn upload_count(&self) -> usize {
self.upload_log.lock().unwrap().len()
}
}

#[async_trait]
impl StorageClient for InMemoryStorage {
async fn upload(
&self,
key: &str,
data: Vec<u8>,
content_type: &str,
) -> Result<String, AppError> {
self.upload_log.lock().unwrap().push((
key.to_string(),
data.clone(),
content_type.to_string(),
));
self.objects
.lock()
.unwrap()
.insert(key.to_string(), (data, Some(content_type.to_string())));
Ok(self.get_url(key))
}

async fn delete(&self, key: &str) -> Result<(), AppError> {
self.objects.lock().unwrap().remove(key);
Ok(())
}

async fn download(&self, key: &str) -> Result<(Vec<u8>, Option<String>), AppError> {
self.objects
.lock()
.unwrap()
.get(key)
.cloned()
.ok_or_else(|| AppError::NotFound(format!("not found: {key}")))
}

fn get_url(&self, key: &str) -> String {
format!("{}/{}", self.public_url.trim_end_matches('/'), key)
}
}

#[tokio::test]
async fn relocate_happy_path_passes_bytes_and_content_type() {
let raw = InMemoryStorage::new("https://pub-x.r2.dev");
let op = InMemoryStorage::new("https://r2.decoded.style");

raw.put(
"starstyle/92/926700.jpg",
vec![0xff, 0xd8, 0xff, 0xe0],
Some("image/jpeg".to_string()),
);

let new_url = relocate_raw_to_operation(
&raw,
&op,
"https://pub-x.r2.dev/starstyle/92/926700.jpg",
"https://pub-x.r2.dev",
"https://r2.decoded.style",
"posts/abc-123",
"image/jpeg",
)
.await
.unwrap();

assert_eq!(new_url, "https://r2.decoded.style/posts/abc-123");
let log = op.upload_log.lock().unwrap();
assert_eq!(log.len(), 1);
assert_eq!(log[0].0, "posts/abc-123");
assert_eq!(log[0].1, vec![0xff, 0xd8, 0xff, 0xe0]);
assert_eq!(log[0].2, "image/jpeg");
}

#[tokio::test]
async fn relocate_uses_fallback_content_type_when_missing() {
let raw = InMemoryStorage::new("https://pub-x.r2.dev");
let op = InMemoryStorage::new("https://r2.decoded.style");
raw.put("starstyle/x.bin", vec![0u8; 8], None);

relocate_raw_to_operation(
&raw,
&op,
"https://pub-x.r2.dev/starstyle/x.bin",
"https://pub-x.r2.dev",
"https://r2.decoded.style",
"posts/abc",
"image/png",
)
.await
.unwrap();

let log = op.upload_log.lock().unwrap();
assert_eq!(log[0].2, "image/png");
}

#[tokio::test]
async fn relocate_rejects_non_raw_url() {
let raw = InMemoryStorage::new("https://pub-x.r2.dev");
let op = InMemoryStorage::new("https://r2.decoded.style");

let err = relocate_raw_to_operation(
&raw,
&op,
"https://i.pinimg.com/anything/abc.jpg",
"https://pub-x.r2.dev",
"https://r2.decoded.style",
"posts/abc",
"image/jpeg",
)
.await
.unwrap_err();

assert!(matches!(err, AppError::BadRequest(_)), "got {err:?}");
assert_eq!(op.upload_count(), 0);
}

#[tokio::test]
async fn relocate_propagates_download_not_found() {
let raw = InMemoryStorage::new("https://pub-x.r2.dev");
let op = InMemoryStorage::new("https://r2.decoded.style");
// raw.put 안 함 — download 시 NotFound

let err = relocate_raw_to_operation(
&raw,
&op,
"https://pub-x.r2.dev/missing.jpg",
"https://pub-x.r2.dev",
"https://r2.decoded.style",
"posts/abc",
"image/jpeg",
)
.await
.unwrap_err();

assert!(matches!(err, AppError::NotFound(_)), "got {err:?}");
assert_eq!(op.upload_count(), 0);
}
}
Loading
Loading