Multi-tenant: albums owned per photographer
- albums.owner_id (migration 0003, backfilled to the original user)
- owned::{album,photo,share} are the only admin data-access paths; another
tenant's resources are indistinguishable from nonexistent (404)
- every admin handler threaded through ownership; each ALLOWED_EMAILS entry
is now its own isolated workspace
- tenant-isolation integration test matrix (tests/tenancy.rs, env-gated on
TEST_DATABASE_URL) driving the real router
This commit is contained in:
+10
-7
@@ -9,9 +9,11 @@ use sha2::Digest;
|
||||
use tokio::io::AsyncWriteExt;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::auth::AuthUser;
|
||||
use crate::error::{ApiError, ApiResult};
|
||||
use crate::jobs;
|
||||
use crate::models::{JobKind, Photo, PhotoStatus};
|
||||
use crate::owned;
|
||||
use crate::s3;
|
||||
use crate::state::AppState;
|
||||
|
||||
@@ -41,18 +43,13 @@ fn sanitize_filename(raw: &str) -> Result<String, ApiError> {
|
||||
|
||||
pub async fn upload(
|
||||
State(state): State<AppState>,
|
||||
user: AuthUser,
|
||||
Path(album_id): Path<Uuid>,
|
||||
Query(query): Query<UploadQuery>,
|
||||
headers: HeaderMap,
|
||||
body: Body,
|
||||
) -> ApiResult<Json<Photo>> {
|
||||
let album_exists: Option<(Uuid,)> = sqlx::query_as("select id from albums where id = $1")
|
||||
.bind(album_id)
|
||||
.fetch_optional(&state.db)
|
||||
.await?;
|
||||
if album_exists.is_none() {
|
||||
return Err(ApiError::not_found());
|
||||
}
|
||||
owned::album(&state, album_id, user.id).await?;
|
||||
|
||||
let filename = sanitize_filename(&query.filename)?;
|
||||
let content_type = headers
|
||||
@@ -178,8 +175,10 @@ pub async fn upload(
|
||||
/// content already exists in the album.
|
||||
pub async fn by_hash(
|
||||
State(state): State<AppState>,
|
||||
user: AuthUser,
|
||||
Path((album_id, sha256)): Path<(Uuid, String)>,
|
||||
) -> ApiResult<Json<Photo>> {
|
||||
owned::album(&state, album_id, user.id).await?;
|
||||
let photo: Option<Photo> =
|
||||
sqlx::query_as("select * from photos where album_id = $1 and sha256 = $2")
|
||||
.bind(album_id)
|
||||
@@ -191,8 +190,10 @@ pub async fn by_hash(
|
||||
|
||||
pub async fn delete(
|
||||
State(state): State<AppState>,
|
||||
user: AuthUser,
|
||||
Path(photo_id): Path<Uuid>,
|
||||
) -> ApiResult<Json<serde_json::Value>> {
|
||||
owned::photo(&state, photo_id, user.id).await?;
|
||||
let mut tx = state.db.begin().await?;
|
||||
let deleted = sqlx::query("delete from photos where id = $1")
|
||||
.bind(photo_id)
|
||||
@@ -213,8 +214,10 @@ pub async fn delete(
|
||||
|
||||
pub async fn reprocess(
|
||||
State(state): State<AppState>,
|
||||
user: AuthUser,
|
||||
Path(photo_id): Path<Uuid>,
|
||||
) -> ApiResult<Json<serde_json::Value>> {
|
||||
owned::photo(&state, photo_id, user.id).await?;
|
||||
let mut tx = state.db.begin().await?;
|
||||
let updated = sqlx::query("update photos set status = $2, error = null where id = $1")
|
||||
.bind(photo_id)
|
||||
|
||||
Reference in New Issue
Block a user