gpt-image-2-web 0.6.0

Self-hosted Docker/Web server for GPT Image 2.
#![allow(unused_imports)]

use super::*;

pub(crate) fn remap_host_codex_app_path(path: &str) -> Option<PathBuf> {
    let marker = format!("/.codex/{CONFIG_DIR_NAME}");
    let marker_index = path.find(&marker)?;
    let suffix = path[marker_index + marker.len()..].trim_start_matches(['/', '\\']);
    let base = shared_config_dir();
    Some(if suffix.is_empty() {
        base
    } else {
        base.join(suffix)
    })
}

pub(crate) fn safe_job_file_path(path: &str) -> Result<PathBuf, ApiError> {
    let requested = remap_host_codex_app_path(path).unwrap_or_else(|| PathBuf::from(path));
    let file = requested
        .canonicalize()
        .map_err(|_| ApiError::not_found("文件不存在,可能已被移动或删除。"))?;
    if !file.is_file() {
        return Err(ApiError::not_found("文件不存在,可能已被移动或删除。"));
    }
    let library = result_library_dir();
    fs::create_dir_all(&library).map_err(|error| ApiError::internal(error.to_string()))?;
    let root = library
        .canonicalize()
        .map_err(|error| ApiError::internal(error.to_string()))?;
    if file.starts_with(&root) {
        return Ok(file);
    }
    let config = load_config_or_default();
    if config.paths.legacy_shared_codex_dir.enabled_for_read {
        let legacy = legacy_jobs_dir(Some(&config));
        let legacy_root = legacy.canonicalize().ok();
        if legacy_root
            .as_ref()
            .is_some_and(|root| file.starts_with(root))
        {
            return Ok(file);
        }
    }
    Err(ApiError::forbidden("只能读取当前服务生成的任务文件。"))
}

pub(crate) async fn file_response(Query(query): Query<FileQuery>) -> Result<Response, ApiError> {
    let path = safe_job_file_path(&query.path)?;
    let bytes = tokio::fs::read(&path)
        .await
        .map_err(|error| ApiError::not_found(error.to_string()))?;
    let mime = mime_guess::from_path(&path).first_or_octet_stream();
    let file_name = path
        .file_name()
        .and_then(|name| name.to_str())
        .unwrap_or("image.png");
    Response::builder()
        .status(StatusCode::OK)
        .header(header::CONTENT_TYPE, mime.as_ref())
        .header(header::CACHE_CONTROL, "private, max-age=31536000")
        .header(
            header::CONTENT_DISPOSITION,
            format!("inline; filename=\"{file_name}\""),
        )
        .body(Body::from(bytes))
        .map_err(|error| ApiError::internal(error.to_string()))
}

pub(crate) async fn job_output_response(
    Path((job_id, output_index)): Path<(String, usize)>,
) -> Result<Response, ApiError> {
    let config = load_config().map_err(ApiError::internal)?;
    let job = show_history_job(&job_id)
        .map_err(app_error)
        .map_err(ApiError::not_found)?;
    let readback = read_job_output_from_storage_with_options(
        &config.storage,
        &job,
        output_index,
        StorageReadbackOptions {
            allow_archive_fallback: true,
            rehydrate_local_cache: true,
            local_cache_roots: local_cache_roots_for_product(&config),
        },
    )
    .map_err(app_error)
    .map_err(ApiError::not_found)?;
    let file_name = output_file_name_from_job(&job, output_index);
    let mime = mime_guess::from_path(&file_name).first_or_octet_stream();
    Response::builder()
        .status(StatusCode::OK)
        .header(header::CONTENT_TYPE, mime.as_ref())
        .header(header::CACHE_CONTROL, "private, max-age=31536000")
        .header(
            header::CONTENT_DISPOSITION,
            format!("inline; filename=\"{file_name}\""),
        )
        .body(Body::from(readback.bytes))
        .map_err(|error| ApiError::internal(error.to_string()))
}

fn output_file_name_from_job(job: &Value, output_index: usize) -> String {
    job.get("outputs")
        .and_then(Value::as_array)
        .and_then(|outputs| {
            outputs.iter().find_map(|output| {
                let index = output
                    .get("index")
                    .and_then(Value::as_u64)
                    .map(|value| value as usize)?;
                if index == output_index {
                    output
                        .get("path")
                        .and_then(Value::as_str)
                        .and_then(|path| FsPath::new(path).file_name())
                        .and_then(|name| name.to_str())
                        .map(ToString::to_string)
                } else {
                    None
                }
            })
        })
        .unwrap_or_else(|| format!("output-{}.png", output_index + 1))
}