From 376ac1dec9d96b0542ed38f06ae5bdf72f0a0bcf Mon Sep 17 00:00:00 2001 From: Danilo Reyes Date: Sun, 2 Nov 2025 18:09:07 -0600 Subject: [PATCH 1/4] fix part 1 --- backend/app/api/images.py | 5 +- backend/app/database/models/image.py | 2 +- backend/app/images/repository.py | 193 ++----- frontend/src/lib/api/images.ts | 22 +- frontend/src/routes/+page.svelte | 57 +++ frontend/src/routes/+page.ts | 2 + frontend/src/routes/boards/[id]/+page.svelte | 506 +++++++++++++++++++ frontend/vite.config.ts | 11 + 8 files changed, 637 insertions(+), 161 deletions(-) create mode 100644 frontend/src/routes/+page.svelte create mode 100644 frontend/src/routes/+page.ts create mode 100644 frontend/src/routes/boards/[id]/+page.svelte create mode 100644 frontend/vite.config.ts diff --git a/backend/app/api/images.py b/backend/app/api/images.py index c4014f1..2c94049 100644 --- a/backend/app/api/images.py +++ b/backend/app/api/images.py @@ -3,10 +3,9 @@ from uuid import UUID from fastapi import APIRouter, Depends, File, HTTPException, UploadFile, status -from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import Session -from app.auth.jwt import get_current_user -from app.core.deps import get_db +from app.core.deps import get_current_user, get_db from app.database.models.board import Board from app.database.models.user import User from app.images.processing import generate_thumbnails diff --git a/backend/app/database/models/image.py b/backend/app/database/models/image.py index 0ad8010..5bfa442 100644 --- a/backend/app/database/models/image.py +++ b/backend/app/database/models/image.py @@ -36,7 +36,7 @@ class Image(Base): mime_type: Mapped[str] = mapped_column(String(100), nullable=False) width: Mapped[int] = mapped_column(Integer, nullable=False) height: Mapped[int] = mapped_column(Integer, nullable=False) - metadata: Mapped[dict] = mapped_column(JSONB, nullable=False) + image_metadata: Mapped[dict] = mapped_column(JSONB, nullable=False) created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=datetime.utcnow) reference_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0) diff --git a/backend/app/images/repository.py b/backend/app/images/repository.py index 2944caa..14b321e 100644 --- a/backend/app/images/repository.py +++ b/backend/app/images/repository.py @@ -3,8 +3,7 @@ from collections.abc import Sequence from uuid import UUID -from sqlalchemy import select -from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import Session from app.database.models.board_image import BoardImage from app.database.models.image import Image @@ -13,11 +12,11 @@ from app.database.models.image import Image class ImageRepository: """Repository for image database operations.""" - def __init__(self, db: AsyncSession): + def __init__(self, db: Session): """Initialize repository with database session.""" self.db = db - async def create_image( + def create_image( self, user_id: UUID, filename: str, @@ -28,22 +27,7 @@ class ImageRepository: height: int, metadata: dict, ) -> Image: - """ - Create new image record. - - Args: - user_id: Owner user ID - filename: Original filename - storage_path: Path in MinIO - file_size: File size in bytes - mime_type: MIME type - width: Image width in pixels - height: Image height in pixels - metadata: Additional metadata (format, checksum, thumbnails, etc) - - Returns: - Created Image instance - """ + """Create new image record.""" image = Image( user_id=user_id, filename=filename, @@ -52,98 +36,59 @@ class ImageRepository: mime_type=mime_type, width=width, height=height, - metadata=metadata, + image_metadata=metadata, ) self.db.add(image) - await self.db.commit() - await self.db.refresh(image) + self.db.commit() + self.db.refresh(image) return image - async def get_image_by_id(self, image_id: UUID) -> Image | None: - """ - Get image by ID. + def get_image_by_id(self, image_id: UUID) -> Image | None: + """Get image by ID.""" + return self.db.query(Image).filter(Image.id == image_id).first() - Args: - image_id: Image ID - - Returns: - Image instance or None - """ - result = await self.db.execute(select(Image).where(Image.id == image_id)) - return result.scalar_one_or_none() - - async def get_user_images(self, user_id: UUID, limit: int = 50, offset: int = 0) -> tuple[Sequence[Image], int]: - """ - Get all images for a user with pagination. - - Args: - user_id: User ID - limit: Maximum number of images to return - offset: Number of images to skip - - Returns: - Tuple of (images, total_count) - """ - # Get total count - count_result = await self.db.execute(select(Image).where(Image.user_id == user_id)) - total = len(count_result.scalars().all()) - - # Get paginated results - result = await self.db.execute( - select(Image).where(Image.user_id == user_id).order_by(Image.created_at.desc()).limit(limit).offset(offset) + def get_user_images( + self, user_id: UUID, limit: int = 50, offset: int = 0 + ) -> tuple[Sequence[Image], int]: + """Get all images for a user with pagination.""" + total = self.db.query(Image).filter(Image.user_id == user_id).count() + images = ( + self.db.query(Image) + .filter(Image.user_id == user_id) + .order_by(Image.created_at.desc()) + .limit(limit) + .offset(offset) + .all() ) - images = result.scalars().all() - return images, total - async def delete_image(self, image_id: UUID) -> bool: - """ - Delete image record. - - Args: - image_id: Image ID - - Returns: - True if deleted, False if not found - """ - image = await self.get_image_by_id(image_id) + def delete_image(self, image_id: UUID) -> bool: + """Delete image record.""" + image = self.get_image_by_id(image_id) if not image: return False - await self.db.delete(image) - await self.db.commit() + self.db.delete(image) + self.db.commit() return True - async def increment_reference_count(self, image_id: UUID) -> None: - """ - Increment reference count for image. - - Args: - image_id: Image ID - """ - image = await self.get_image_by_id(image_id) + def increment_reference_count(self, image_id: UUID) -> None: + """Increment reference count for image.""" + image = self.get_image_by_id(image_id) if image: image.reference_count += 1 - await self.db.commit() + self.db.commit() - async def decrement_reference_count(self, image_id: UUID) -> int: - """ - Decrement reference count for image. - - Args: - image_id: Image ID - - Returns: - New reference count - """ - image = await self.get_image_by_id(image_id) + def decrement_reference_count(self, image_id: UUID) -> int: + """Decrement reference count for image.""" + image = self.get_image_by_id(image_id) if image and image.reference_count > 0: image.reference_count -= 1 - await self.db.commit() + self.db.commit() return image.reference_count return 0 - async def add_image_to_board( + def add_image_to_board( self, board_id: UUID, image_id: UUID, @@ -151,19 +96,7 @@ class ImageRepository: transformations: dict, z_order: int = 0, ) -> BoardImage: - """ - Add image to board. - - Args: - board_id: Board ID - image_id: Image ID - position: Canvas position {x, y} - transformations: Image transformations - z_order: Layer order - - Returns: - Created BoardImage instance - """ + """Add image to board.""" board_image = BoardImage( board_id=board_id, image_id=image_id, @@ -174,50 +107,36 @@ class ImageRepository: self.db.add(board_image) # Increment reference count - await self.increment_reference_count(image_id) + self.increment_reference_count(image_id) - await self.db.commit() - await self.db.refresh(board_image) + self.db.commit() + self.db.refresh(board_image) return board_image - async def get_board_images(self, board_id: UUID) -> Sequence[BoardImage]: - """ - Get all images for a board, ordered by z-order. - - Args: - board_id: Board ID - - Returns: - List of BoardImage instances - """ - result = await self.db.execute( - select(BoardImage).where(BoardImage.board_id == board_id).order_by(BoardImage.z_order.asc()) + def get_board_images(self, board_id: UUID) -> Sequence[BoardImage]: + """Get all images for a board, ordered by z-order.""" + return ( + self.db.query(BoardImage) + .filter(BoardImage.board_id == board_id) + .order_by(BoardImage.z_order.asc()) + .all() ) - return result.scalars().all() - async def remove_image_from_board(self, board_id: UUID, image_id: UUID) -> bool: - """ - Remove image from board. - - Args: - board_id: Board ID - image_id: Image ID - - Returns: - True if removed, False if not found - """ - result = await self.db.execute( - select(BoardImage).where(BoardImage.board_id == board_id, BoardImage.image_id == image_id) + def remove_image_from_board(self, board_id: UUID, image_id: UUID) -> bool: + """Remove image from board.""" + board_image = ( + self.db.query(BoardImage) + .filter(BoardImage.board_id == board_id, BoardImage.image_id == image_id) + .first() ) - board_image = result.scalar_one_or_none() if not board_image: return False - await self.db.delete(board_image) + self.db.delete(board_image) # Decrement reference count - await self.decrement_reference_count(image_id) + self.decrement_reference_count(image_id) - await self.db.commit() + self.db.commit() return True diff --git a/frontend/src/lib/api/images.ts b/frontend/src/lib/api/images.ts index d61feb6..3526f65 100644 --- a/frontend/src/lib/api/images.ts +++ b/frontend/src/lib/api/images.ts @@ -9,32 +9,14 @@ import type { Image, BoardImage, ImageListResponse } from '$lib/types/images'; * Upload a single image */ export async function uploadImage(file: File): Promise { - const formData = new FormData(); - formData.append('file', file); - - const response = await apiClient.post('/images/upload', formData, { - headers: { - 'Content-Type': 'multipart/form-data', - }, - }); - - return response; + return await apiClient.uploadFile('/images/upload', file); } /** * Upload multiple images from a ZIP file */ export async function uploadZip(file: File): Promise { - const formData = new FormData(); - formData.append('file', file); - - const response = await apiClient.post('/images/upload-zip', formData, { - headers: { - 'Content-Type': 'multipart/form-data', - }, - }); - - return response; + return await apiClient.uploadFile('/images/upload-zip', file); } /** diff --git a/frontend/src/routes/+page.svelte b/frontend/src/routes/+page.svelte new file mode 100644 index 0000000..403d77f --- /dev/null +++ b/frontend/src/routes/+page.svelte @@ -0,0 +1,57 @@ + + + + Reference Board Viewer + + +
+
+

Loading...

+
+ + diff --git a/frontend/src/routes/+page.ts b/frontend/src/routes/+page.ts new file mode 100644 index 0000000..502318d --- /dev/null +++ b/frontend/src/routes/+page.ts @@ -0,0 +1,2 @@ +// Disable server-side rendering for the root page +export const ssr = false; diff --git a/frontend/src/routes/boards/[id]/+page.svelte b/frontend/src/routes/boards/[id]/+page.svelte new file mode 100644 index 0000000..98f0ccc --- /dev/null +++ b/frontend/src/routes/boards/[id]/+page.svelte @@ -0,0 +1,506 @@ + + + + {$currentBoard?.title || 'Board'} - Reference Board Viewer + + + + + diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts new file mode 100644 index 0000000..3893b92 --- /dev/null +++ b/frontend/vite.config.ts @@ -0,0 +1,11 @@ +import { sveltekit } from '@sveltejs/kit/vite'; +import { defineConfig } from 'vite'; + +export default defineConfig({ + plugins: [sveltekit()], + server: { + port: 5173, + strictPort: false, + }, +}); + From 209b6d9f18b41ae23cf436bf1edbdd6f5ca0aa6d Mon Sep 17 00:00:00 2001 From: Danilo Reyes Date: Sun, 2 Nov 2025 18:23:10 -0600 Subject: [PATCH 2/4] fix part 2 --- backend/app/api/auth.py | 6 +- backend/app/api/images.py | 47 +++----- backend/app/api/sharing.py | 6 +- backend/app/auth/jwt.py | 8 +- backend/app/boards/sharing.py | 6 +- backend/app/core/deps.py | 98 +++++++++++++++- backend/app/database/base.py | 4 +- backend/app/database/models/board.py | 6 +- backend/app/database/models/board_image.py | 6 +- backend/app/database/models/comment.py | 4 +- backend/app/database/models/group.py | 6 +- backend/app/database/models/image.py | 4 +- backend/app/database/models/share_link.py | 4 +- backend/app/database/models/user.py | 6 +- backend/app/database/session.py | 32 +++--- backend/app/images/repository.py | 128 ++++++++++++++------- backend/app/images/schemas.py | 6 +- flake.nix | 76 +++++++++++- 18 files changed, 328 insertions(+), 125 deletions(-) diff --git a/backend/app/api/auth.py b/backend/app/api/auth.py index 5461ed5..8e02f34 100644 --- a/backend/app/api/auth.py +++ b/backend/app/api/auth.py @@ -7,14 +7,14 @@ from app.auth.jwt import create_access_token from app.auth.repository import UserRepository from app.auth.schemas import TokenResponse, UserCreate, UserLogin, UserResponse from app.auth.security import validate_password_strength, verify_password -from app.core.deps import get_current_user, get_db +from app.core.deps import get_current_user, get_db_sync from app.database.models.user import User router = APIRouter(prefix="/auth", tags=["auth"]) @router.post("/register", response_model=UserResponse, status_code=status.HTTP_201_CREATED) -def register_user(user_data: UserCreate, db: Session = Depends(get_db)): +def register_user(user_data: UserCreate, db: Session = Depends(get_db_sync)): """ Register a new user. @@ -46,7 +46,7 @@ def register_user(user_data: UserCreate, db: Session = Depends(get_db)): @router.post("/login", response_model=TokenResponse) -def login_user(login_data: UserLogin, db: Session = Depends(get_db)): +def login_user(login_data: UserLogin, db: Session = Depends(get_db_sync)): """ Login user and return JWT token. diff --git a/backend/app/api/images.py b/backend/app/api/images.py index 2c94049..17a5261 100644 --- a/backend/app/api/images.py +++ b/backend/app/api/images.py @@ -3,9 +3,10 @@ from uuid import UUID from fastapi import APIRouter, Depends, File, HTTPException, UploadFile, status -from sqlalchemy.orm import Session +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession -from app.core.deps import get_current_user, get_db +from app.core.deps import get_current_user_async, get_db from app.database.models.board import Board from app.database.models.user import User from app.images.processing import generate_thumbnails @@ -30,7 +31,7 @@ router = APIRouter(prefix="/images", tags=["images"]) @router.post("/upload", response_model=ImageUploadResponse, status_code=status.HTTP_201_CREATED) async def upload_image( file: UploadFile = File(...), - current_user: User = Depends(get_current_user), + current_user: User = Depends(get_current_user_async), db: AsyncSession = Depends(get_db), ): """ @@ -64,7 +65,7 @@ async def upload_image( checksum = calculate_checksum(contents) # Create metadata - metadata = {"format": mime_type.split("/")[1], "checksum": checksum, "thumbnails": thumbnail_paths} + image_metadata = {"format": mime_type.split("/")[1], "checksum": checksum, "thumbnails": thumbnail_paths} # Create database record repo = ImageRepository(db) @@ -76,7 +77,7 @@ async def upload_image( mime_type=mime_type, width=width, height=height, - metadata=metadata, + image_metadata=image_metadata, ) return image @@ -85,7 +86,7 @@ async def upload_image( @router.post("/upload-zip", response_model=list[ImageUploadResponse]) async def upload_zip( file: UploadFile = File(...), - current_user: User = Depends(get_current_user), + current_user: User = Depends(get_current_user_async), db: AsyncSession = Depends(get_db), ): """ @@ -120,7 +121,7 @@ async def upload_zip( checksum = calculate_checksum(contents) # Create metadata - metadata = { + img_metadata = { "format": mime_type.split("/")[1], "checksum": checksum, "thumbnails": thumbnail_paths, @@ -135,7 +136,7 @@ async def upload_zip( mime_type=mime_type, width=width, height=height, - metadata=metadata, + image_metadata=img_metadata, ) uploaded_images.append(image) @@ -155,7 +156,7 @@ async def upload_zip( async def get_image_library( page: int = 1, page_size: int = 50, - current_user: User = Depends(get_current_user), + current_user: User = Depends(get_current_user_async), db: AsyncSession = Depends(get_db), ): """ @@ -173,7 +174,7 @@ async def get_image_library( @router.get("/{image_id}", response_model=ImageResponse) async def get_image( image_id: UUID, - current_user: User = Depends(get_current_user), + current_user: User = Depends(get_current_user_async), db: AsyncSession = Depends(get_db), ): """Get image by ID.""" @@ -193,7 +194,7 @@ async def get_image( @router.delete("/{image_id}", status_code=status.HTTP_204_NO_CONTENT) async def delete_image( image_id: UUID, - current_user: User = Depends(get_current_user), + current_user: User = Depends(get_current_user_async), db: AsyncSession = Depends(get_db), ): """ @@ -223,8 +224,8 @@ async def delete_image( from app.images.upload import delete_image_from_storage await delete_image_from_storage(image.storage_path) - if "thumbnails" in image.metadata: - await delete_thumbnails(image.metadata["thumbnails"]) + if "thumbnails" in image.image_metadata: + await delete_thumbnails(image.image_metadata["thumbnails"]) # Delete from database await repo.delete_image(image_id) @@ -234,7 +235,7 @@ async def delete_image( async def add_image_to_board( board_id: UUID, data: BoardImageCreate, - current_user: User = Depends(get_current_user), + current_user: User = Depends(get_current_user_async), db: AsyncSession = Depends(get_db), ): """ @@ -243,8 +244,6 @@ async def add_image_to_board( The image must already be uploaded and owned by the current user. """ # Verify board ownership - from sqlalchemy import select - board_result = await db.execute(select(Board).where(Board.id == board_id)) board = board_result.scalar_one_or_none() @@ -284,7 +283,7 @@ async def update_board_image( board_id: UUID, image_id: UUID, data: BoardImageUpdate, - current_user: User = Depends(get_current_user), + current_user: User = Depends(get_current_user_async), db: AsyncSession = Depends(get_db), ): """ @@ -294,8 +293,6 @@ async def update_board_image( Only provided fields are updated. """ # Verify board ownership - from sqlalchemy import select - board_result = await db.execute(select(Board).where(Board.id == board_id)) board = board_result.scalar_one_or_none() @@ -329,7 +326,7 @@ async def update_board_image( async def remove_image_from_board( board_id: UUID, image_id: UUID, - current_user: User = Depends(get_current_user), + current_user: User = Depends(get_current_user_async), db: AsyncSession = Depends(get_db), ): """ @@ -339,8 +336,6 @@ async def remove_image_from_board( The image remains in the user's library. """ # Verify board ownership - from sqlalchemy import select - board_result = await db.execute(select(Board).where(Board.id == board_id)) board = board_result.scalar_one_or_none() @@ -362,7 +357,7 @@ async def remove_image_from_board( async def bulk_update_board_images( board_id: UUID, data: BulkImageUpdate, - current_user: User = Depends(get_current_user), + current_user: User = Depends(get_current_user_async), db: AsyncSession = Depends(get_db), ): """ @@ -371,8 +366,6 @@ async def bulk_update_board_images( Applies the same changes to all specified images. Useful for multi-selection operations. """ # Verify board ownership - from sqlalchemy import select - board_result = await db.execute(select(Board).where(Board.id == board_id)) board = board_result.scalar_one_or_none() @@ -438,7 +431,7 @@ async def bulk_update_board_images( @router.get("/boards/{board_id}/images", response_model=list[BoardImageResponse]) async def get_board_images( board_id: UUID, - current_user: User = Depends(get_current_user), + current_user: User = Depends(get_current_user_async), db: AsyncSession = Depends(get_db), ): """ @@ -447,8 +440,6 @@ async def get_board_images( Used for loading board contents in the canvas. """ # Verify board access (owner or shared link - for now just owner) - from sqlalchemy import select - board_result = await db.execute(select(Board).where(Board.id == board_id)) board = board_result.scalar_one_or_none() diff --git a/backend/app/api/sharing.py b/backend/app/api/sharing.py index ba15917..e0b7daa 100644 --- a/backend/app/api/sharing.py +++ b/backend/app/api/sharing.py @@ -1,6 +1,6 @@ """Board sharing API endpoints.""" -from datetime import datetime +from datetime import datetime, timezone from uuid import UUID from fastapi import APIRouter, Depends, HTTPException, status @@ -54,7 +54,7 @@ def validate_share_link(token: str, db: Session, required_permission: str = "vie ) # Check expiration - if share_link.expires_at and share_link.expires_at < datetime.utcnow(): + if share_link.expires_at and share_link.expires_at < datetime.now(timezone.utc): raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="Share link has expired", @@ -69,7 +69,7 @@ def validate_share_link(token: str, db: Session, required_permission: str = "vie # Update access tracking share_link.access_count += 1 - share_link.last_accessed_at = datetime.utcnow() + share_link.last_accessed_at = datetime.now(timezone.utc) db.commit() return share_link diff --git a/backend/app/auth/jwt.py b/backend/app/auth/jwt.py index 6bd5411..6ebc1df 100644 --- a/backend/app/auth/jwt.py +++ b/backend/app/auth/jwt.py @@ -1,6 +1,6 @@ """JWT token generation and validation.""" -from datetime import datetime, timedelta +from datetime import datetime, timedelta, timezone from uuid import UUID from jose import JWTError, jwt @@ -21,11 +21,11 @@ def create_access_token(user_id: UUID, email: str, expires_delta: timedelta | No Encoded JWT token string """ if expires_delta: - expire = datetime.utcnow() + expires_delta + expire = datetime.now(timezone.utc) + expires_delta else: - expire = datetime.utcnow() + timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES) + expire = datetime.now(timezone.utc) + timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES) - to_encode = {"sub": str(user_id), "email": email, "exp": expire, "iat": datetime.utcnow(), "type": "access"} + to_encode = {"sub": str(user_id), "email": email, "exp": expire, "iat": datetime.now(timezone.utc), "type": "access"} encoded_jwt = jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM) return encoded_jwt diff --git a/backend/app/boards/sharing.py b/backend/app/boards/sharing.py index cbf1e81..f6fc621 100644 --- a/backend/app/boards/sharing.py +++ b/backend/app/boards/sharing.py @@ -2,7 +2,7 @@ import secrets import string -from datetime import datetime +from datetime import datetime, timezone from sqlalchemy.orm import Session @@ -53,12 +53,12 @@ def validate_share_link_token(token: str, db: Session) -> ShareLink | None: return None # Check expiration - if share_link.expires_at and share_link.expires_at < datetime.utcnow(): + if share_link.expires_at and share_link.expires_at < datetime.now(timezone.utc): return None # Update access tracking share_link.access_count += 1 - share_link.last_accessed_at = datetime.utcnow() + share_link.last_accessed_at = datetime.now(timezone.utc) db.commit() return share_link diff --git a/backend/app/core/deps.py b/backend/app/core/deps.py index 7e76934..04ef5ce 100644 --- a/backend/app/core/deps.py +++ b/backend/app/core/deps.py @@ -5,24 +5,50 @@ from uuid import UUID from fastapi import Depends, HTTPException, status from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import Session from app.auth.jwt import decode_access_token from app.database.models.user import User from app.database.session import get_db -# Database session dependency -DatabaseSession = Annotated[Session, Depends(get_db)] +# For backwards compatibility with synchronous code +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker +from app.core.config import settings + +# Sync engine for synchronous endpoints +_sync_engine = create_engine( + str(settings.DATABASE_URL), + pool_size=settings.DATABASE_POOL_SIZE, + max_overflow=settings.DATABASE_MAX_OVERFLOW, + pool_pre_ping=True, +) +_SyncSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=_sync_engine) + + +def get_db_sync(): + """Synchronous database session dependency.""" + db = _SyncSessionLocal() + try: + yield db + finally: + db.close() + + +# Database session dependency (async) +DatabaseSession = Annotated[AsyncSession, Depends(get_db)] # Security scheme for JWT Bearer token security = HTTPBearer() def get_current_user( - credentials: HTTPAuthorizationCredentials = Depends(security), db: Session = Depends(get_db) + credentials: HTTPAuthorizationCredentials = Depends(security), db: Session = Depends(get_db_sync) ) -> User: """ - Get current authenticated user from JWT token. + Get current authenticated user from JWT token (synchronous version). Args: credentials: HTTP Authorization Bearer token @@ -63,7 +89,7 @@ def get_current_user( headers={"WWW-Authenticate": "Bearer"}, ) from None - # Get user from database + # Get user from database (sync) user = db.query(User).filter(User.id == user_id).first() if user is None: @@ -77,3 +103,65 @@ def get_current_user( raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="User account is deactivated") return user + + +async def get_current_user_async( + credentials: HTTPAuthorizationCredentials = Depends(security), db: AsyncSession = Depends(get_db) +) -> User: + """ + Get current authenticated user from JWT token (asynchronous version). + + Args: + credentials: HTTP Authorization Bearer token + db: Async database session + + Returns: + Current authenticated user + + Raises: + HTTPException: If token is invalid or user not found + """ + # Decode token + token = credentials.credentials + payload = decode_access_token(token) + + if payload is None: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid authentication credentials", + headers={"WWW-Authenticate": "Bearer"}, + ) + + # Extract user ID from token + user_id_str: str = payload.get("sub") + if user_id_str is None: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid token payload", + headers={"WWW-Authenticate": "Bearer"}, + ) + + try: + user_id = UUID(user_id_str) + except ValueError: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid user ID in token", + headers={"WWW-Authenticate": "Bearer"}, + ) from None + + # Get user from database (async) + result = await db.execute(select(User).where(User.id == user_id)) + user = result.scalar_one_or_none() + + if user is None: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="User not found", + headers={"WWW-Authenticate": "Bearer"}, + ) + + if not user.is_active: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="User account is deactivated") + + return user diff --git a/backend/app/database/base.py b/backend/app/database/base.py index 2118370..a1a389f 100644 --- a/backend/app/database/base.py +++ b/backend/app/database/base.py @@ -1,6 +1,6 @@ """Base model for all database models.""" -from datetime import datetime +from datetime import datetime, timezone from typing import Any from uuid import uuid4 @@ -22,7 +22,7 @@ class Base(DeclarativeBase): # Common columns for all models id: Any = Column(UUID(as_uuid=True), primary_key=True, default=uuid4) - created_at: Any = Column(DateTime, default=datetime.utcnow, nullable=False) + created_at: Any = Column(DateTime, default=lambda: datetime.now(timezone.utc), nullable=False) def dict(self) -> dict[str, Any]: """Convert model to dictionary.""" diff --git a/backend/app/database/models/board.py b/backend/app/database/models/board.py index 0fac153..d393241 100644 --- a/backend/app/database/models/board.py +++ b/backend/app/database/models/board.py @@ -1,6 +1,6 @@ """Board database model.""" -from datetime import datetime +from datetime import datetime, timezone from typing import TYPE_CHECKING from uuid import UUID, uuid4 @@ -42,9 +42,9 @@ class Board(Base): default=lambda: {"x": 0, "y": 0, "zoom": 1.0, "rotation": 0}, ) - created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=datetime.utcnow) + created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=lambda: datetime.now(timezone.utc)) updated_at: Mapped[datetime] = mapped_column( - DateTime, nullable=False, default=datetime.utcnow, onupdate=datetime.utcnow + DateTime, nullable=False, default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc) ) is_deleted: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) diff --git a/backend/app/database/models/board_image.py b/backend/app/database/models/board_image.py index a996e83..70822b6 100644 --- a/backend/app/database/models/board_image.py +++ b/backend/app/database/models/board_image.py @@ -1,6 +1,6 @@ """BoardImage database model - junction table for boards and images.""" -from datetime import datetime +from datetime import datetime, timezone from typing import TYPE_CHECKING from uuid import UUID, uuid4 @@ -52,9 +52,9 @@ class BoardImage(Base): PGUUID(as_uuid=True), ForeignKey("groups.id", ondelete="SET NULL"), nullable=True ) - created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=datetime.utcnow) + created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=lambda: datetime.now(timezone.utc)) updated_at: Mapped[datetime] = mapped_column( - DateTime, nullable=False, default=datetime.utcnow, onupdate=datetime.utcnow + DateTime, nullable=False, default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc) ) # Relationships diff --git a/backend/app/database/models/comment.py b/backend/app/database/models/comment.py index e1b145e..715de5c 100644 --- a/backend/app/database/models/comment.py +++ b/backend/app/database/models/comment.py @@ -1,7 +1,7 @@ """Comment model for board annotations.""" import uuid -from datetime import datetime +from datetime import datetime, timezone from sqlalchemy import Boolean, Column, DateTime, ForeignKey, String, Text from sqlalchemy.dialects.postgresql import JSONB, UUID @@ -21,7 +21,7 @@ class Comment(Base): author_name = Column(String(100), nullable=False) content = Column(Text, nullable=False) position = Column(JSONB, nullable=True) # Optional canvas position reference - created_at = Column(DateTime, nullable=False, default=datetime.utcnow) + created_at = Column(DateTime, nullable=False, default=lambda: datetime.now(timezone.utc)) is_deleted = Column(Boolean, nullable=False, default=False) # Relationships diff --git a/backend/app/database/models/group.py b/backend/app/database/models/group.py index fced044..034fd7d 100644 --- a/backend/app/database/models/group.py +++ b/backend/app/database/models/group.py @@ -1,6 +1,6 @@ """Group database model.""" -from datetime import datetime +from datetime import datetime, timezone from typing import TYPE_CHECKING from uuid import UUID, uuid4 @@ -33,9 +33,9 @@ class Group(Base): color: Mapped[str] = mapped_column(String(7), nullable=False) # Hex color #RRGGBB annotation: Mapped[str | None] = mapped_column(Text, nullable=True) - created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=datetime.utcnow) + created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=lambda: datetime.now(timezone.utc)) updated_at: Mapped[datetime] = mapped_column( - DateTime, nullable=False, default=datetime.utcnow, onupdate=datetime.utcnow + DateTime, nullable=False, default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc) ) # Relationships diff --git a/backend/app/database/models/image.py b/backend/app/database/models/image.py index 5bfa442..7e9d5f9 100644 --- a/backend/app/database/models/image.py +++ b/backend/app/database/models/image.py @@ -1,6 +1,6 @@ """Image database model.""" -from datetime import datetime +from datetime import datetime, timezone from typing import TYPE_CHECKING from uuid import UUID, uuid4 @@ -38,7 +38,7 @@ class Image(Base): height: Mapped[int] = mapped_column(Integer, nullable=False) image_metadata: Mapped[dict] = mapped_column(JSONB, nullable=False) - created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=datetime.utcnow) + created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=lambda: datetime.now(timezone.utc)) reference_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0) # Relationships diff --git a/backend/app/database/models/share_link.py b/backend/app/database/models/share_link.py index 34ada78..5dd0a45 100644 --- a/backend/app/database/models/share_link.py +++ b/backend/app/database/models/share_link.py @@ -1,7 +1,7 @@ """ShareLink model for board sharing functionality.""" import uuid -from datetime import datetime +from datetime import datetime, timezone from sqlalchemy import Boolean, Column, DateTime, ForeignKey, Integer, String from sqlalchemy.dialects.postgresql import UUID @@ -19,7 +19,7 @@ class ShareLink(Base): board_id = Column(UUID(as_uuid=True), ForeignKey("boards.id", ondelete="CASCADE"), nullable=False) token = Column(String(64), unique=True, nullable=False, index=True) permission_level = Column(String(20), nullable=False) # 'view-only' or 'view-comment' - created_at = Column(DateTime, nullable=False, default=datetime.utcnow) + created_at = Column(DateTime, nullable=False, default=lambda: datetime.now(timezone.utc)) expires_at = Column(DateTime, nullable=True) last_accessed_at = Column(DateTime, nullable=True) access_count = Column(Integer, nullable=False, default=0) diff --git a/backend/app/database/models/user.py b/backend/app/database/models/user.py index ebfec48..c76efed 100644 --- a/backend/app/database/models/user.py +++ b/backend/app/database/models/user.py @@ -1,7 +1,7 @@ """User model for authentication and ownership.""" import uuid -from datetime import datetime +from datetime import datetime, timezone from sqlalchemy import Boolean, Column, DateTime, String from sqlalchemy.dialects.postgresql import UUID @@ -18,8 +18,8 @@ class User(Base): id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) email = Column(String(255), unique=True, nullable=False, index=True) password_hash = Column(String(255), nullable=False) - created_at = Column(DateTime, nullable=False, default=datetime.utcnow) - updated_at = Column(DateTime, nullable=False, default=datetime.utcnow, onupdate=datetime.utcnow) + created_at = Column(DateTime, nullable=False, default=lambda: datetime.now(timezone.utc)) + updated_at = Column(DateTime, nullable=False, default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc)) is_active = Column(Boolean, nullable=False, default=True) # Relationships diff --git a/backend/app/database/session.py b/backend/app/database/session.py index cb299d6..85bb8b6 100644 --- a/backend/app/database/session.py +++ b/backend/app/database/session.py @@ -1,27 +1,33 @@ """Database session management.""" -from sqlalchemy import create_engine +from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine from sqlalchemy.orm import sessionmaker from app.core.config import settings -# Create SQLAlchemy engine -engine = create_engine( - str(settings.DATABASE_URL), +# Convert sync DATABASE_URL to async (replace postgresql:// with postgresql+asyncpg://) +async_database_url = str(settings.DATABASE_URL).replace("postgresql://", "postgresql+asyncpg://") + +# Create async SQLAlchemy engine +engine = create_async_engine( + async_database_url, pool_size=settings.DATABASE_POOL_SIZE, max_overflow=settings.DATABASE_MAX_OVERFLOW, pool_pre_ping=True, # Verify connections before using echo=settings.DEBUG, # Log SQL queries in debug mode ) -# Create session factory -SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) +# Create async session factory +SessionLocal = sessionmaker( + bind=engine, + class_=AsyncSession, + autocommit=False, + autoflush=False, + expire_on_commit=False, +) -def get_db(): - """Dependency for getting database session.""" - db = SessionLocal() - try: - yield db - finally: - db.close() +async def get_db(): + """Dependency for getting async database session.""" + async with SessionLocal() as session: + yield session diff --git a/backend/app/images/repository.py b/backend/app/images/repository.py index 14b321e..f7c5b40 100644 --- a/backend/app/images/repository.py +++ b/backend/app/images/repository.py @@ -3,7 +3,8 @@ from collections.abc import Sequence from uuid import UUID -from sqlalchemy.orm import Session +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession from app.database.models.board_image import BoardImage from app.database.models.image import Image @@ -12,11 +13,11 @@ from app.database.models.image import Image class ImageRepository: """Repository for image database operations.""" - def __init__(self, db: Session): + def __init__(self, db: AsyncSession): """Initialize repository with database session.""" self.db = db - def create_image( + async def create_image( self, user_id: UUID, filename: str, @@ -25,7 +26,7 @@ class ImageRepository: mime_type: str, width: int, height: int, - metadata: dict, + image_metadata: dict, ) -> Image: """Create new image record.""" image = Image( @@ -36,59 +37,68 @@ class ImageRepository: mime_type=mime_type, width=width, height=height, - image_metadata=metadata, + image_metadata=image_metadata, ) self.db.add(image) - self.db.commit() - self.db.refresh(image) + await self.db.commit() + await self.db.refresh(image) return image - def get_image_by_id(self, image_id: UUID) -> Image | None: + async def get_image_by_id(self, image_id: UUID) -> Image | None: """Get image by ID.""" - return self.db.query(Image).filter(Image.id == image_id).first() + result = await self.db.execute(select(Image).where(Image.id == image_id)) + return result.scalar_one_or_none() - def get_user_images( + async def get_user_images( self, user_id: UUID, limit: int = 50, offset: int = 0 ) -> tuple[Sequence[Image], int]: """Get all images for a user with pagination.""" - total = self.db.query(Image).filter(Image.user_id == user_id).count() - images = ( - self.db.query(Image) - .filter(Image.user_id == user_id) + from sqlalchemy import func + + # Get total count efficiently + count_result = await self.db.execute( + select(func.count(Image.id)).where(Image.user_id == user_id) + ) + total = count_result.scalar_one() + + # Get paginated images + result = await self.db.execute( + select(Image) + .where(Image.user_id == user_id) .order_by(Image.created_at.desc()) .limit(limit) .offset(offset) - .all() ) + images = result.scalars().all() return images, total - def delete_image(self, image_id: UUID) -> bool: + async def delete_image(self, image_id: UUID) -> bool: """Delete image record.""" - image = self.get_image_by_id(image_id) + image = await self.get_image_by_id(image_id) if not image: return False - self.db.delete(image) - self.db.commit() + await self.db.delete(image) + await self.db.commit() return True - def increment_reference_count(self, image_id: UUID) -> None: + async def increment_reference_count(self, image_id: UUID) -> None: """Increment reference count for image.""" - image = self.get_image_by_id(image_id) + image = await self.get_image_by_id(image_id) if image: image.reference_count += 1 - self.db.commit() + await self.db.commit() - def decrement_reference_count(self, image_id: UUID) -> int: + async def decrement_reference_count(self, image_id: UUID) -> int: """Decrement reference count for image.""" - image = self.get_image_by_id(image_id) + image = await self.get_image_by_id(image_id) if image and image.reference_count > 0: image.reference_count -= 1 - self.db.commit() + await self.db.commit() return image.reference_count return 0 - def add_image_to_board( + async def add_image_to_board( self, board_id: UUID, image_id: UUID, @@ -107,36 +117,68 @@ class ImageRepository: self.db.add(board_image) # Increment reference count - self.increment_reference_count(image_id) + await self.increment_reference_count(image_id) - self.db.commit() - self.db.refresh(board_image) + await self.db.commit() + await self.db.refresh(board_image) return board_image - def get_board_images(self, board_id: UUID) -> Sequence[BoardImage]: + async def get_board_images(self, board_id: UUID) -> Sequence[BoardImage]: """Get all images for a board, ordered by z-order.""" - return ( - self.db.query(BoardImage) - .filter(BoardImage.board_id == board_id) + result = await self.db.execute( + select(BoardImage) + .where(BoardImage.board_id == board_id) .order_by(BoardImage.z_order.asc()) - .all() ) + return result.scalars().all() - def remove_image_from_board(self, board_id: UUID, image_id: UUID) -> bool: - """Remove image from board.""" - board_image = ( - self.db.query(BoardImage) - .filter(BoardImage.board_id == board_id, BoardImage.image_id == image_id) - .first() + async def get_board_image(self, board_id: UUID, image_id: UUID) -> BoardImage | None: + """Get a specific board image.""" + result = await self.db.execute( + select(BoardImage) + .where(BoardImage.board_id == board_id, BoardImage.image_id == image_id) ) + return result.scalar_one_or_none() + + async def update_board_image( + self, + board_id: UUID, + image_id: UUID, + position: dict | None = None, + transformations: dict | None = None, + z_order: int | None = None, + group_id: UUID | None = None, + ) -> BoardImage | None: + """Update board image position, transformations, z-order, or group.""" + board_image = await self.get_board_image(board_id, image_id) + + if not board_image: + return None + + if position is not None: + board_image.position = position + if transformations is not None: + board_image.transformations = transformations + if z_order is not None: + board_image.z_order = z_order + if group_id is not None: + board_image.group_id = group_id + + await self.db.commit() + await self.db.refresh(board_image) + return board_image + + async def remove_image_from_board(self, board_id: UUID, image_id: UUID) -> bool: + """Remove image from board.""" + board_image = await self.get_board_image(board_id, image_id) if not board_image: return False - self.db.delete(board_image) + await self.db.delete(board_image) # Decrement reference count - self.decrement_reference_count(image_id) + await self.decrement_reference_count(image_id) - self.db.commit() + await self.db.commit() return True diff --git a/backend/app/images/schemas.py b/backend/app/images/schemas.py index dfa12c3..bb91e35 100644 --- a/backend/app/images/schemas.py +++ b/backend/app/images/schemas.py @@ -26,13 +26,14 @@ class ImageUploadResponse(BaseModel): mime_type: str width: int height: int - metadata: dict[str, Any] + metadata: dict[str, Any] = Field(..., alias="image_metadata") created_at: datetime class Config: """Pydantic config.""" from_attributes = True + populate_by_name = True class ImageResponse(BaseModel): @@ -46,7 +47,7 @@ class ImageResponse(BaseModel): mime_type: str width: int height: int - metadata: dict[str, Any] + metadata: dict[str, Any] = Field(..., alias="image_metadata") created_at: datetime reference_count: int @@ -54,6 +55,7 @@ class ImageResponse(BaseModel): """Pydantic config.""" from_attributes = True + populate_by_name = True class BoardImageCreate(BaseModel): diff --git a/flake.nix b/flake.nix index 10809a2..4b1c4a2 100644 --- a/flake.nix +++ b/flake.nix @@ -31,7 +31,8 @@ alembic pydantic pydantic-settings # Settings management - psycopg2 # PostgreSQL driver + psycopg2 # PostgreSQL driver (sync) + asyncpg # PostgreSQL driver (async) # Auth & Security python-jose passlib @@ -88,6 +89,7 @@ # Development tools git direnv + tmux ]; shellHook = '' @@ -105,6 +107,7 @@ echo " Status: ./scripts/dev-services.sh status" echo "" echo "šŸ“š Quick Commands:" + echo " Dev (tmux): nix run .#dev" echo " Backend: cd backend && uvicorn app.main:app --reload" echo " Frontend: cd frontend && npm run dev" echo " Database: psql -h localhost -U webref webref" @@ -131,6 +134,7 @@ type = "app"; program = "${pkgs.writeShellScript "help" '' echo "Available commands:" + echo " nix run .#dev - Start backend + frontend in tmux" echo " nix run .#lint - Run all linting checks" echo " nix run .#lint-backend - Run backend linting only" echo " nix run .#lint-frontend - Run frontend linting only" @@ -138,6 +142,76 @@ ''}"; }; + # Development runner with tmux + dev = { + type = "app"; + program = "${pkgs.writeShellScript "dev-tmux" '' + set -e + + # Check if we're in the project root + if [ ! -d "backend" ] || [ ! -d "frontend" ]; then + echo "āŒ Error: Not in project root directory" + echo "Please run this command from the webref project root" + exit 1 + fi + + # Check if frontend dependencies are installed + if [ ! -d "frontend/node_modules" ]; then + echo "šŸ“¦ Installing frontend dependencies..." + cd frontend + ${pkgs.nodejs}/bin/npm install + cd .. + fi + + # Set environment variables + export DATABASE_URL="postgresql://webref@localhost:5432/webref" + export MINIO_ENDPOINT="localhost:9000" + export MINIO_ACCESS_KEY="minioadmin" + export MINIO_SECRET_KEY="minioadmin" + export PYTHONPATH="$PWD/backend:$PYTHONPATH" + export PATH="${pythonEnv}/bin:${pkgs.nodejs}/bin:$PATH" + + # Session name + SESSION_NAME="webref-dev" + + # Kill existing session if it exists + ${pkgs.tmux}/bin/tmux has-session -t $SESSION_NAME 2>/dev/null && ${pkgs.tmux}/bin/tmux kill-session -t $SESSION_NAME + + echo "šŸš€ Starting development environment in tmux..." + echo "" + echo "šŸ“‹ Tmux Controls:" + echo " Switch panes: Ctrl+b → arrow keys" + echo " Scroll mode: Ctrl+b → [" + echo " Exit scroll: q" + echo " Detach session: Ctrl+b → d" + echo " Kill session: Ctrl+b → :kill-session" + echo "" + echo "Starting in 2 seconds..." + sleep 2 + + # Create new tmux session with backend + ${pkgs.tmux}/bin/tmux new-session -d -s "$SESSION_NAME" -n "webref" -c "$PWD/backend" \ + "printf '\nšŸ Starting Backend (uvicorn)...\n\n' && ${pythonEnv}/bin/uvicorn app.main:app --reload --host 0.0.0.0 --port 8000; read -p 'Backend stopped. Press Enter to exit...'" + + # Split window vertically and run frontend + ${pkgs.tmux}/bin/tmux split-window -h -t "$SESSION_NAME":0 -c "$PWD/frontend" \ + "printf '\n⚔ Starting Frontend (Vite)...\n\n' && ${pkgs.nodejs}/bin/npm run dev; read -p 'Frontend stopped. Press Enter to exit...'" + + # Set pane titles + ${pkgs.tmux}/bin/tmux select-pane -t "$SESSION_NAME":0.0 -T "Backend (uvicorn)" + ${pkgs.tmux}/bin/tmux select-pane -t "$SESSION_NAME":0.1 -T "Frontend (vite)" + + # Balance panes + ${pkgs.tmux}/bin/tmux select-layout -t "$SESSION_NAME":0 even-horizontal + + # Focus on backend pane + ${pkgs.tmux}/bin/tmux select-pane -t "$SESSION_NAME":0.0 + + # Attach to session + ${pkgs.tmux}/bin/tmux attach-session -t "$SESSION_NAME" + ''}"; + }; + # Unified linting - calls both backend and frontend lints lint = { type = "app"; From ff1c29c66a95763b6908ea9c22a4c9b7367e9ab0 Mon Sep 17 00:00:00 2001 From: Danilo Reyes Date: Sun, 2 Nov 2025 18:31:46 -0600 Subject: [PATCH 3/4] fix part 3 --- .../92khy67bgrzx85f6052pnw7xrs2jk1v6-source | 1 + .../lhn3s31zbiq1syclv0rk94bn5g74750c-source | 1 + .../xjjq52iwslhz6lbc621a31v0nfdhr5ks-source | 1 + .../zzxxnkdqc6rdycxkylwrs2pg8ahj3cny-source | 1 + ...e-a5d5b61aa8a61b7d9d765e1daf971a9a578f1cfa | 1 + ...5d5b61aa8a61b7d9d765e1daf971a9a578f1cfa.rc | 2163 +++++++++++++++++ .env.example | 1 + .gitignore | 2 +- backend/app/api/boards.py | 14 +- backend/app/api/sharing.py | 6 +- backend/app/auth/jwt.py | 8 +- backend/app/boards/sharing.py | 6 +- backend/app/core/deps.py | 16 +- backend/app/database/base.py | 5 +- backend/app/database/models/board.py | 8 +- backend/app/database/models/board_image.py | 8 +- backend/app/database/models/comment.py | 5 +- backend/app/database/models/group.py | 8 +- backend/app/database/models/image.py | 6 +- backend/app/database/models/share_link.py | 5 +- backend/app/database/models/user.py | 7 +- backend/app/images/repository.py | 27 +- 22 files changed, 2226 insertions(+), 74 deletions(-) create mode 120000 .direnv/flake-inputs/92khy67bgrzx85f6052pnw7xrs2jk1v6-source create mode 120000 .direnv/flake-inputs/lhn3s31zbiq1syclv0rk94bn5g74750c-source create mode 120000 .direnv/flake-inputs/xjjq52iwslhz6lbc621a31v0nfdhr5ks-source create mode 120000 .direnv/flake-inputs/zzxxnkdqc6rdycxkylwrs2pg8ahj3cny-source create mode 120000 .direnv/flake-profile-a5d5b61aa8a61b7d9d765e1daf971a9a578f1cfa create mode 100644 .direnv/flake-profile-a5d5b61aa8a61b7d9d765e1daf971a9a578f1cfa.rc create mode 100644 .env.example diff --git a/.direnv/flake-inputs/92khy67bgrzx85f6052pnw7xrs2jk1v6-source b/.direnv/flake-inputs/92khy67bgrzx85f6052pnw7xrs2jk1v6-source new file mode 120000 index 0000000..5457450 --- /dev/null +++ b/.direnv/flake-inputs/92khy67bgrzx85f6052pnw7xrs2jk1v6-source @@ -0,0 +1 @@ +/nix/store/92khy67bgrzx85f6052pnw7xrs2jk1v6-source \ No newline at end of file diff --git a/.direnv/flake-inputs/lhn3s31zbiq1syclv0rk94bn5g74750c-source b/.direnv/flake-inputs/lhn3s31zbiq1syclv0rk94bn5g74750c-source new file mode 120000 index 0000000..e67c258 --- /dev/null +++ b/.direnv/flake-inputs/lhn3s31zbiq1syclv0rk94bn5g74750c-source @@ -0,0 +1 @@ +/nix/store/lhn3s31zbiq1syclv0rk94bn5g74750c-source \ No newline at end of file diff --git a/.direnv/flake-inputs/xjjq52iwslhz6lbc621a31v0nfdhr5ks-source b/.direnv/flake-inputs/xjjq52iwslhz6lbc621a31v0nfdhr5ks-source new file mode 120000 index 0000000..7844a4e --- /dev/null +++ b/.direnv/flake-inputs/xjjq52iwslhz6lbc621a31v0nfdhr5ks-source @@ -0,0 +1 @@ +/nix/store/xjjq52iwslhz6lbc621a31v0nfdhr5ks-source \ No newline at end of file diff --git a/.direnv/flake-inputs/zzxxnkdqc6rdycxkylwrs2pg8ahj3cny-source b/.direnv/flake-inputs/zzxxnkdqc6rdycxkylwrs2pg8ahj3cny-source new file mode 120000 index 0000000..99f4863 --- /dev/null +++ b/.direnv/flake-inputs/zzxxnkdqc6rdycxkylwrs2pg8ahj3cny-source @@ -0,0 +1 @@ +/nix/store/zzxxnkdqc6rdycxkylwrs2pg8ahj3cny-source \ No newline at end of file diff --git a/.direnv/flake-profile-a5d5b61aa8a61b7d9d765e1daf971a9a578f1cfa b/.direnv/flake-profile-a5d5b61aa8a61b7d9d765e1daf971a9a578f1cfa new file mode 120000 index 0000000..f628d82 --- /dev/null +++ b/.direnv/flake-profile-a5d5b61aa8a61b7d9d765e1daf971a9a578f1cfa @@ -0,0 +1 @@ +/nix/store/xxizbrvv0ysnp79c429sgsa7g5vwqbr3-nix-shell-env \ No newline at end of file diff --git a/.direnv/flake-profile-a5d5b61aa8a61b7d9d765e1daf971a9a578f1cfa.rc b/.direnv/flake-profile-a5d5b61aa8a61b7d9d765e1daf971a9a578f1cfa.rc new file mode 100644 index 0000000..915f7b0 --- /dev/null +++ b/.direnv/flake-profile-a5d5b61aa8a61b7d9d765e1daf971a9a578f1cfa.rc @@ -0,0 +1,2163 @@ +unset shellHook +PATH=${PATH:-} +nix_saved_PATH="$PATH" +XDG_DATA_DIRS=${XDG_DATA_DIRS:-} +nix_saved_XDG_DATA_DIRS="$XDG_DATA_DIRS" +AR='ar' +export AR +AS='as' +export AS +BASH='/nix/store/ciarnmsx8lvsrmdbjddpmx0pqjrm8imb-bash-5.3p3/bin/bash' +CC='gcc' +export CC +CONFIG_SHELL='/nix/store/ciarnmsx8lvsrmdbjddpmx0pqjrm8imb-bash-5.3p3/bin/bash' +export CONFIG_SHELL +CXX='g++' +export CXX +HOSTTYPE='x86_64' +HOST_PATH='/nix/store/lw0pnrlif2m177izcpbq67bdd824wp3p-python3-3.13.8-env/bin:/nix/store/n6chrdybb91npp8gvf8mjk55smx4sn8s-uv-0.8.23/bin:/nix/store/g6vnwhcxc1c0ry2igh4sca5gg7ss3cja-ruff-0.14.2/bin:/nix/store/40wlxs3zyvkl7psva31hx4qvq8qk1wq9-postgresql-17.6-dev/bin:/nix/store/4n3wdlj53mw2z9j32zg7v1d5hbn5b36x-postgresql-17.6/bin:/nix/store/x62pydw9kpd88m4986kyc02q8x3ipp3j-nodejs-22.20.0-dev/bin:/nix/store/nvf9kaarb9kqqdbygl9cbzhli1y8yjik-nodejs-22.20.0/bin:/nix/store/v3czg9qdaxnc7mymgrapd6w94kslpdhg-nodejs-22.20.0-dev/bin:/nix/store/zjrmvhbdnc33dvfshsixmxgjfi6848sz-nodejs-22.20.0/bin:/nix/store/y0cxgk9wrx91gsjmdx46khvl5ghjhajb-eslint-9.35.0/bin:/nix/store/vifx2z8zr1lrrrydm6cvffwmk35win8h-imagemagick-7.1.2-8-dev/bin:/nix/store/b65dj8iryqd1bss2qlg5ipxqndngcl9n-curl-8.16.0-dev/bin:/nix/store/7c3v4dg0ym1vdlgbr8qhcrcagqjkads6-brotli-1.1.0/bin:/nix/store/jskkvr36pxdasygya2v681fx2dkpnc3w-krb5-1.22.1-dev/bin:/nix/store/q1fkhzk163l40y30x3kqr34wryyc1a00-krb5-1.22.1/bin:/nix/store/d5bh7q649gi7srsgcq4civgajh6a0zks-nghttp2-1.67.1/bin:/nix/store/q9d5w2mc89b40hm4vnrh9m0c3d0lwbzw-libidn2-2.3.8-bin/bin:/nix/store/wlh6plymkd971amrm339p4jg9mk48w4q-openssl-3.6.0-bin/bin:/nix/store/30s8lvymqlfxvcdcm35nd0qrk6hp2bm9-libpsl-0.21.5/bin:/nix/store/bws65kn5ji8vpi51dsxhvn22kxgm5kmk-zstd-1.5.7-bin/bin:/nix/store/mmfrs9gzbhrfgv12ff68a056yv942s21-zstd-1.5.7/bin:/nix/store/xvh4bi0cc2cw18lpn6ax9m4zwnn1s9lj-curl-8.16.0-bin/bin:/nix/store/vzj5iy2png2rs6q2kb3svzp0cnd878f1-bzip2-1.0.8-bin/bin:/nix/store/104x1y8lq59bnmh71vcvym57grcdzc9b-freetype-2.13.3-dev/bin:/nix/store/1hkdki45cpkpkmszjm3mafac1igj6qyf-libpng-apng-1.6.50-dev/bin:/nix/store/hdy8y6irf4icxc9ijyls9zgz0xw1q70j-libjpeg-turbo-3.1.2-bin/bin:/nix/store/6g2qf8r8a5czg053gly63rkak3q2jx4a-lcms2-2.17-bin/bin:/nix/store/0vdhbsw15wcllh9q0jvhyzgn8asmavfi-libwebp-1.6.0/bin:/nix/store/108ma555wa88kyc933lpsmhnaymiygax-fftw-double-3.3.10-dev/bin:/nix/store/fha5q12bpdy6n5w11f15i1pjl6qpwc09-imagemagick-7.1.2-8/bin:/nix/store/dja5qb5q8nlyfc6wl49xmm6sg9hq052z-file-5.45/bin:/nix/store/glyid6d4mnv83j8gzj1r969y2220gwda-minio-2025-09-07T16-13-09Z/bin:/nix/store/q3r9y1jhcr2pxqhs3z0lca2bczrs2y3f-minio-client-2025-08-13T08-35-41Z/bin:/nix/store/60440kg5c51rrlpinkk2rkg020q5q6kv-git-2.51.0/bin:/nix/store/3by7b7afc50p6v1khzj7wyml1rdmagff-direnv-2.37.1/bin:/nix/store/8vwll24rhnizhrpa79lksk8jd8chzsn9-tmux-3.5a/bin:/nix/store/xs8scz9w9jp4hpqycx3n3bah5y07ymgj-coreutils-9.8/bin:/nix/store/qqvfnxa9jg71wp4hfg1l63r4m78iwvl9-findutils-4.10.0/bin:/nix/store/7ql4x9i7w7ihxw23vkanvcvrvqhay23c-diffutils-3.12/bin:/nix/store/zppkx0lkizglyqa9h26wf495qkllrjgy-gnused-4.9/bin:/nix/store/22r4s6lqhl43jkazn51f3c18qwk894g4-gnugrep-3.12/bin:/nix/store/8c4l9cqqj7pixqlmljx5d495pfpw8pys-gawk-5.3.2/bin:/nix/store/3m0zcl1by8ifylmgdcaa317cnhqn8q95-gnutar-1.35/bin:/nix/store/vjj9x3dzszbzjpkzwx63z4gpypiqphzf-gzip-1.14/bin:/nix/store/vzj5iy2png2rs6q2kb3svzp0cnd878f1-bzip2-1.0.8-bin/bin:/nix/store/43lv2nr7pj7wy09qyicjq57bl209ccis-gnumake-4.4.1/bin:/nix/store/ciarnmsx8lvsrmdbjddpmx0pqjrm8imb-bash-5.3p3/bin:/nix/store/miap8jjp87z0gibkrzh68lw2w8kzy4yx-patch-2.8/bin:/nix/store/vwx988887gsrdvg9lbg5f03sy91wrjjv-xz-5.8.1-bin/bin:/nix/store/n686xm9c2ak4x02pj9izvkm0zx7ilwir-file-5.45/bin' +export HOST_PATH +IFS=' +' +IN_NIX_SHELL='impure' +export IN_NIX_SHELL +LD='ld' +export LD +LINENO='76' +MACHTYPE='x86_64-pc-linux-gnu' +NIX_BINTOOLS='/nix/store/918ldr9axgh5kdmpp5fnj2n37pyghwbx-binutils-wrapper-2.44' +export NIX_BINTOOLS +NIX_BINTOOLS_WRAPPER_TARGET_HOST_x86_64_unknown_linux_gnu='1' +export NIX_BINTOOLS_WRAPPER_TARGET_HOST_x86_64_unknown_linux_gnu +NIX_BUILD_CORES='8' +export NIX_BUILD_CORES +NIX_CC='/nix/store/x8mydcgbry214s802nzvy7fdljx404ym-gcc-wrapper-14.3.0' +export NIX_CC +NIX_CC_WRAPPER_TARGET_HOST_x86_64_unknown_linux_gnu='1' +export NIX_CC_WRAPPER_TARGET_HOST_x86_64_unknown_linux_gnu +NIX_CFLAGS_COMPILE=' -frandom-seed=xxizbrvv0y -isystem /nix/store/lw0pnrlif2m177izcpbq67bdd824wp3p-python3-3.13.8-env/include -isystem /nix/store/40wlxs3zyvkl7psva31hx4qvq8qk1wq9-postgresql-17.6-dev/include -isystem /nix/store/x62pydw9kpd88m4986kyc02q8x3ipp3j-nodejs-22.20.0-dev/include -isystem /nix/store/nvf9kaarb9kqqdbygl9cbzhli1y8yjik-nodejs-22.20.0/include -isystem /nix/store/v3czg9qdaxnc7mymgrapd6w94kslpdhg-nodejs-22.20.0-dev/include -isystem /nix/store/zjrmvhbdnc33dvfshsixmxgjfi6848sz-nodejs-22.20.0/include -isystem /nix/store/vifx2z8zr1lrrrydm6cvffwmk35win8h-imagemagick-7.1.2-8-dev/include -isystem /nix/store/b65dj8iryqd1bss2qlg5ipxqndngcl9n-curl-8.16.0-dev/include -isystem /nix/store/r5dnrkpnds4xi52cdwakbjvvx049cj3x-brotli-1.1.0-dev/include -isystem /nix/store/jskkvr36pxdasygya2v681fx2dkpnc3w-krb5-1.22.1-dev/include -isystem /nix/store/710f10kd7vppa8wd3lxrvj51gz7v1nk1-nghttp2-1.67.1-dev/include -isystem /nix/store/qviyg73dvs1cf4f0ggafprwvwi192bs9-nghttp3-1.12.0-dev/include -isystem /nix/store/nff0nifv0g3pg4djn86rfniz1sp022r7-ngtcp2-1.15.1-dev/include -isystem /nix/store/6h9ffn5nwcilirkgva4hb99rnhmjxnqf-libidn2-2.3.8-dev/include -isystem /nix/store/4vm27ldxmphlgyj3vf1h7dlgvj5hvj0w-openssl-3.6.0-dev/include -isystem /nix/store/xshi6pdd825v5pzg3h94sxdxa8ap3liq-libpsl-0.21.5-dev/include -isystem /nix/store/3171192n3540d42bc380cv00vzn6hyhs-libssh2-1.11.1-dev/include -isystem /nix/store/hwqbid7b85dfdvyj0ckgi2c6a4ir653q-zlib-1.3.1-dev/include -isystem /nix/store/v12k3k3nbhhqmwcvzbsljr135wbrwnsr-zstd-1.5.7-dev/include -isystem /nix/store/i20l7xn2a2yxdalwc2c8yp29dia5v2j7-bzip2-1.0.8-dev/include -isystem /nix/store/104x1y8lq59bnmh71vcvym57grcdzc9b-freetype-2.13.3-dev/include -isystem /nix/store/1hkdki45cpkpkmszjm3mafac1igj6qyf-libpng-apng-1.6.50-dev/include -isystem /nix/store/mfnqd0l258v2vdyxynbw2dq129z7sad3-libjpeg-turbo-3.1.2-dev/include -isystem /nix/store/6vxqzmlq8acapwzssss59lxnzs0mkdz5-lcms2-2.17-dev/include -isystem /nix/store/20q72vi8wdb7j6p9sdnpi32477j6nv2z-libx11-1.8.12-dev/include -isystem /nix/store/nml7jra2yzxg133ssim80aj39f4668gc-xorgproto-2024.1/include -isystem /nix/store/vxvpdfl4k0gqh3nhmx8w9pz04c60p1ls-libxt-1.3.1-dev/include -isystem /nix/store/vb5a0cp9npq3935fblc25abd5dw4zijv-libsm-1.2.6-dev/include -isystem /nix/store/zqma24cl2d96xbgqd97jkphnc9x1w5la-libice-1.1.2-dev/include -isystem /nix/store/0vdhbsw15wcllh9q0jvhyzgn8asmavfi-libwebp-1.6.0/include -isystem /nix/store/108ma555wa88kyc933lpsmhnaymiygax-fftw-double-3.3.10-dev/include -isystem /nix/store/nd3xq6vzsdwdlxjv4bb8w6dcmqs319qm-file-5.45-dev/include -isystem /nix/store/lw0pnrlif2m177izcpbq67bdd824wp3p-python3-3.13.8-env/include -isystem /nix/store/40wlxs3zyvkl7psva31hx4qvq8qk1wq9-postgresql-17.6-dev/include -isystem /nix/store/x62pydw9kpd88m4986kyc02q8x3ipp3j-nodejs-22.20.0-dev/include -isystem /nix/store/nvf9kaarb9kqqdbygl9cbzhli1y8yjik-nodejs-22.20.0/include -isystem /nix/store/v3czg9qdaxnc7mymgrapd6w94kslpdhg-nodejs-22.20.0-dev/include -isystem /nix/store/zjrmvhbdnc33dvfshsixmxgjfi6848sz-nodejs-22.20.0/include -isystem /nix/store/vifx2z8zr1lrrrydm6cvffwmk35win8h-imagemagick-7.1.2-8-dev/include -isystem /nix/store/b65dj8iryqd1bss2qlg5ipxqndngcl9n-curl-8.16.0-dev/include -isystem /nix/store/r5dnrkpnds4xi52cdwakbjvvx049cj3x-brotli-1.1.0-dev/include -isystem /nix/store/jskkvr36pxdasygya2v681fx2dkpnc3w-krb5-1.22.1-dev/include -isystem /nix/store/710f10kd7vppa8wd3lxrvj51gz7v1nk1-nghttp2-1.67.1-dev/include -isystem /nix/store/qviyg73dvs1cf4f0ggafprwvwi192bs9-nghttp3-1.12.0-dev/include -isystem /nix/store/nff0nifv0g3pg4djn86rfniz1sp022r7-ngtcp2-1.15.1-dev/include -isystem /nix/store/6h9ffn5nwcilirkgva4hb99rnhmjxnqf-libidn2-2.3.8-dev/include -isystem /nix/store/4vm27ldxmphlgyj3vf1h7dlgvj5hvj0w-openssl-3.6.0-dev/include -isystem /nix/store/xshi6pdd825v5pzg3h94sxdxa8ap3liq-libpsl-0.21.5-dev/include -isystem /nix/store/3171192n3540d42bc380cv00vzn6hyhs-libssh2-1.11.1-dev/include -isystem /nix/store/hwqbid7b85dfdvyj0ckgi2c6a4ir653q-zlib-1.3.1-dev/include -isystem /nix/store/v12k3k3nbhhqmwcvzbsljr135wbrwnsr-zstd-1.5.7-dev/include -isystem /nix/store/i20l7xn2a2yxdalwc2c8yp29dia5v2j7-bzip2-1.0.8-dev/include -isystem /nix/store/104x1y8lq59bnmh71vcvym57grcdzc9b-freetype-2.13.3-dev/include -isystem /nix/store/1hkdki45cpkpkmszjm3mafac1igj6qyf-libpng-apng-1.6.50-dev/include -isystem /nix/store/mfnqd0l258v2vdyxynbw2dq129z7sad3-libjpeg-turbo-3.1.2-dev/include -isystem /nix/store/6vxqzmlq8acapwzssss59lxnzs0mkdz5-lcms2-2.17-dev/include -isystem /nix/store/20q72vi8wdb7j6p9sdnpi32477j6nv2z-libx11-1.8.12-dev/include -isystem /nix/store/nml7jra2yzxg133ssim80aj39f4668gc-xorgproto-2024.1/include -isystem /nix/store/vxvpdfl4k0gqh3nhmx8w9pz04c60p1ls-libxt-1.3.1-dev/include -isystem /nix/store/vb5a0cp9npq3935fblc25abd5dw4zijv-libsm-1.2.6-dev/include -isystem /nix/store/zqma24cl2d96xbgqd97jkphnc9x1w5la-libice-1.1.2-dev/include -isystem /nix/store/0vdhbsw15wcllh9q0jvhyzgn8asmavfi-libwebp-1.6.0/include -isystem /nix/store/108ma555wa88kyc933lpsmhnaymiygax-fftw-double-3.3.10-dev/include -isystem /nix/store/nd3xq6vzsdwdlxjv4bb8w6dcmqs319qm-file-5.45-dev/include' +export NIX_CFLAGS_COMPILE +NIX_ENFORCE_NO_NATIVE='1' +export NIX_ENFORCE_NO_NATIVE +NIX_HARDENING_ENABLE='bindnow format fortify fortify3 pic relro stackclashprotection stackprotector strictoverflow zerocallusedregs' +export NIX_HARDENING_ENABLE +NIX_LDFLAGS='-rpath /home/jawz/Development/Projects/personal/webref/outputs/out/lib -L/nix/store/lw0pnrlif2m177izcpbq67bdd824wp3p-python3-3.13.8-env/lib -L/nix/store/40wlxs3zyvkl7psva31hx4qvq8qk1wq9-postgresql-17.6-dev/lib -L/nix/store/09g5yr15i53l6yfif3p7q86shchwk1wr-postgresql-17.6-lib/lib -L/nix/store/4n3wdlj53mw2z9j32zg7v1d5hbn5b36x-postgresql-17.6/lib -L/nix/store/9qqi70nhacrdaq6i9063cszipizvgxvy-brotli-1.1.0-lib/lib -L/nix/store/gm9mjpm5y5kqnximvnqspk8x4ljk92kv-krb5-1.22.1-lib/lib -L/nix/store/5gn6gxwj5z9liir7vpr78h0rd56dfhri-nghttp2-1.67.1-lib/lib -L/nix/store/jvrplmhg6qfj6m21xrf7adb8cixbfj4q-nghttp3-1.12.0/lib -L/nix/store/lnykqqapknvsnm36ysviyd5hp1fy4add-ngtcp2-1.15.1/lib -L/nix/store/f04v56v4i3z0mrvl2npgcx5k5a792yjc-libidn2-2.3.8/lib -L/nix/store/llswcygvgv9x2sa3z6j7i0g5iqqmn5gn-openssl-3.6.0/lib -L/nix/store/30s8lvymqlfxvcdcm35nd0qrk6hp2bm9-libpsl-0.21.5/lib -L/nix/store/mlkib9x3s71ykinpld8gmyaymy6ml0lq-libssh2-1.11.1/lib -L/nix/store/z55x0q74zldi64iwamqf8wgrm2iza5rk-zlib-1.3.1/lib -L/nix/store/mmfrs9gzbhrfgv12ff68a056yv942s21-zstd-1.5.7/lib -L/nix/store/8np9zvwqmwsnbkbrwm8x7jq4ygdkjz5g-curl-8.16.0/lib -L/nix/store/gpwlv1hsykscxws6h8r2cv17lkchpmwq-bzip2-1.0.8/lib -L/nix/store/x23lssxwyj35r3d3y1x12bs3xgg84jz5-libpng-apng-1.6.50/lib -L/nix/store/psvw7vs0h8kfggxjxlscsv8hl9189768-freetype-2.13.3/lib -L/nix/store/gdfk1rph83bvw311jq02br1zy2krcmaf-libjpeg-turbo-3.1.2/lib -L/nix/store/n043a92dgc73ynpjwm108qy56qai7qpx-lcms2-2.17/lib -L/nix/store/0bhv7asksrwac04m9vjiqs2hdrm2jrc4-libx11-1.8.12/lib -L/nix/store/0j8a2izaid7g5ny14avyfzbmzw4csxrp-libice-1.1.2/lib -L/nix/store/9lzskrzabmn49hldrh55ff1vazb3pn3s-libsm-1.2.6/lib -L/nix/store/cqnkncc06yjynzq5cmprrpbr23akgfdb-libxt-1.3.1/lib -L/nix/store/0vdhbsw15wcllh9q0jvhyzgn8asmavfi-libwebp-1.6.0/lib -L/nix/store/91xjc9drfsynsxz2zw689fx6b8yzsj14-fftw-double-3.3.10/lib -L/nix/store/fha5q12bpdy6n5w11f15i1pjl6qpwc09-imagemagick-7.1.2-8/lib -L/nix/store/dja5qb5q8nlyfc6wl49xmm6sg9hq052z-file-5.45/lib -L/nix/store/lw0pnrlif2m177izcpbq67bdd824wp3p-python3-3.13.8-env/lib -L/nix/store/40wlxs3zyvkl7psva31hx4qvq8qk1wq9-postgresql-17.6-dev/lib -L/nix/store/09g5yr15i53l6yfif3p7q86shchwk1wr-postgresql-17.6-lib/lib -L/nix/store/4n3wdlj53mw2z9j32zg7v1d5hbn5b36x-postgresql-17.6/lib -L/nix/store/9qqi70nhacrdaq6i9063cszipizvgxvy-brotli-1.1.0-lib/lib -L/nix/store/gm9mjpm5y5kqnximvnqspk8x4ljk92kv-krb5-1.22.1-lib/lib -L/nix/store/5gn6gxwj5z9liir7vpr78h0rd56dfhri-nghttp2-1.67.1-lib/lib -L/nix/store/jvrplmhg6qfj6m21xrf7adb8cixbfj4q-nghttp3-1.12.0/lib -L/nix/store/lnykqqapknvsnm36ysviyd5hp1fy4add-ngtcp2-1.15.1/lib -L/nix/store/f04v56v4i3z0mrvl2npgcx5k5a792yjc-libidn2-2.3.8/lib -L/nix/store/llswcygvgv9x2sa3z6j7i0g5iqqmn5gn-openssl-3.6.0/lib -L/nix/store/30s8lvymqlfxvcdcm35nd0qrk6hp2bm9-libpsl-0.21.5/lib -L/nix/store/mlkib9x3s71ykinpld8gmyaymy6ml0lq-libssh2-1.11.1/lib -L/nix/store/z55x0q74zldi64iwamqf8wgrm2iza5rk-zlib-1.3.1/lib -L/nix/store/mmfrs9gzbhrfgv12ff68a056yv942s21-zstd-1.5.7/lib -L/nix/store/8np9zvwqmwsnbkbrwm8x7jq4ygdkjz5g-curl-8.16.0/lib -L/nix/store/gpwlv1hsykscxws6h8r2cv17lkchpmwq-bzip2-1.0.8/lib -L/nix/store/x23lssxwyj35r3d3y1x12bs3xgg84jz5-libpng-apng-1.6.50/lib -L/nix/store/psvw7vs0h8kfggxjxlscsv8hl9189768-freetype-2.13.3/lib -L/nix/store/gdfk1rph83bvw311jq02br1zy2krcmaf-libjpeg-turbo-3.1.2/lib -L/nix/store/n043a92dgc73ynpjwm108qy56qai7qpx-lcms2-2.17/lib -L/nix/store/0bhv7asksrwac04m9vjiqs2hdrm2jrc4-libx11-1.8.12/lib -L/nix/store/0j8a2izaid7g5ny14avyfzbmzw4csxrp-libice-1.1.2/lib -L/nix/store/9lzskrzabmn49hldrh55ff1vazb3pn3s-libsm-1.2.6/lib -L/nix/store/cqnkncc06yjynzq5cmprrpbr23akgfdb-libxt-1.3.1/lib -L/nix/store/0vdhbsw15wcllh9q0jvhyzgn8asmavfi-libwebp-1.6.0/lib -L/nix/store/91xjc9drfsynsxz2zw689fx6b8yzsj14-fftw-double-3.3.10/lib -L/nix/store/fha5q12bpdy6n5w11f15i1pjl6qpwc09-imagemagick-7.1.2-8/lib -L/nix/store/dja5qb5q8nlyfc6wl49xmm6sg9hq052z-file-5.45/lib' +export NIX_LDFLAGS +NIX_NO_SELF_RPATH='1' +NIX_STORE='/nix/store' +export NIX_STORE +NM='nm' +export NM +NODE_PATH='/nix/store/nvf9kaarb9kqqdbygl9cbzhli1y8yjik-nodejs-22.20.0/lib/node_modules:/nix/store/zjrmvhbdnc33dvfshsixmxgjfi6848sz-nodejs-22.20.0/lib/node_modules:/nix/store/y0cxgk9wrx91gsjmdx46khvl5ghjhajb-eslint-9.35.0/lib/node_modules' +export NODE_PATH +OBJCOPY='objcopy' +export OBJCOPY +OBJDUMP='objdump' +export OBJDUMP +OLDPWD='' +export OLDPWD +OPTERR='1' +OSTYPE='linux-gnu' +PATH='/nix/store/85n78yrssyfc65f32yxpqqcpzkgbjv8c-patchelf-0.15.2/bin:/nix/store/x8mydcgbry214s802nzvy7fdljx404ym-gcc-wrapper-14.3.0/bin:/nix/store/ffrg0560kj0066s4k9pznjand907nlnz-gcc-14.3.0/bin:/nix/store/76zn66xib0r80s5y7p0m1ba3y036sxwh-glibc-2.40-66-bin/bin:/nix/store/xs8scz9w9jp4hpqycx3n3bah5y07ymgj-coreutils-9.8/bin:/nix/store/918ldr9axgh5kdmpp5fnj2n37pyghwbx-binutils-wrapper-2.44/bin:/nix/store/416ykpc2bksb90sd1ia8cybxb3p83mrd-binutils-2.44/bin:/nix/store/lw0pnrlif2m177izcpbq67bdd824wp3p-python3-3.13.8-env/bin:/nix/store/n6chrdybb91npp8gvf8mjk55smx4sn8s-uv-0.8.23/bin:/nix/store/g6vnwhcxc1c0ry2igh4sca5gg7ss3cja-ruff-0.14.2/bin:/nix/store/40wlxs3zyvkl7psva31hx4qvq8qk1wq9-postgresql-17.6-dev/bin:/nix/store/4n3wdlj53mw2z9j32zg7v1d5hbn5b36x-postgresql-17.6/bin:/nix/store/x62pydw9kpd88m4986kyc02q8x3ipp3j-nodejs-22.20.0-dev/bin:/nix/store/nvf9kaarb9kqqdbygl9cbzhli1y8yjik-nodejs-22.20.0/bin:/nix/store/v3czg9qdaxnc7mymgrapd6w94kslpdhg-nodejs-22.20.0-dev/bin:/nix/store/zjrmvhbdnc33dvfshsixmxgjfi6848sz-nodejs-22.20.0/bin:/nix/store/y0cxgk9wrx91gsjmdx46khvl5ghjhajb-eslint-9.35.0/bin:/nix/store/vifx2z8zr1lrrrydm6cvffwmk35win8h-imagemagick-7.1.2-8-dev/bin:/nix/store/b65dj8iryqd1bss2qlg5ipxqndngcl9n-curl-8.16.0-dev/bin:/nix/store/7c3v4dg0ym1vdlgbr8qhcrcagqjkads6-brotli-1.1.0/bin:/nix/store/jskkvr36pxdasygya2v681fx2dkpnc3w-krb5-1.22.1-dev/bin:/nix/store/q1fkhzk163l40y30x3kqr34wryyc1a00-krb5-1.22.1/bin:/nix/store/d5bh7q649gi7srsgcq4civgajh6a0zks-nghttp2-1.67.1/bin:/nix/store/q9d5w2mc89b40hm4vnrh9m0c3d0lwbzw-libidn2-2.3.8-bin/bin:/nix/store/wlh6plymkd971amrm339p4jg9mk48w4q-openssl-3.6.0-bin/bin:/nix/store/30s8lvymqlfxvcdcm35nd0qrk6hp2bm9-libpsl-0.21.5/bin:/nix/store/bws65kn5ji8vpi51dsxhvn22kxgm5kmk-zstd-1.5.7-bin/bin:/nix/store/mmfrs9gzbhrfgv12ff68a056yv942s21-zstd-1.5.7/bin:/nix/store/xvh4bi0cc2cw18lpn6ax9m4zwnn1s9lj-curl-8.16.0-bin/bin:/nix/store/vzj5iy2png2rs6q2kb3svzp0cnd878f1-bzip2-1.0.8-bin/bin:/nix/store/104x1y8lq59bnmh71vcvym57grcdzc9b-freetype-2.13.3-dev/bin:/nix/store/1hkdki45cpkpkmszjm3mafac1igj6qyf-libpng-apng-1.6.50-dev/bin:/nix/store/hdy8y6irf4icxc9ijyls9zgz0xw1q70j-libjpeg-turbo-3.1.2-bin/bin:/nix/store/6g2qf8r8a5czg053gly63rkak3q2jx4a-lcms2-2.17-bin/bin:/nix/store/0vdhbsw15wcllh9q0jvhyzgn8asmavfi-libwebp-1.6.0/bin:/nix/store/108ma555wa88kyc933lpsmhnaymiygax-fftw-double-3.3.10-dev/bin:/nix/store/fha5q12bpdy6n5w11f15i1pjl6qpwc09-imagemagick-7.1.2-8/bin:/nix/store/dja5qb5q8nlyfc6wl49xmm6sg9hq052z-file-5.45/bin:/nix/store/glyid6d4mnv83j8gzj1r969y2220gwda-minio-2025-09-07T16-13-09Z/bin:/nix/store/q3r9y1jhcr2pxqhs3z0lca2bczrs2y3f-minio-client-2025-08-13T08-35-41Z/bin:/nix/store/60440kg5c51rrlpinkk2rkg020q5q6kv-git-2.51.0/bin:/nix/store/3by7b7afc50p6v1khzj7wyml1rdmagff-direnv-2.37.1/bin:/nix/store/8vwll24rhnizhrpa79lksk8jd8chzsn9-tmux-3.5a/bin:/nix/store/xs8scz9w9jp4hpqycx3n3bah5y07ymgj-coreutils-9.8/bin:/nix/store/qqvfnxa9jg71wp4hfg1l63r4m78iwvl9-findutils-4.10.0/bin:/nix/store/7ql4x9i7w7ihxw23vkanvcvrvqhay23c-diffutils-3.12/bin:/nix/store/zppkx0lkizglyqa9h26wf495qkllrjgy-gnused-4.9/bin:/nix/store/22r4s6lqhl43jkazn51f3c18qwk894g4-gnugrep-3.12/bin:/nix/store/8c4l9cqqj7pixqlmljx5d495pfpw8pys-gawk-5.3.2/bin:/nix/store/3m0zcl1by8ifylmgdcaa317cnhqn8q95-gnutar-1.35/bin:/nix/store/vjj9x3dzszbzjpkzwx63z4gpypiqphzf-gzip-1.14/bin:/nix/store/vzj5iy2png2rs6q2kb3svzp0cnd878f1-bzip2-1.0.8-bin/bin:/nix/store/43lv2nr7pj7wy09qyicjq57bl209ccis-gnumake-4.4.1/bin:/nix/store/ciarnmsx8lvsrmdbjddpmx0pqjrm8imb-bash-5.3p3/bin:/nix/store/miap8jjp87z0gibkrzh68lw2w8kzy4yx-patch-2.8/bin:/nix/store/vwx988887gsrdvg9lbg5f03sy91wrjjv-xz-5.8.1-bin/bin:/nix/store/n686xm9c2ak4x02pj9izvkm0zx7ilwir-file-5.45/bin' +export PATH +PS4='+ ' +RANLIB='ranlib' +export RANLIB +READELF='readelf' +export READELF +SHELL='/nix/store/ciarnmsx8lvsrmdbjddpmx0pqjrm8imb-bash-5.3p3/bin/bash' +export SHELL +SIZE='size' +export SIZE +SOURCE_DATE_EPOCH='315532800' +export SOURCE_DATE_EPOCH +STRINGS='strings' +export STRINGS +STRIP='strip' +export STRIP +XDG_DATA_DIRS='/nix/store/85n78yrssyfc65f32yxpqqcpzkgbjv8c-patchelf-0.15.2/share' +export XDG_DATA_DIRS +__structuredAttrs='' +export __structuredAttrs +_substituteStream_has_warned_replace_deprecation='false' +buildInputs='/nix/store/lw0pnrlif2m177izcpbq67bdd824wp3p-python3-3.13.8-env /nix/store/n6chrdybb91npp8gvf8mjk55smx4sn8s-uv-0.8.23 /nix/store/g6vnwhcxc1c0ry2igh4sca5gg7ss3cja-ruff-0.14.2 /nix/store/40wlxs3zyvkl7psva31hx4qvq8qk1wq9-postgresql-17.6-dev /nix/store/x62pydw9kpd88m4986kyc02q8x3ipp3j-nodejs-22.20.0-dev /nix/store/v3czg9qdaxnc7mymgrapd6w94kslpdhg-nodejs-22.20.0-dev /nix/store/y0cxgk9wrx91gsjmdx46khvl5ghjhajb-eslint-9.35.0 /nix/store/vifx2z8zr1lrrrydm6cvffwmk35win8h-imagemagick-7.1.2-8-dev /nix/store/nd3xq6vzsdwdlxjv4bb8w6dcmqs319qm-file-5.45-dev /nix/store/glyid6d4mnv83j8gzj1r969y2220gwda-minio-2025-09-07T16-13-09Z /nix/store/q3r9y1jhcr2pxqhs3z0lca2bczrs2y3f-minio-client-2025-08-13T08-35-41Z /nix/store/60440kg5c51rrlpinkk2rkg020q5q6kv-git-2.51.0 /nix/store/3by7b7afc50p6v1khzj7wyml1rdmagff-direnv-2.37.1 /nix/store/8vwll24rhnizhrpa79lksk8jd8chzsn9-tmux-3.5a' +export buildInputs +buildPhase='{ echo "------------------------------------------------------------"; + echo " WARNING: the existence of this path is not guaranteed."; + echo " It is an internal implementation detail for pkgs.mkShell."; + echo "------------------------------------------------------------"; + echo; + # Record all build inputs as runtime dependencies + export; +} >> "$out" +' +export buildPhase +builder='/nix/store/ciarnmsx8lvsrmdbjddpmx0pqjrm8imb-bash-5.3p3/bin/bash' +export builder +cmakeFlags='' +export cmakeFlags +configureFlags='' +export configureFlags +defaultBuildInputs='' +defaultNativeBuildInputs='/nix/store/85n78yrssyfc65f32yxpqqcpzkgbjv8c-patchelf-0.15.2 /nix/store/lbfg38p9kgm2dadwjqa7fgv0sjjh3ban-update-autotools-gnu-config-scripts-hook /nix/store/0y5xmdb7qfvimjwbq7ibg1xdgkgjwqng-no-broken-symlinks.sh /nix/store/cv1d7p48379km6a85h4zp6kr86brh32q-audit-tmpdir.sh /nix/store/85clx3b0xkdf58jn161iy80y5223ilbi-compress-man-pages.sh /nix/store/wgrbkkaldkrlrni33ccvm3b6vbxzb656-make-symlinks-relative.sh /nix/store/5yzw0vhkyszf2d179m0qfkgxmp5wjjx4-move-docs.sh /nix/store/fyaryjvghbkpfnsyw97hb3lyb37s1pd6-move-lib64.sh /nix/store/kd4xwxjpjxi71jkm6ka0np72if9rm3y0-move-sbin.sh /nix/store/pag6l61paj1dc9sv15l7bm5c17xn5kyk-move-systemd-user-units.sh /nix/store/cmzya9irvxzlkh7lfy6i82gbp0saxqj3-multiple-outputs.sh /nix/store/x8c40nfigps493a07sdr2pm5s9j1cdc0-patch-shebangs.sh /nix/store/cickvswrvann041nqxb0rxilc46svw1n-prune-libtool-files.sh /nix/store/xyff06pkhki3qy1ls77w10s0v79c9il0-reproducible-builds.sh /nix/store/z7k98578dfzi6l3hsvbivzm7hfqlk0zc-set-source-date-epoch-to-latest.sh /nix/store/pilsssjjdxvdphlg2h19p0bfx5q0jzkn-strip.sh /nix/store/x8mydcgbry214s802nzvy7fdljx404ym-gcc-wrapper-14.3.0' +depsBuildBuild='' +export depsBuildBuild +depsBuildBuildPropagated='' +export depsBuildBuildPropagated +depsBuildTarget='' +export depsBuildTarget +depsBuildTargetPropagated='' +export depsBuildTargetPropagated +depsHostHost='' +export depsHostHost +depsHostHostPropagated='' +export depsHostHostPropagated +depsTargetTarget='' +export depsTargetTarget +depsTargetTargetPropagated='' +export depsTargetTargetPropagated +doCheck='' +export doCheck +doInstallCheck='' +export doInstallCheck +dontAddDisableDepTrack='1' +export dontAddDisableDepTrack +declare -a envBuildBuildHooks=() +declare -a envBuildHostHooks=() +declare -a envBuildTargetHooks=() +declare -a envHostHostHooks=('ccWrapper_addCVars' 'bintoolsWrapper_addLDVars' 'addNodePath' 'addNodePath' ) +declare -a envHostTargetHooks=('ccWrapper_addCVars' 'bintoolsWrapper_addLDVars' 'addNodePath' 'addNodePath' ) +declare -a envTargetTargetHooks=() +declare -a fixupOutputHooks=('if [ -z "${dontPatchELF-}" ]; then patchELF "$prefix"; fi' 'if [[ -z "${noAuditTmpdir-}" && -e "$prefix" ]]; then auditTmpdir "$prefix"; fi' 'if [ -z "${dontGzipMan-}" ]; then compressManPages "$prefix"; fi' '_moveLib64' '_moveSbin' '_moveSystemdUserUnits' 'patchShebangsAuto' '_pruneLibtoolFiles' '_doStrip' ) +initialPath='/nix/store/xs8scz9w9jp4hpqycx3n3bah5y07ymgj-coreutils-9.8 /nix/store/qqvfnxa9jg71wp4hfg1l63r4m78iwvl9-findutils-4.10.0 /nix/store/7ql4x9i7w7ihxw23vkanvcvrvqhay23c-diffutils-3.12 /nix/store/zppkx0lkizglyqa9h26wf495qkllrjgy-gnused-4.9 /nix/store/22r4s6lqhl43jkazn51f3c18qwk894g4-gnugrep-3.12 /nix/store/8c4l9cqqj7pixqlmljx5d495pfpw8pys-gawk-5.3.2 /nix/store/3m0zcl1by8ifylmgdcaa317cnhqn8q95-gnutar-1.35 /nix/store/vjj9x3dzszbzjpkzwx63z4gpypiqphzf-gzip-1.14 /nix/store/vzj5iy2png2rs6q2kb3svzp0cnd878f1-bzip2-1.0.8-bin /nix/store/43lv2nr7pj7wy09qyicjq57bl209ccis-gnumake-4.4.1 /nix/store/ciarnmsx8lvsrmdbjddpmx0pqjrm8imb-bash-5.3p3 /nix/store/miap8jjp87z0gibkrzh68lw2w8kzy4yx-patch-2.8 /nix/store/vwx988887gsrdvg9lbg5f03sy91wrjjv-xz-5.8.1-bin /nix/store/n686xm9c2ak4x02pj9izvkm0zx7ilwir-file-5.45' +mesonFlags='' +export mesonFlags +name='nix-shell-env' +export name +nativeBuildInputs='' +export nativeBuildInputs +out='/home/jawz/Development/Projects/personal/webref/outputs/out' +export out +outputBin='out' +outputDev='out' +outputDevdoc='REMOVE' +outputDevman='out' +outputDoc='out' +outputInclude='out' +outputInfo='out' +outputLib='out' +outputMan='out' +outputs='out' +export outputs +patches='' +export patches +phases='buildPhase' +export phases +pkg='/nix/store/x8mydcgbry214s802nzvy7fdljx404ym-gcc-wrapper-14.3.0' +declare -a pkgsBuildBuild=() +declare -a pkgsBuildHost=('/nix/store/85n78yrssyfc65f32yxpqqcpzkgbjv8c-patchelf-0.15.2' '/nix/store/lbfg38p9kgm2dadwjqa7fgv0sjjh3ban-update-autotools-gnu-config-scripts-hook' '/nix/store/0y5xmdb7qfvimjwbq7ibg1xdgkgjwqng-no-broken-symlinks.sh' '/nix/store/cv1d7p48379km6a85h4zp6kr86brh32q-audit-tmpdir.sh' '/nix/store/85clx3b0xkdf58jn161iy80y5223ilbi-compress-man-pages.sh' '/nix/store/wgrbkkaldkrlrni33ccvm3b6vbxzb656-make-symlinks-relative.sh' '/nix/store/5yzw0vhkyszf2d179m0qfkgxmp5wjjx4-move-docs.sh' '/nix/store/fyaryjvghbkpfnsyw97hb3lyb37s1pd6-move-lib64.sh' '/nix/store/kd4xwxjpjxi71jkm6ka0np72if9rm3y0-move-sbin.sh' '/nix/store/pag6l61paj1dc9sv15l7bm5c17xn5kyk-move-systemd-user-units.sh' '/nix/store/cmzya9irvxzlkh7lfy6i82gbp0saxqj3-multiple-outputs.sh' '/nix/store/x8c40nfigps493a07sdr2pm5s9j1cdc0-patch-shebangs.sh' '/nix/store/cickvswrvann041nqxb0rxilc46svw1n-prune-libtool-files.sh' '/nix/store/xyff06pkhki3qy1ls77w10s0v79c9il0-reproducible-builds.sh' '/nix/store/z7k98578dfzi6l3hsvbivzm7hfqlk0zc-set-source-date-epoch-to-latest.sh' '/nix/store/pilsssjjdxvdphlg2h19p0bfx5q0jzkn-strip.sh' '/nix/store/x8mydcgbry214s802nzvy7fdljx404ym-gcc-wrapper-14.3.0' '/nix/store/918ldr9axgh5kdmpp5fnj2n37pyghwbx-binutils-wrapper-2.44' ) +declare -a pkgsBuildTarget=() +declare -a pkgsHostHost=() +declare -a pkgsHostTarget=('/nix/store/lw0pnrlif2m177izcpbq67bdd824wp3p-python3-3.13.8-env' '/nix/store/n6chrdybb91npp8gvf8mjk55smx4sn8s-uv-0.8.23' '/nix/store/g6vnwhcxc1c0ry2igh4sca5gg7ss3cja-ruff-0.14.2' '/nix/store/40wlxs3zyvkl7psva31hx4qvq8qk1wq9-postgresql-17.6-dev' '/nix/store/09g5yr15i53l6yfif3p7q86shchwk1wr-postgresql-17.6-lib' '/nix/store/4n3wdlj53mw2z9j32zg7v1d5hbn5b36x-postgresql-17.6' '/nix/store/x62pydw9kpd88m4986kyc02q8x3ipp3j-nodejs-22.20.0-dev' '/nix/store/nvf9kaarb9kqqdbygl9cbzhli1y8yjik-nodejs-22.20.0' '/nix/store/v3czg9qdaxnc7mymgrapd6w94kslpdhg-nodejs-22.20.0-dev' '/nix/store/zjrmvhbdnc33dvfshsixmxgjfi6848sz-nodejs-22.20.0' '/nix/store/y0cxgk9wrx91gsjmdx46khvl5ghjhajb-eslint-9.35.0' '/nix/store/vifx2z8zr1lrrrydm6cvffwmk35win8h-imagemagick-7.1.2-8-dev' '/nix/store/b65dj8iryqd1bss2qlg5ipxqndngcl9n-curl-8.16.0-dev' '/nix/store/r5dnrkpnds4xi52cdwakbjvvx049cj3x-brotli-1.1.0-dev' '/nix/store/9qqi70nhacrdaq6i9063cszipizvgxvy-brotli-1.1.0-lib' '/nix/store/7c3v4dg0ym1vdlgbr8qhcrcagqjkads6-brotli-1.1.0' '/nix/store/jskkvr36pxdasygya2v681fx2dkpnc3w-krb5-1.22.1-dev' '/nix/store/gm9mjpm5y5kqnximvnqspk8x4ljk92kv-krb5-1.22.1-lib' '/nix/store/q1fkhzk163l40y30x3kqr34wryyc1a00-krb5-1.22.1' '/nix/store/710f10kd7vppa8wd3lxrvj51gz7v1nk1-nghttp2-1.67.1-dev' '/nix/store/5gn6gxwj5z9liir7vpr78h0rd56dfhri-nghttp2-1.67.1-lib' '/nix/store/d5bh7q649gi7srsgcq4civgajh6a0zks-nghttp2-1.67.1' '/nix/store/qviyg73dvs1cf4f0ggafprwvwi192bs9-nghttp3-1.12.0-dev' '/nix/store/jvrplmhg6qfj6m21xrf7adb8cixbfj4q-nghttp3-1.12.0' '/nix/store/nff0nifv0g3pg4djn86rfniz1sp022r7-ngtcp2-1.15.1-dev' '/nix/store/lnykqqapknvsnm36ysviyd5hp1fy4add-ngtcp2-1.15.1' '/nix/store/6h9ffn5nwcilirkgva4hb99rnhmjxnqf-libidn2-2.3.8-dev' '/nix/store/q9d5w2mc89b40hm4vnrh9m0c3d0lwbzw-libidn2-2.3.8-bin' '/nix/store/f04v56v4i3z0mrvl2npgcx5k5a792yjc-libidn2-2.3.8' '/nix/store/4vm27ldxmphlgyj3vf1h7dlgvj5hvj0w-openssl-3.6.0-dev' '/nix/store/wlh6plymkd971amrm339p4jg9mk48w4q-openssl-3.6.0-bin' '/nix/store/llswcygvgv9x2sa3z6j7i0g5iqqmn5gn-openssl-3.6.0' '/nix/store/xshi6pdd825v5pzg3h94sxdxa8ap3liq-libpsl-0.21.5-dev' '/nix/store/0dqzmxlgkmw057ffz0cpcbszg17yfmrk-publicsuffix-list-0-unstable-2025-08-28' '/nix/store/30s8lvymqlfxvcdcm35nd0qrk6hp2bm9-libpsl-0.21.5' '/nix/store/3171192n3540d42bc380cv00vzn6hyhs-libssh2-1.11.1-dev' '/nix/store/mlkib9x3s71ykinpld8gmyaymy6ml0lq-libssh2-1.11.1' '/nix/store/hwqbid7b85dfdvyj0ckgi2c6a4ir653q-zlib-1.3.1-dev' '/nix/store/z55x0q74zldi64iwamqf8wgrm2iza5rk-zlib-1.3.1' '/nix/store/v12k3k3nbhhqmwcvzbsljr135wbrwnsr-zstd-1.5.7-dev' '/nix/store/bws65kn5ji8vpi51dsxhvn22kxgm5kmk-zstd-1.5.7-bin' '/nix/store/mmfrs9gzbhrfgv12ff68a056yv942s21-zstd-1.5.7' '/nix/store/xvh4bi0cc2cw18lpn6ax9m4zwnn1s9lj-curl-8.16.0-bin' '/nix/store/8np9zvwqmwsnbkbrwm8x7jq4ygdkjz5g-curl-8.16.0' '/nix/store/i20l7xn2a2yxdalwc2c8yp29dia5v2j7-bzip2-1.0.8-dev' '/nix/store/vzj5iy2png2rs6q2kb3svzp0cnd878f1-bzip2-1.0.8-bin' '/nix/store/gpwlv1hsykscxws6h8r2cv17lkchpmwq-bzip2-1.0.8' '/nix/store/104x1y8lq59bnmh71vcvym57grcdzc9b-freetype-2.13.3-dev' '/nix/store/1hkdki45cpkpkmszjm3mafac1igj6qyf-libpng-apng-1.6.50-dev' '/nix/store/x23lssxwyj35r3d3y1x12bs3xgg84jz5-libpng-apng-1.6.50' '/nix/store/psvw7vs0h8kfggxjxlscsv8hl9189768-freetype-2.13.3' '/nix/store/mfnqd0l258v2vdyxynbw2dq129z7sad3-libjpeg-turbo-3.1.2-dev' '/nix/store/hdy8y6irf4icxc9ijyls9zgz0xw1q70j-libjpeg-turbo-3.1.2-bin' '/nix/store/gdfk1rph83bvw311jq02br1zy2krcmaf-libjpeg-turbo-3.1.2' '/nix/store/6vxqzmlq8acapwzssss59lxnzs0mkdz5-lcms2-2.17-dev' '/nix/store/6g2qf8r8a5czg053gly63rkak3q2jx4a-lcms2-2.17-bin' '/nix/store/n043a92dgc73ynpjwm108qy56qai7qpx-lcms2-2.17' '/nix/store/20q72vi8wdb7j6p9sdnpi32477j6nv2z-libx11-1.8.12-dev' '/nix/store/nml7jra2yzxg133ssim80aj39f4668gc-xorgproto-2024.1' '/nix/store/0bhv7asksrwac04m9vjiqs2hdrm2jrc4-libx11-1.8.12' '/nix/store/vxvpdfl4k0gqh3nhmx8w9pz04c60p1ls-libxt-1.3.1-dev' '/nix/store/vb5a0cp9npq3935fblc25abd5dw4zijv-libsm-1.2.6-dev' '/nix/store/zqma24cl2d96xbgqd97jkphnc9x1w5la-libice-1.1.2-dev' '/nix/store/0j8a2izaid7g5ny14avyfzbmzw4csxrp-libice-1.1.2' '/nix/store/9lzskrzabmn49hldrh55ff1vazb3pn3s-libsm-1.2.6' '/nix/store/cqnkncc06yjynzq5cmprrpbr23akgfdb-libxt-1.3.1' '/nix/store/0vdhbsw15wcllh9q0jvhyzgn8asmavfi-libwebp-1.6.0' '/nix/store/108ma555wa88kyc933lpsmhnaymiygax-fftw-double-3.3.10-dev' '/nix/store/91xjc9drfsynsxz2zw689fx6b8yzsj14-fftw-double-3.3.10' '/nix/store/fha5q12bpdy6n5w11f15i1pjl6qpwc09-imagemagick-7.1.2-8' '/nix/store/nd3xq6vzsdwdlxjv4bb8w6dcmqs319qm-file-5.45-dev' '/nix/store/dja5qb5q8nlyfc6wl49xmm6sg9hq052z-file-5.45' '/nix/store/glyid6d4mnv83j8gzj1r969y2220gwda-minio-2025-09-07T16-13-09Z' '/nix/store/q3r9y1jhcr2pxqhs3z0lca2bczrs2y3f-minio-client-2025-08-13T08-35-41Z' '/nix/store/60440kg5c51rrlpinkk2rkg020q5q6kv-git-2.51.0' '/nix/store/3by7b7afc50p6v1khzj7wyml1rdmagff-direnv-2.37.1' '/nix/store/8vwll24rhnizhrpa79lksk8jd8chzsn9-tmux-3.5a' ) +declare -a pkgsTargetTarget=() +declare -a postFixupHooks=('noBrokenSymlinksInAllOutputs' '_makeSymlinksRelativeInAllOutputs' '_multioutPropagateDev' ) +declare -a postUnpackHooks=('_updateSourceDateEpochFromSourceRoot' ) +declare -a preConfigureHooks=('_multioutConfig' ) +preConfigurePhases=' updateAutotoolsGnuConfigScriptsPhase' +declare -a preFixupHooks=('_moveToShare' '_multioutDocs' '_multioutDevs' ) +preferLocalBuild='1' +export preferLocalBuild +prefix='/home/jawz/Development/Projects/personal/webref/outputs/out' +declare -a propagatedBuildDepFiles=('propagated-build-build-deps' 'propagated-native-build-inputs' 'propagated-build-target-deps' ) +propagatedBuildInputs='' +export propagatedBuildInputs +declare -a propagatedHostDepFiles=('propagated-host-host-deps' 'propagated-build-inputs' ) +propagatedNativeBuildInputs='' +export propagatedNativeBuildInputs +declare -a propagatedTargetDepFiles=('propagated-target-target-deps' ) +shell='/nix/store/ciarnmsx8lvsrmdbjddpmx0pqjrm8imb-bash-5.3p3/bin/bash' +export shell +shellHook='echo "šŸš€ Reference Board Viewer Development Environment" +echo "" +echo "šŸ“¦ Versions:" +echo " Python: $(python --version)" +echo " Node.js: $(node --version)" +echo " PostgreSQL: $(psql --version | head -n1)" +echo " MinIO: $(minio --version | head -n1)" +echo "" +echo "šŸ”§ Development Services:" +echo " Start: ./scripts/dev-services.sh start" +echo " Stop: ./scripts/dev-services.sh stop" +echo " Status: ./scripts/dev-services.sh status" +echo "" +echo "šŸ“š Quick Commands:" +echo " Dev (tmux): nix run .#dev" +echo " Backend: cd backend && uvicorn app.main:app --reload" +echo " Frontend: cd frontend && npm run dev" +echo " Database: psql -h localhost -U webref webref" +echo " Tests: cd backend && pytest --cov" +echo "" +echo "šŸ“– Documentation:" +echo " API Docs: http://localhost:8000/docs" +echo " App: http://localhost:5173" +echo " MinIO UI: http://localhost:9001" +echo "" + +# Set up environment variables +export DATABASE_URL="postgresql://webref@localhost:5432/webref" +export MINIO_ENDPOINT="localhost:9000" +export MINIO_ACCESS_KEY="minioadmin" +export MINIO_SECRET_KEY="minioadmin" +export PYTHONPATH="$PWD/backend:$PYTHONPATH" +' +export shellHook +stdenv='/nix/store/jsj1sql9id9c10sxmg7rvq3pj0f1l19b-stdenv-linux' +export stdenv +strictDeps='' +export strictDeps +system='x86_64-linux' +export system +declare -a unpackCmdHooks=('_defaultUnpack' ) +_activatePkgs () +{ + + local hostOffset targetOffset; + local pkg; + for hostOffset in "${allPlatOffsets[@]}"; + do + local pkgsVar="${pkgAccumVarVars[hostOffset + 1]}"; + for targetOffset in "${allPlatOffsets[@]}"; + do + (( hostOffset <= targetOffset )) || continue; + local pkgsRef="${pkgsVar}[$targetOffset - $hostOffset]"; + local pkgsSlice="${!pkgsRef}[@]"; + for pkg in ${!pkgsSlice+"${!pkgsSlice}"}; + do + activatePackage "$pkg" "$hostOffset" "$targetOffset"; + done; + done; + done +} +_addRpathPrefix () +{ + + if [ "${NIX_NO_SELF_RPATH:-0}" != 1 ]; then + export NIX_LDFLAGS="-rpath $1/lib ${NIX_LDFLAGS-}"; + fi +} +_addToEnv () +{ + + local depHostOffset depTargetOffset; + local pkg; + for depHostOffset in "${allPlatOffsets[@]}"; + do + local hookVar="${pkgHookVarVars[depHostOffset + 1]}"; + local pkgsVar="${pkgAccumVarVars[depHostOffset + 1]}"; + for depTargetOffset in "${allPlatOffsets[@]}"; + do + (( depHostOffset <= depTargetOffset )) || continue; + local hookRef="${hookVar}[$depTargetOffset - $depHostOffset]"; + if [[ -z "${strictDeps-}" ]]; then + local visitedPkgs=""; + for pkg in "${pkgsBuildBuild[@]}" "${pkgsBuildHost[@]}" "${pkgsBuildTarget[@]}" "${pkgsHostHost[@]}" "${pkgsHostTarget[@]}" "${pkgsTargetTarget[@]}"; + do + if [[ "$visitedPkgs" = *"$pkg"* ]]; then + continue; + fi; + runHook "${!hookRef}" "$pkg"; + visitedPkgs+=" $pkg"; + done; + else + local pkgsRef="${pkgsVar}[$depTargetOffset - $depHostOffset]"; + local pkgsSlice="${!pkgsRef}[@]"; + for pkg in ${!pkgsSlice+"${!pkgsSlice}"}; + do + runHook "${!hookRef}" "$pkg"; + done; + fi; + done; + done +} +_allFlags () +{ + + export system pname name version; + while IFS='' read -r varName; do + nixTalkativeLog "@${varName}@ -> ${!varName}"; + args+=("--subst-var" "$varName"); + done < <(awk 'BEGIN { for (v in ENVIRON) if (v ~ /^[a-z][a-zA-Z0-9_]*$/) print v }') +} +_assignFirst () +{ + + local varName="$1"; + local _var; + local REMOVE=REMOVE; + shift; + for _var in "$@"; + do + if [ -n "${!_var-}" ]; then + eval "${varName}"="${_var}"; + return; + fi; + done; + echo; + echo "error: _assignFirst: could not find a non-empty variable whose name to assign to ${varName}."; + echo " The following variables were all unset or empty:"; + echo " $*"; + if [ -z "${out:-}" ]; then + echo ' If you do not want an "out" output in your derivation, make sure to define'; + echo ' the other specific required outputs. This can be achieved by picking one'; + echo " of the above as an output."; + echo ' You do not have to remove "out" if you want to have a different default'; + echo ' output, because the first output is taken as a default.'; + echo; + fi; + return 1 +} +_callImplicitHook () +{ + + local def="$1"; + local hookName="$2"; + if declare -F "$hookName" > /dev/null; then + nixTalkativeLog "calling implicit '$hookName' function hook"; + "$hookName"; + else + if type -p "$hookName" > /dev/null; then + nixTalkativeLog "sourcing implicit '$hookName' script hook"; + source "$hookName"; + else + if [ -n "${!hookName:-}" ]; then + nixTalkativeLog "evaling implicit '$hookName' string hook"; + eval "${!hookName}"; + else + return "$def"; + fi; + fi; + fi +} +_defaultUnpack () +{ + + local fn="$1"; + local destination; + if [ -d "$fn" ]; then + destination="$(stripHash "$fn")"; + if [ -e "$destination" ]; then + echo "Cannot copy $fn to $destination: destination already exists!"; + echo "Did you specify two \"srcs\" with the same \"name\"?"; + return 1; + fi; + cp -r --preserve=timestamps --reflink=auto -- "$fn" "$destination"; + else + case "$fn" in + *.tar.xz | *.tar.lzma | *.txz) + ( XZ_OPT="--threads=$NIX_BUILD_CORES" xz -d < "$fn"; + true ) | tar xf - --mode=+w --warning=no-timestamp + ;; + *.tar | *.tar.* | *.tgz | *.tbz2 | *.tbz) + tar xf "$fn" --mode=+w --warning=no-timestamp + ;; + *) + return 1 + ;; + esac; + fi +} +_doStrip () +{ + + local -ra flags=(dontStripHost dontStripTarget); + local -ra debugDirs=(stripDebugList stripDebugListTarget); + local -ra allDirs=(stripAllList stripAllListTarget); + local -ra stripCmds=(STRIP STRIP_FOR_TARGET); + local -ra ranlibCmds=(RANLIB RANLIB_FOR_TARGET); + stripDebugList=${stripDebugList[*]:-lib lib32 lib64 libexec bin sbin Applications Library/Frameworks}; + stripDebugListTarget=${stripDebugListTarget[*]:-}; + stripAllList=${stripAllList[*]:-}; + stripAllListTarget=${stripAllListTarget[*]:-}; + local i; + for i in ${!stripCmds[@]}; + do + local -n flag="${flags[$i]}"; + local -n debugDirList="${debugDirs[$i]}"; + local -n allDirList="${allDirs[$i]}"; + local -n stripCmd="${stripCmds[$i]}"; + local -n ranlibCmd="${ranlibCmds[$i]}"; + if [[ -n "${dontStrip-}" || -n "${flag-}" ]] || ! type -f "${stripCmd-}" 2> /dev/null 1>&2; then + continue; + fi; + stripDirs "$stripCmd" "$ranlibCmd" "$debugDirList" "${stripDebugFlags[*]:--S -p}"; + stripDirs "$stripCmd" "$ranlibCmd" "$allDirList" "${stripAllFlags[*]:--s -p}"; + done +} +_eval () +{ + + if declare -F "$1" > /dev/null 2>&1; then + "$@"; + else + eval "$1"; + fi +} +_logHook () +{ + + if [[ -z ${NIX_LOG_FD-} ]]; then + return; + fi; + local hookKind="$1"; + local hookExpr="$2"; + shift 2; + if declare -F "$hookExpr" > /dev/null 2>&1; then + nixTalkativeLog "calling '$hookKind' function hook '$hookExpr'" "$@"; + else + if type -p "$hookExpr" > /dev/null; then + nixTalkativeLog "sourcing '$hookKind' script hook '$hookExpr'"; + else + if [[ "$hookExpr" != "_callImplicitHook"* ]]; then + local exprToOutput; + if [[ ${NIX_DEBUG:-0} -ge 5 ]]; then + exprToOutput="$hookExpr"; + else + local hookExprLine; + while IFS= read -r hookExprLine; do + hookExprLine="${hookExprLine#"${hookExprLine%%[![:space:]]*}"}"; + if [[ -n "$hookExprLine" ]]; then + exprToOutput+="$hookExprLine\\n "; + fi; + done <<< "$hookExpr"; + exprToOutput="${exprToOutput%%\\n }"; + fi; + nixTalkativeLog "evaling '$hookKind' string hook '$exprToOutput'"; + fi; + fi; + fi +} +_makeSymlinksRelative () +{ + + local symlinkTarget; + if [ "${dontRewriteSymlinks-}" ] || [ ! -e "$prefix" ]; then + return; + fi; + while IFS= read -r -d '' f; do + symlinkTarget=$(readlink "$f"); + if [[ "$symlinkTarget"/ != "$prefix"/* ]]; then + continue; + fi; + if [ ! -e "$symlinkTarget" ]; then + echo "the symlink $f is broken, it points to $symlinkTarget (which is missing)"; + fi; + echo "rewriting symlink $f to be relative to $prefix"; + ln -snrf "$symlinkTarget" "$f"; + done < <(find $prefix -type l -print0) +} +_makeSymlinksRelativeInAllOutputs () +{ + + local output; + for output in $(getAllOutputNames); + do + prefix="${!output}" _makeSymlinksRelative; + done +} +_moveLib64 () +{ + + if [ "${dontMoveLib64-}" = 1 ]; then + return; + fi; + if [ ! -e "$prefix/lib64" -o -L "$prefix/lib64" ]; then + return; + fi; + echo "moving $prefix/lib64/* to $prefix/lib"; + mkdir -p $prefix/lib; + shopt -s dotglob; + for i in $prefix/lib64/*; + do + mv --no-clobber "$i" $prefix/lib; + done; + shopt -u dotglob; + rmdir $prefix/lib64; + ln -s lib $prefix/lib64 +} +_moveSbin () +{ + + if [ "${dontMoveSbin-}" = 1 ]; then + return; + fi; + if [ ! -e "$prefix/sbin" -o -L "$prefix/sbin" ]; then + return; + fi; + echo "moving $prefix/sbin/* to $prefix/bin"; + mkdir -p $prefix/bin; + shopt -s dotglob; + for i in $prefix/sbin/*; + do + mv "$i" $prefix/bin; + done; + shopt -u dotglob; + rmdir $prefix/sbin; + ln -s bin $prefix/sbin +} +_moveSystemdUserUnits () +{ + + if [ "${dontMoveSystemdUserUnits:-0}" = 1 ]; then + return; + fi; + if [ ! -e "${prefix:?}/lib/systemd/user" ]; then + return; + fi; + local source="$prefix/lib/systemd/user"; + local target="$prefix/share/systemd/user"; + echo "moving $source/* to $target"; + mkdir -p "$target"; + ( shopt -s dotglob; + for i in "$source"/*; + do + mv "$i" "$target"; + done ); + rmdir "$source"; + ln -s "$target" "$source" +} +_moveToShare () +{ + + if [ -n "$__structuredAttrs" ]; then + if [ -z "${forceShare-}" ]; then + forceShare=(man doc info); + fi; + else + forceShare=(${forceShare:-man doc info}); + fi; + if [[ -z "$out" ]]; then + return; + fi; + for d in "${forceShare[@]}"; + do + if [ -d "$out/$d" ]; then + if [ -d "$out/share/$d" ]; then + echo "both $d/ and share/$d/ exist!"; + else + echo "moving $out/$d to $out/share/$d"; + mkdir -p $out/share; + mv $out/$d $out/share/; + fi; + fi; + done +} +_multioutConfig () +{ + + if [ "$(getAllOutputNames)" = "out" ] || [ -z "${setOutputFlags-1}" ]; then + return; + fi; + if [ -z "${shareDocName:-}" ]; then + local confScript="${configureScript:-}"; + if [ -z "$confScript" ] && [ -x ./configure ]; then + confScript=./configure; + fi; + if [ -f "$confScript" ]; then + local shareDocName="$(sed -n "s/^PACKAGE_TARNAME='\(.*\)'$/\1/p" < "$confScript")"; + fi; + if [ -z "$shareDocName" ] || echo "$shareDocName" | grep -q '[^a-zA-Z0-9_-]'; then + shareDocName="$(echo "$name" | sed 's/-[^a-zA-Z].*//')"; + fi; + fi; + prependToVar configureFlags --bindir="${!outputBin}"/bin --sbindir="${!outputBin}"/sbin --includedir="${!outputInclude}"/include --mandir="${!outputMan}"/share/man --infodir="${!outputInfo}"/share/info --docdir="${!outputDoc}"/share/doc/"${shareDocName}" --libdir="${!outputLib}"/lib --libexecdir="${!outputLib}"/libexec --localedir="${!outputLib}"/share/locale; + prependToVar installFlags pkgconfigdir="${!outputDev}"/lib/pkgconfig m4datadir="${!outputDev}"/share/aclocal aclocaldir="${!outputDev}"/share/aclocal +} +_multioutDevs () +{ + + if [ "$(getAllOutputNames)" = "out" ] || [ -z "${moveToDev-1}" ]; then + return; + fi; + moveToOutput include "${!outputInclude}"; + moveToOutput lib/pkgconfig "${!outputDev}"; + moveToOutput share/pkgconfig "${!outputDev}"; + moveToOutput lib/cmake "${!outputDev}"; + moveToOutput share/aclocal "${!outputDev}"; + for f in "${!outputDev}"/{lib,share}/pkgconfig/*.pc; + do + echo "Patching '$f' includedir to output ${!outputInclude}"; + sed -i "/^includedir=/s,=\${prefix},=${!outputInclude}," "$f"; + done +} +_multioutDocs () +{ + + local REMOVE=REMOVE; + moveToOutput share/info "${!outputInfo}"; + moveToOutput share/doc "${!outputDoc}"; + moveToOutput share/gtk-doc "${!outputDevdoc}"; + moveToOutput share/devhelp/books "${!outputDevdoc}"; + moveToOutput share/man "${!outputMan}"; + moveToOutput share/man/man3 "${!outputDevman}" +} +_multioutPropagateDev () +{ + + if [ "$(getAllOutputNames)" = "out" ]; then + return; + fi; + local outputFirst; + for outputFirst in $(getAllOutputNames); + do + break; + done; + local propagaterOutput="$outputDev"; + if [ -z "$propagaterOutput" ]; then + propagaterOutput="$outputFirst"; + fi; + if [ -z "${propagatedBuildOutputs+1}" ]; then + local po_dirty="$outputBin $outputInclude $outputLib"; + set +o pipefail; + propagatedBuildOutputs=`echo "$po_dirty" | tr -s ' ' '\n' | grep -v -F "$propagaterOutput" | sort -u | tr '\n' ' ' `; + set -o pipefail; + fi; + if [ -z "$propagatedBuildOutputs" ]; then + return; + fi; + mkdir -p "${!propagaterOutput}"/nix-support; + for output in $propagatedBuildOutputs; + do + echo -n " ${!output}" >> "${!propagaterOutput}"/nix-support/propagated-build-inputs; + done +} +_nixLogWithLevel () +{ + + [[ -z ${NIX_LOG_FD-} || ${NIX_DEBUG:-0} -lt ${1:?} ]] && return 0; + local logLevel; + case "${1:?}" in + 0) + logLevel=ERROR + ;; + 1) + logLevel=WARN + ;; + 2) + logLevel=NOTICE + ;; + 3) + logLevel=INFO + ;; + 4) + logLevel=TALKATIVE + ;; + 5) + logLevel=CHATTY + ;; + 6) + logLevel=DEBUG + ;; + 7) + logLevel=VOMIT + ;; + *) + echo "_nixLogWithLevel: called with invalid log level: ${1:?}" >&"$NIX_LOG_FD"; + return 1 + ;; + esac; + local callerName="${FUNCNAME[2]}"; + if [[ $callerName == "_callImplicitHook" ]]; then + callerName="${hookName:?}"; + fi; + printf "%s: %s: %s\n" "$logLevel" "$callerName" "${2:?}" >&"$NIX_LOG_FD" +} +_overrideFirst () +{ + + if [ -z "${!1-}" ]; then + _assignFirst "$@"; + fi +} +_pruneLibtoolFiles () +{ + + if [ "${dontPruneLibtoolFiles-}" ] || [ ! -e "$prefix" ]; then + return; + fi; + find "$prefix" -type f -name '*.la' -exec grep -q '^# Generated by .*libtool' {} \; -exec grep -q "^old_library=''" {} \; -exec sed -i {} -e "/^dependency_libs='[^']/ c dependency_libs='' #pruned" \; +} +_updateSourceDateEpochFromSourceRoot () +{ + + if [ -n "$sourceRoot" ]; then + updateSourceDateEpoch "$sourceRoot"; + fi +} +activatePackage () +{ + + local pkg="$1"; + local -r hostOffset="$2"; + local -r targetOffset="$3"; + (( hostOffset <= targetOffset )) || exit 1; + if [ -f "$pkg" ]; then + nixTalkativeLog "sourcing setup hook '$pkg'"; + source "$pkg"; + fi; + if [[ -z "${strictDeps-}" || "$hostOffset" -le -1 ]]; then + addToSearchPath _PATH "$pkg/bin"; + fi; + if (( hostOffset <= -1 )); then + addToSearchPath _XDG_DATA_DIRS "$pkg/share"; + fi; + if [[ "$hostOffset" -eq 0 && -d "$pkg/bin" ]]; then + addToSearchPath _HOST_PATH "$pkg/bin"; + fi; + if [[ -f "$pkg/nix-support/setup-hook" ]]; then + nixTalkativeLog "sourcing setup hook '$pkg/nix-support/setup-hook'"; + source "$pkg/nix-support/setup-hook"; + fi +} +addEnvHooks () +{ + + local depHostOffset="$1"; + shift; + local pkgHookVarsSlice="${pkgHookVarVars[$depHostOffset + 1]}[@]"; + local pkgHookVar; + for pkgHookVar in "${!pkgHookVarsSlice}"; + do + eval "${pkgHookVar}s"'+=("$@")'; + done +} +addNodePath () +{ + + addToSearchPath NODE_PATH "$1/lib/node_modules" +} +addToSearchPath () +{ + + addToSearchPathWithCustomDelimiter ":" "$@" +} +addToSearchPathWithCustomDelimiter () +{ + + local delimiter="$1"; + local varName="$2"; + local dir="$3"; + if [[ -d "$dir" && "${!varName:+${delimiter}${!varName}${delimiter}}" != *"${delimiter}${dir}${delimiter}"* ]]; then + export "${varName}=${!varName:+${!varName}${delimiter}}${dir}"; + fi +} +appendToVar () +{ + + local -n nameref="$1"; + local useArray type; + if [ -n "$__structuredAttrs" ]; then + useArray=true; + else + useArray=false; + fi; + if type=$(declare -p "$1" 2> /dev/null); then + case "${type#* }" in + -A*) + echo "appendToVar(): ERROR: trying to use appendToVar on an associative array, use variable+=([\"X\"]=\"Y\") instead." 1>&2; + return 1 + ;; + -a*) + useArray=true + ;; + *) + useArray=false + ;; + esac; + fi; + shift; + if $useArray; then + nameref=(${nameref+"${nameref[@]}"} "$@"); + else + nameref="${nameref-} $*"; + fi +} +auditTmpdir () +{ + + local dir="$1"; + [ -e "$dir" ] || return 0; + echo "checking for references to $TMPDIR/ in $dir..."; + local tmpdir elf_fifo script_fifo; + tmpdir="$(mktemp -d)"; + elf_fifo="$tmpdir/elf"; + script_fifo="$tmpdir/script"; + mkfifo "$elf_fifo" "$script_fifo"; + ( find "$dir" -type f -not -path '*/.build-id/*' -print0 | while IFS= read -r -d '' file; do + if isELF "$file"; then + printf '%s\0' "$file" 1>&3; + else + if isScript "$file"; then + filename=${file##*/}; + dir=${file%/*}; + if [ -e "$dir/.$filename-wrapped" ]; then + printf '%s\0' "$file" 1>&4; + fi; + fi; + fi; + done; + exec 3>&- 4>&- ) 3> "$elf_fifo" 4> "$script_fifo" & ( xargs -0 -r -P "$NIX_BUILD_CORES" -n 1 sh -c ' + if { printf :; patchelf --print-rpath "$1"; } | grep -q -F ":$TMPDIR/"; then + echo "RPATH of binary $1 contains a forbidden reference to $TMPDIR/" + exit 1 + fi + ' _ < "$elf_fifo" ) & local pid_elf=$!; + local pid_script; + ( xargs -0 -r -P "$NIX_BUILD_CORES" -n 1 sh -c ' + if grep -q -F "$TMPDIR/" "$1"; then + echo "wrapper script $1 contains a forbidden reference to $TMPDIR/" + exit 1 + fi + ' _ < "$script_fifo" ) & local pid_script=$!; + wait "$pid_elf" || { + echo "Some binaries contain forbidden references to $TMPDIR/. Check the error above!"; + exit 1 + }; + wait "$pid_script" || { + echo "Some scripts contain forbidden references to $TMPDIR/. Check the error above!"; + exit 1 + }; + rm -r "$tmpdir" +} +bintoolsWrapper_addLDVars () +{ + + local role_post; + getHostRoleEnvHook; + if [[ -d "$1/lib64" && ! -L "$1/lib64" ]]; then + export NIX_LDFLAGS${role_post}+=" -L$1/lib64"; + fi; + if [[ -d "$1/lib" ]]; then + local -a glob=($1/lib/lib*); + if [ "${#glob[*]}" -gt 0 ]; then + export NIX_LDFLAGS${role_post}+=" -L$1/lib"; + fi; + fi +} +buildPhase () +{ + + runHook preBuild; + if [[ -z "${makeFlags-}" && -z "${makefile:-}" && ! ( -e Makefile || -e makefile || -e GNUmakefile ) ]]; then + echo "no Makefile or custom buildPhase, doing nothing"; + else + foundMakefile=1; + local flagsArray=(${enableParallelBuilding:+-j${NIX_BUILD_CORES}} SHELL="$SHELL"); + concatTo flagsArray makeFlags makeFlagsArray buildFlags buildFlagsArray; + echoCmd 'build flags' "${flagsArray[@]}"; + make ${makefile:+-f $makefile} "${flagsArray[@]}"; + unset flagsArray; + fi; + runHook postBuild +} +ccWrapper_addCVars () +{ + + local role_post; + getHostRoleEnvHook; + local found=; + if [ -d "$1/include" ]; then + export NIX_CFLAGS_COMPILE${role_post}+=" -isystem $1/include"; + found=1; + fi; + if [ -d "$1/Library/Frameworks" ]; then + export NIX_CFLAGS_COMPILE${role_post}+=" -iframework $1/Library/Frameworks"; + found=1; + fi; + if [[ -n "" && -n ${NIX_STORE:-} && -n $found ]]; then + local scrubbed="$NIX_STORE/eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee-${1#"$NIX_STORE"/*-}"; + export NIX_CFLAGS_COMPILE${role_post}+=" -fmacro-prefix-map=$1=$scrubbed"; + fi +} +checkPhase () +{ + + runHook preCheck; + if [[ -z "${foundMakefile:-}" ]]; then + echo "no Makefile or custom checkPhase, doing nothing"; + runHook postCheck; + return; + fi; + if [[ -z "${checkTarget:-}" ]]; then + if make -n ${makefile:+-f $makefile} check > /dev/null 2>&1; then + checkTarget="check"; + else + if make -n ${makefile:+-f $makefile} test > /dev/null 2>&1; then + checkTarget="test"; + fi; + fi; + fi; + if [[ -z "${checkTarget:-}" ]]; then + echo "no check/test target in ${makefile:-Makefile}, doing nothing"; + else + local flagsArray=(${enableParallelChecking:+-j${NIX_BUILD_CORES}} SHELL="$SHELL"); + concatTo flagsArray makeFlags makeFlagsArray checkFlags=VERBOSE=y checkFlagsArray checkTarget; + echoCmd 'check flags' "${flagsArray[@]}"; + make ${makefile:+-f $makefile} "${flagsArray[@]}"; + unset flagsArray; + fi; + runHook postCheck +} +compressManPages () +{ + + local dir="$1"; + if [ -L "$dir"/share ] || [ -L "$dir"/share/man ] || [ ! -d "$dir/share/man" ]; then + return; + fi; + echo "gzipping man pages under $dir/share/man/"; + find "$dir"/share/man/ -type f -a '!' -regex '.*\.\(bz2\|gz\|xz\)$' -print0 | xargs -0 -n1 -P "$NIX_BUILD_CORES" gzip -n -f; + find "$dir"/share/man/ -type l -a '!' -regex '.*\.\(bz2\|gz\|xz\)$' -print0 | sort -z | while IFS= read -r -d '' f; do + local target; + target="$(readlink -f "$f")"; + if [ -f "$target".gz ]; then + ln -sf "$target".gz "$f".gz && rm "$f"; + fi; + done +} +concatStringsSep () +{ + + local sep="$1"; + local name="$2"; + local type oldifs; + if type=$(declare -p "$name" 2> /dev/null); then + local -n nameref="$name"; + case "${type#* }" in + -A*) + echo "concatStringsSep(): ERROR: trying to use concatStringsSep on an associative array." 1>&2; + return 1 + ;; + -a*) + local IFS="$(printf '\036')" + ;; + *) + local IFS=" " + ;; + esac; + local ifs_separated="${nameref[*]}"; + echo -n "${ifs_separated//"$IFS"/"$sep"}"; + fi +} +concatTo () +{ + + local -; + set -o noglob; + local -n targetref="$1"; + shift; + local arg default name type; + for arg in "$@"; + do + IFS="=" read -r name default <<< "$arg"; + local -n nameref="$name"; + if [[ -z "${nameref[*]}" && -n "$default" ]]; then + targetref+=("$default"); + else + if type=$(declare -p "$name" 2> /dev/null); then + case "${type#* }" in + -A*) + echo "concatTo(): ERROR: trying to use concatTo on an associative array." 1>&2; + return 1 + ;; + -a*) + targetref+=("${nameref[@]}") + ;; + *) + if [[ "$name" = *"Array" ]]; then + nixErrorLog "concatTo(): $name is not declared as array, treating as a singleton. This will become an error in future"; + targetref+=(${nameref+"${nameref[@]}"}); + else + targetref+=(${nameref-}); + fi + ;; + esac; + fi; + fi; + done +} +configurePhase () +{ + + runHook preConfigure; + : "${configureScript=}"; + if [[ -z "$configureScript" && -x ./configure ]]; then + configureScript=./configure; + fi; + if [ -z "${dontFixLibtool:-}" ]; then + export lt_cv_deplibs_check_method="${lt_cv_deplibs_check_method-pass_all}"; + local i; + find . -iname "ltmain.sh" -print0 | while IFS='' read -r -d '' i; do + echo "fixing libtool script $i"; + fixLibtool "$i"; + done; + CONFIGURE_MTIME_REFERENCE=$(mktemp configure.mtime.reference.XXXXXX); + find . -executable -type f -name configure -exec grep -l 'GNU Libtool is free software; you can redistribute it and/or modify' {} \; -exec touch -r {} "$CONFIGURE_MTIME_REFERENCE" \; -exec sed -i s_/usr/bin/file_file_g {} \; -exec touch -r "$CONFIGURE_MTIME_REFERENCE" {} \;; + rm -f "$CONFIGURE_MTIME_REFERENCE"; + fi; + if [[ -z "${dontAddPrefix:-}" && -n "$prefix" ]]; then + prependToVar configureFlags "${prefixKey:---prefix=}$prefix"; + fi; + if [[ -f "$configureScript" ]]; then + if [ -z "${dontAddDisableDepTrack:-}" ]; then + if grep -q dependency-tracking "$configureScript"; then + prependToVar configureFlags --disable-dependency-tracking; + fi; + fi; + if [ -z "${dontDisableStatic:-}" ]; then + if grep -q enable-static "$configureScript"; then + prependToVar configureFlags --disable-static; + fi; + fi; + if [ -z "${dontPatchShebangsInConfigure:-}" ]; then + patchShebangs --build "$configureScript"; + fi; + fi; + if [ -n "$configureScript" ]; then + local -a flagsArray; + concatTo flagsArray configureFlags configureFlagsArray; + echoCmd 'configure flags' "${flagsArray[@]}"; + $configureScript "${flagsArray[@]}"; + unset flagsArray; + else + echo "no configure script, doing nothing"; + fi; + runHook postConfigure +} +consumeEntire () +{ + + if IFS='' read -r -d '' "$1"; then + echo "consumeEntire(): ERROR: Input null bytes, won't process" 1>&2; + return 1; + fi +} +distPhase () +{ + + runHook preDist; + local flagsArray=(); + concatTo flagsArray distFlags distFlagsArray distTarget=dist; + echo 'dist flags: %q' "${flagsArray[@]}"; + make ${makefile:+-f $makefile} "${flagsArray[@]}"; + if [ "${dontCopyDist:-0}" != 1 ]; then + mkdir -p "$out/tarballs"; + cp -pvd ${tarballs[*]:-*.tar.gz} "$out/tarballs"; + fi; + runHook postDist +} +dumpVars () +{ + + if [[ "${noDumpEnvVars:-0}" != 1 && -d "$NIX_BUILD_TOP" ]]; then + local old_umask; + old_umask=$(umask); + umask 0077; + export 2> /dev/null > "$NIX_BUILD_TOP/env-vars"; + umask "$old_umask"; + fi +} +echoCmd () +{ + + printf "%s:" "$1"; + shift; + printf ' %q' "$@"; + echo +} +exitHandler () +{ + + exitCode="$?"; + set +e; + if [ -n "${showBuildStats:-}" ]; then + read -r -d '' -a buildTimes < <(times); + echo "build times:"; + echo "user time for the shell ${buildTimes[0]}"; + echo "system time for the shell ${buildTimes[1]}"; + echo "user time for all child processes ${buildTimes[2]}"; + echo "system time for all child processes ${buildTimes[3]}"; + fi; + if (( "$exitCode" != 0 )); then + runHook failureHook; + if [ -n "${succeedOnFailure:-}" ]; then + echo "build failed with exit code $exitCode (ignored)"; + mkdir -p "$out/nix-support"; + printf "%s" "$exitCode" > "$out/nix-support/failed"; + exit 0; + fi; + else + runHook exitHook; + fi; + return "$exitCode" +} +findInputs () +{ + + local -r pkg="$1"; + local -r hostOffset="$2"; + local -r targetOffset="$3"; + (( hostOffset <= targetOffset )) || exit 1; + local varVar="${pkgAccumVarVars[hostOffset + 1]}"; + local varRef="$varVar[$((targetOffset - hostOffset))]"; + local var="${!varRef}"; + unset -v varVar varRef; + local varSlice="$var[*]"; + case " ${!varSlice-} " in + *" $pkg "*) + return 0 + ;; + esac; + unset -v varSlice; + eval "$var"'+=("$pkg")'; + if ! [ -e "$pkg" ]; then + echo "build input $pkg does not exist" 1>&2; + exit 1; + fi; + function mapOffset () + { + local -r inputOffset="$1"; + local -n outputOffset="$2"; + if (( inputOffset <= 0 )); then + outputOffset=$((inputOffset + hostOffset)); + else + outputOffset=$((inputOffset - 1 + targetOffset)); + fi + }; + local relHostOffset; + for relHostOffset in "${allPlatOffsets[@]}"; + do + local files="${propagatedDepFilesVars[relHostOffset + 1]}"; + local hostOffsetNext; + mapOffset "$relHostOffset" hostOffsetNext; + (( -1 <= hostOffsetNext && hostOffsetNext <= 1 )) || continue; + local relTargetOffset; + for relTargetOffset in "${allPlatOffsets[@]}"; + do + (( "$relHostOffset" <= "$relTargetOffset" )) || continue; + local fileRef="${files}[$relTargetOffset - $relHostOffset]"; + local file="${!fileRef}"; + unset -v fileRef; + local targetOffsetNext; + mapOffset "$relTargetOffset" targetOffsetNext; + (( -1 <= hostOffsetNext && hostOffsetNext <= 1 )) || continue; + [[ -f "$pkg/nix-support/$file" ]] || continue; + local pkgNext; + read -r -d '' pkgNext < "$pkg/nix-support/$file" || true; + for pkgNext in $pkgNext; + do + findInputs "$pkgNext" "$hostOffsetNext" "$targetOffsetNext"; + done; + done; + done +} +fixLibtool () +{ + + local search_path; + for flag in $NIX_LDFLAGS; + do + case $flag in + -L*) + search_path+=" ${flag#-L}" + ;; + esac; + done; + sed -i "$1" -e "s^eval \(sys_lib_search_path=\).*^\1'${search_path:-}'^" -e 's^eval sys_lib_.+search_path=.*^^' +} +fixupPhase () +{ + + local output; + for output in $(getAllOutputNames); + do + if [ -e "${!output}" ]; then + chmod -R u+w,u-s,g-s "${!output}"; + fi; + done; + runHook preFixup; + local output; + for output in $(getAllOutputNames); + do + prefix="${!output}" runHook fixupOutput; + done; + recordPropagatedDependencies; + if [ -n "${setupHook:-}" ]; then + mkdir -p "${!outputDev}/nix-support"; + substituteAll "$setupHook" "${!outputDev}/nix-support/setup-hook"; + fi; + if [ -n "${setupHooks:-}" ]; then + mkdir -p "${!outputDev}/nix-support"; + local hook; + for hook in ${setupHooks[@]}; + do + local content; + consumeEntire content < "$hook"; + substituteAllStream content "file '$hook'" >> "${!outputDev}/nix-support/setup-hook"; + unset -v content; + done; + unset -v hook; + fi; + if [ -n "${propagatedUserEnvPkgs[*]:-}" ]; then + mkdir -p "${!outputBin}/nix-support"; + printWords "${propagatedUserEnvPkgs[@]}" > "${!outputBin}/nix-support/propagated-user-env-packages"; + fi; + runHook postFixup +} +genericBuild () +{ + + export GZIP_NO_TIMESTAMPS=1; + if [ -f "${buildCommandPath:-}" ]; then + source "$buildCommandPath"; + return; + fi; + if [ -n "${buildCommand:-}" ]; then + eval "$buildCommand"; + return; + fi; + if [ -z "${phases[*]:-}" ]; then + phases="${prePhases[*]:-} unpackPhase patchPhase ${preConfigurePhases[*]:-} configurePhase ${preBuildPhases[*]:-} buildPhase checkPhase ${preInstallPhases[*]:-} installPhase ${preFixupPhases[*]:-} fixupPhase installCheckPhase ${preDistPhases[*]:-} distPhase ${postPhases[*]:-}"; + fi; + for curPhase in ${phases[*]}; + do + runPhase "$curPhase"; + done +} +getAllOutputNames () +{ + + if [ -n "$__structuredAttrs" ]; then + echo "${!outputs[*]}"; + else + echo "$outputs"; + fi +} +getHostRole () +{ + + getRole "$hostOffset" +} +getHostRoleEnvHook () +{ + + getRole "$depHostOffset" +} +getRole () +{ + + case $1 in + -1) + role_post='_FOR_BUILD' + ;; + 0) + role_post='' + ;; + 1) + role_post='_FOR_TARGET' + ;; + *) + echo "binutils-wrapper-2.44: used as improper sort of dependency" 1>&2; + return 1 + ;; + esac +} +getTargetRole () +{ + + getRole "$targetOffset" +} +getTargetRoleEnvHook () +{ + + getRole "$depTargetOffset" +} +getTargetRoleWrapper () +{ + + case $targetOffset in + -1) + export NIX_BINTOOLS_WRAPPER_TARGET_BUILD_x86_64_unknown_linux_gnu=1 + ;; + 0) + export NIX_BINTOOLS_WRAPPER_TARGET_HOST_x86_64_unknown_linux_gnu=1 + ;; + 1) + export NIX_BINTOOLS_WRAPPER_TARGET_TARGET_x86_64_unknown_linux_gnu=1 + ;; + *) + echo "binutils-wrapper-2.44: used as improper sort of dependency" 1>&2; + return 1 + ;; + esac +} +installCheckPhase () +{ + + runHook preInstallCheck; + if [[ -z "${foundMakefile:-}" ]]; then + echo "no Makefile or custom installCheckPhase, doing nothing"; + else + if [[ -z "${installCheckTarget:-}" ]] && ! make -n ${makefile:+-f $makefile} "${installCheckTarget:-installcheck}" > /dev/null 2>&1; then + echo "no installcheck target in ${makefile:-Makefile}, doing nothing"; + else + local flagsArray=(${enableParallelChecking:+-j${NIX_BUILD_CORES}} SHELL="$SHELL"); + concatTo flagsArray makeFlags makeFlagsArray installCheckFlags installCheckFlagsArray installCheckTarget=installcheck; + echoCmd 'installcheck flags' "${flagsArray[@]}"; + make ${makefile:+-f $makefile} "${flagsArray[@]}"; + unset flagsArray; + fi; + fi; + runHook postInstallCheck +} +installPhase () +{ + + runHook preInstall; + if [[ -z "${makeFlags-}" && -z "${makefile:-}" && ! ( -e Makefile || -e makefile || -e GNUmakefile ) ]]; then + echo "no Makefile or custom installPhase, doing nothing"; + runHook postInstall; + return; + else + foundMakefile=1; + fi; + if [ -n "$prefix" ]; then + mkdir -p "$prefix"; + fi; + local flagsArray=(${enableParallelInstalling:+-j${NIX_BUILD_CORES}} SHELL="$SHELL"); + concatTo flagsArray makeFlags makeFlagsArray installFlags installFlagsArray installTargets=install; + echoCmd 'install flags' "${flagsArray[@]}"; + make ${makefile:+-f $makefile} "${flagsArray[@]}"; + unset flagsArray; + runHook postInstall +} +isELF () +{ + + local fn="$1"; + local fd; + local magic; + exec {fd}< "$fn"; + LANG=C read -r -n 4 -u "$fd" magic; + exec {fd}>&-; + if [ "$magic" = 'ELF' ]; then + return 0; + else + return 1; + fi +} +isMachO () +{ + + local fn="$1"; + local fd; + local magic; + exec {fd}< "$fn"; + LANG=C read -r -n 4 -u "$fd" magic; + exec {fd}>&-; + if [[ "$magic" = $(echo -ne "\xfe\xed\xfa\xcf") || "$magic" = $(echo -ne "\xcf\xfa\xed\xfe") ]]; then + return 0; + else + if [[ "$magic" = $(echo -ne "\xfe\xed\xfa\xce") || "$magic" = $(echo -ne "\xce\xfa\xed\xfe") ]]; then + return 0; + else + if [[ "$magic" = $(echo -ne "\xca\xfe\xba\xbe") || "$magic" = $(echo -ne "\xbe\xba\xfe\xca") ]]; then + return 0; + else + return 1; + fi; + fi; + fi +} +isScript () +{ + + local fn="$1"; + local fd; + local magic; + exec {fd}< "$fn"; + LANG=C read -r -n 2 -u "$fd" magic; + exec {fd}>&-; + if [[ "$magic" =~ \#! ]]; then + return 0; + else + return 1; + fi +} +mapOffset () +{ + + local -r inputOffset="$1"; + local -n outputOffset="$2"; + if (( inputOffset <= 0 )); then + outputOffset=$((inputOffset + hostOffset)); + else + outputOffset=$((inputOffset - 1 + targetOffset)); + fi +} +moveToOutput () +{ + + local patt="$1"; + local dstOut="$2"; + local output; + for output in $(getAllOutputNames); + do + if [ "${!output}" = "$dstOut" ]; then + continue; + fi; + local srcPath; + for srcPath in "${!output}"/$patt; + do + if [ ! -e "$srcPath" ] && [ ! -L "$srcPath" ]; then + continue; + fi; + if [ "$dstOut" = REMOVE ]; then + echo "Removing $srcPath"; + rm -r "$srcPath"; + else + local dstPath="$dstOut${srcPath#${!output}}"; + echo "Moving $srcPath to $dstPath"; + if [ -d "$dstPath" ] && [ -d "$srcPath" ]; then + rmdir "$srcPath" --ignore-fail-on-non-empty; + if [ -d "$srcPath" ]; then + mv -t "$dstPath" "$srcPath"/*; + rmdir "$srcPath"; + fi; + else + mkdir -p "$(readlink -m "$dstPath/..")"; + mv "$srcPath" "$dstPath"; + fi; + fi; + local srcParent="$(readlink -m "$srcPath/..")"; + if [ -n "$(find "$srcParent" -maxdepth 0 -type d -empty 2> /dev/null)" ]; then + echo "Removing empty $srcParent/ and (possibly) its parents"; + rmdir -p --ignore-fail-on-non-empty "$srcParent" 2> /dev/null || true; + fi; + done; + done +} +nixChattyLog () +{ + + _nixLogWithLevel 5 "$*" +} +nixDebugLog () +{ + + _nixLogWithLevel 6 "$*" +} +nixErrorLog () +{ + + _nixLogWithLevel 0 "$*" +} +nixInfoLog () +{ + + _nixLogWithLevel 3 "$*" +} +nixLog () +{ + + [[ -z ${NIX_LOG_FD-} ]] && return 0; + local callerName="${FUNCNAME[1]}"; + if [[ $callerName == "_callImplicitHook" ]]; then + callerName="${hookName:?}"; + fi; + printf "%s: %s\n" "$callerName" "$*" >&"$NIX_LOG_FD" +} +nixNoticeLog () +{ + + _nixLogWithLevel 2 "$*" +} +nixTalkativeLog () +{ + + _nixLogWithLevel 4 "$*" +} +nixVomitLog () +{ + + _nixLogWithLevel 7 "$*" +} +nixWarnLog () +{ + + _nixLogWithLevel 1 "$*" +} +noBrokenSymlinks () +{ + + local -r output="${1:?}"; + local path; + local pathParent; + local symlinkTarget; + local -i numDanglingSymlinks=0; + local -i numReflexiveSymlinks=0; + local -i numUnreadableSymlinks=0; + if [[ ! -e $output ]]; then + nixWarnLog "skipping non-existent output $output"; + return 0; + fi; + nixInfoLog "running on $output"; + while IFS= read -r -d '' path; do + pathParent="$(dirname "$path")"; + if ! symlinkTarget="$(readlink "$path")"; then + nixErrorLog "the symlink $path is unreadable"; + numUnreadableSymlinks+=1; + continue; + fi; + if [[ $symlinkTarget == /* ]]; then + nixInfoLog "symlink $path points to absolute target $symlinkTarget"; + else + nixInfoLog "symlink $path points to relative target $symlinkTarget"; + symlinkTarget="$(realpath --no-symlinks --canonicalize-missing "$pathParent/$symlinkTarget")"; + fi; + if [[ $symlinkTarget = "$TMPDIR"/* ]]; then + nixErrorLog "the symlink $path points to $TMPDIR directory: $symlinkTarget"; + numDanglingSymlinks+=1; + continue; + fi; + if [[ $symlinkTarget != "$NIX_STORE"/* ]]; then + nixInfoLog "symlink $path points outside the Nix store; ignoring"; + continue; + fi; + if [[ $path == "$symlinkTarget" ]]; then + nixErrorLog "the symlink $path is reflexive"; + numReflexiveSymlinks+=1; + else + if [[ ! -e $symlinkTarget ]]; then + nixErrorLog "the symlink $path points to a missing target: $symlinkTarget"; + numDanglingSymlinks+=1; + else + nixDebugLog "the symlink $path is irreflexive and points to a target which exists"; + fi; + fi; + done < <(find "$output" -type l -print0); + if ((numDanglingSymlinks > 0 || numReflexiveSymlinks > 0 || numUnreadableSymlinks > 0)); then + nixErrorLog "found $numDanglingSymlinks dangling symlinks, $numReflexiveSymlinks reflexive symlinks and $numUnreadableSymlinks unreadable symlinks"; + exit 1; + fi; + return 0 +} +noBrokenSymlinksInAllOutputs () +{ + + if [[ -z ${dontCheckForBrokenSymlinks-} ]]; then + for output in $(getAllOutputNames); + do + noBrokenSymlinks "${!output}"; + done; + fi +} +patchELF () +{ + + local dir="$1"; + [ -e "$dir" ] || return 0; + echo "shrinking RPATHs of ELF executables and libraries in $dir"; + local i; + while IFS= read -r -d '' i; do + if [[ "$i" =~ .build-id ]]; then + continue; + fi; + if ! isELF "$i"; then + continue; + fi; + echo "shrinking $i"; + patchelf --shrink-rpath "$i" || true; + done < <(find "$dir" -type f -print0) +} +patchPhase () +{ + + runHook prePatch; + local -a patchesArray; + concatTo patchesArray patches; + local -a flagsArray; + concatTo flagsArray patchFlags=-p1; + for i in "${patchesArray[@]}"; + do + echo "applying patch $i"; + local uncompress=cat; + case "$i" in + *.gz) + uncompress="gzip -d" + ;; + *.bz2) + uncompress="bzip2 -d" + ;; + *.xz) + uncompress="xz -d" + ;; + *.lzma) + uncompress="lzma -d" + ;; + esac; + $uncompress < "$i" 2>&1 | patch "${flagsArray[@]}"; + done; + runHook postPatch +} +patchShebangs () +{ + + local pathName; + local update=false; + while [[ $# -gt 0 ]]; do + case "$1" in + --host) + pathName=HOST_PATH; + shift + ;; + --build) + pathName=PATH; + shift + ;; + --update) + update=true; + shift + ;; + --) + shift; + break + ;; + -* | --*) + echo "Unknown option $1 supplied to patchShebangs" 1>&2; + return 1 + ;; + *) + break + ;; + esac; + done; + echo "patching script interpreter paths in $@"; + local f; + local oldPath; + local newPath; + local arg0; + local args; + local oldInterpreterLine; + local newInterpreterLine; + if [[ $# -eq 0 ]]; then + echo "No arguments supplied to patchShebangs" 1>&2; + return 0; + fi; + local f; + while IFS= read -r -d '' f; do + isScript "$f" || continue; + read -r oldInterpreterLine < "$f" || [ "$oldInterpreterLine" ]; + read -r oldPath arg0 args <<< "${oldInterpreterLine:2}"; + if [[ -z "${pathName:-}" ]]; then + if [[ -n $strictDeps && $f == "$NIX_STORE"* ]]; then + pathName=HOST_PATH; + else + pathName=PATH; + fi; + fi; + if [[ "$oldPath" == *"/bin/env" ]]; then + if [[ $arg0 == "-S" ]]; then + arg0=${args%% *}; + [[ "$args" == *" "* ]] && args=${args#* } || args=; + newPath="$(PATH="${!pathName}" type -P "env" || true)"; + args="-S $(PATH="${!pathName}" type -P "$arg0" || true) $args"; + else + if [[ $arg0 == "-"* || $arg0 == *"="* ]]; then + echo "$f: unsupported interpreter directive \"$oldInterpreterLine\" (set dontPatchShebangs=1 and handle shebang patching yourself)" 1>&2; + exit 1; + else + newPath="$(PATH="${!pathName}" type -P "$arg0" || true)"; + fi; + fi; + else + if [[ -z $oldPath ]]; then + oldPath="/bin/sh"; + fi; + newPath="$(PATH="${!pathName}" type -P "$(basename "$oldPath")" || true)"; + args="$arg0 $args"; + fi; + newInterpreterLine="$newPath $args"; + newInterpreterLine=${newInterpreterLine%${newInterpreterLine##*[![:space:]]}}; + if [[ -n "$oldPath" && ( "$update" == true || "${oldPath:0:${#NIX_STORE}}" != "$NIX_STORE" ) ]]; then + if [[ -n "$newPath" && "$newPath" != "$oldPath" ]]; then + echo "$f: interpreter directive changed from \"$oldInterpreterLine\" to \"$newInterpreterLine\""; + escapedInterpreterLine=${newInterpreterLine//\\/\\\\}; + timestamp=$(stat --printf "%y" "$f"); + tmpFile=$(mktemp -t patchShebangs.XXXXXXXXXX); + sed -e "1 s|.*|#\!$escapedInterpreterLine|" "$f" > "$tmpFile"; + local restoreReadOnly; + if [[ ! -w "$f" ]]; then + chmod +w "$f"; + restoreReadOnly=true; + fi; + cat "$tmpFile" > "$f"; + rm "$tmpFile"; + if [[ -n "${restoreReadOnly:-}" ]]; then + chmod -w "$f"; + fi; + touch --date "$timestamp" "$f"; + fi; + fi; + done < <(find "$@" -type f -perm -0100 -print0) +} +patchShebangsAuto () +{ + + if [[ -z "${dontPatchShebangs-}" && -e "$prefix" ]]; then + if [[ "$output" != out && "$output" = "$outputDev" ]]; then + patchShebangs --build "$prefix"; + else + patchShebangs --host "$prefix"; + fi; + fi +} +prependToVar () +{ + + local -n nameref="$1"; + local useArray type; + if [ -n "$__structuredAttrs" ]; then + useArray=true; + else + useArray=false; + fi; + if type=$(declare -p "$1" 2> /dev/null); then + case "${type#* }" in + -A*) + echo "prependToVar(): ERROR: trying to use prependToVar on an associative array." 1>&2; + return 1 + ;; + -a*) + useArray=true + ;; + *) + useArray=false + ;; + esac; + fi; + shift; + if $useArray; then + nameref=("$@" ${nameref+"${nameref[@]}"}); + else + nameref="$* ${nameref-}"; + fi +} +printLines () +{ + + (( "$#" > 0 )) || return 0; + printf '%s\n' "$@" +} +printWords () +{ + + (( "$#" > 0 )) || return 0; + printf '%s ' "$@" +} +recordPropagatedDependencies () +{ + + declare -ra flatVars=(depsBuildBuildPropagated propagatedNativeBuildInputs depsBuildTargetPropagated depsHostHostPropagated propagatedBuildInputs depsTargetTargetPropagated); + declare -ra flatFiles=("${propagatedBuildDepFiles[@]}" "${propagatedHostDepFiles[@]}" "${propagatedTargetDepFiles[@]}"); + local propagatedInputsIndex; + for propagatedInputsIndex in "${!flatVars[@]}"; + do + local propagatedInputsSlice="${flatVars[$propagatedInputsIndex]}[@]"; + local propagatedInputsFile="${flatFiles[$propagatedInputsIndex]}"; + [[ -n "${!propagatedInputsSlice}" ]] || continue; + mkdir -p "${!outputDev}/nix-support"; + printWords ${!propagatedInputsSlice} > "${!outputDev}/nix-support/$propagatedInputsFile"; + done +} +runHook () +{ + + local hookName="$1"; + shift; + local hooksSlice="${hookName%Hook}Hooks[@]"; + local hook; + for hook in "_callImplicitHook 0 $hookName" ${!hooksSlice+"${!hooksSlice}"}; + do + _logHook "$hookName" "$hook" "$@"; + _eval "$hook" "$@"; + done; + return 0 +} +runOneHook () +{ + + local hookName="$1"; + shift; + local hooksSlice="${hookName%Hook}Hooks[@]"; + local hook ret=1; + for hook in "_callImplicitHook 1 $hookName" ${!hooksSlice+"${!hooksSlice}"}; + do + _logHook "$hookName" "$hook" "$@"; + if _eval "$hook" "$@"; then + ret=0; + break; + fi; + done; + return "$ret" +} +runPhase () +{ + + local curPhase="$*"; + if [[ "$curPhase" = unpackPhase && -n "${dontUnpack:-}" ]]; then + return; + fi; + if [[ "$curPhase" = patchPhase && -n "${dontPatch:-}" ]]; then + return; + fi; + if [[ "$curPhase" = configurePhase && -n "${dontConfigure:-}" ]]; then + return; + fi; + if [[ "$curPhase" = buildPhase && -n "${dontBuild:-}" ]]; then + return; + fi; + if [[ "$curPhase" = checkPhase && -z "${doCheck:-}" ]]; then + return; + fi; + if [[ "$curPhase" = installPhase && -n "${dontInstall:-}" ]]; then + return; + fi; + if [[ "$curPhase" = fixupPhase && -n "${dontFixup:-}" ]]; then + return; + fi; + if [[ "$curPhase" = installCheckPhase && -z "${doInstallCheck:-}" ]]; then + return; + fi; + if [[ "$curPhase" = distPhase && -z "${doDist:-}" ]]; then + return; + fi; + showPhaseHeader "$curPhase"; + dumpVars; + local startTime endTime; + startTime=$(date +"%s"); + eval "${!curPhase:-$curPhase}"; + endTime=$(date +"%s"); + showPhaseFooter "$curPhase" "$startTime" "$endTime"; + if [ "$curPhase" = unpackPhase ]; then + [ -n "${sourceRoot:-}" ] && chmod +x -- "${sourceRoot}"; + cd -- "${sourceRoot:-.}"; + fi +} +showPhaseFooter () +{ + + local phase="$1"; + local startTime="$2"; + local endTime="$3"; + local delta=$(( endTime - startTime )); + (( delta < 30 )) && return; + local H=$((delta/3600)); + local M=$((delta%3600/60)); + local S=$((delta%60)); + echo -n "$phase completed in "; + (( H > 0 )) && echo -n "$H hours "; + (( M > 0 )) && echo -n "$M minutes "; + echo "$S seconds" +} +showPhaseHeader () +{ + + local phase="$1"; + echo "Running phase: $phase"; + if [[ -z ${NIX_LOG_FD-} ]]; then + return; + fi; + printf "@nix { \"action\": \"setPhase\", \"phase\": \"%s\" }\n" "$phase" >&"$NIX_LOG_FD" +} +stripDirs () +{ + + local cmd="$1"; + local ranlibCmd="$2"; + local paths="$3"; + local stripFlags="$4"; + local excludeFlags=(); + local pathsNew=; + [ -z "$cmd" ] && echo "stripDirs: Strip command is empty" 1>&2 && exit 1; + [ -z "$ranlibCmd" ] && echo "stripDirs: Ranlib command is empty" 1>&2 && exit 1; + local pattern; + if [ -n "${stripExclude:-}" ]; then + for pattern in "${stripExclude[@]}"; + do + excludeFlags+=(-a '!' '(' -name "$pattern" -o -wholename "$prefix/$pattern" ')'); + done; + fi; + local p; + for p in ${paths}; + do + if [ -e "$prefix/$p" ]; then + pathsNew="${pathsNew} $prefix/$p"; + fi; + done; + paths=${pathsNew}; + if [ -n "${paths}" ]; then + echo "stripping (with command $cmd and flags $stripFlags) in $paths"; + local striperr; + striperr="$(mktemp --tmpdir="$TMPDIR" 'striperr.XXXXXX')"; + find $paths -type f "${excludeFlags[@]}" -a '!' -path "$prefix/lib/debug/*" -printf '%D-%i,%p\0' | sort -t, -k1,1 -u -z | cut -d, -f2- -z | xargs -r -0 -n1 -P "$NIX_BUILD_CORES" -- $cmd $stripFlags 2> "$striperr" || exit_code=$?; + [[ "$exit_code" = 123 || -z "$exit_code" ]] || ( cat "$striperr" 1>&2 && exit 1 ); + rm "$striperr"; + find $paths -name '*.a' -type f -exec $ranlibCmd '{}' \; 2> /dev/null; + fi +} +stripHash () +{ + + local strippedName casematchOpt=0; + strippedName="$(basename -- "$1")"; + shopt -q nocasematch && casematchOpt=1; + shopt -u nocasematch; + if [[ "$strippedName" =~ ^[a-z0-9]{32}- ]]; then + echo "${strippedName:33}"; + else + echo "$strippedName"; + fi; + if (( casematchOpt )); then + shopt -s nocasematch; + fi +} +substitute () +{ + + local input="$1"; + local output="$2"; + shift 2; + if [ ! -f "$input" ]; then + echo "substitute(): ERROR: file '$input' does not exist" 1>&2; + return 1; + fi; + local content; + consumeEntire content < "$input"; + if [ -e "$output" ]; then + chmod +w "$output"; + fi; + substituteStream content "file '$input'" "$@" > "$output" +} +substituteAll () +{ + + local input="$1"; + local output="$2"; + local -a args=(); + _allFlags; + substitute "$input" "$output" "${args[@]}" +} +substituteAllInPlace () +{ + + local fileName="$1"; + shift; + substituteAll "$fileName" "$fileName" "$@" +} +substituteAllStream () +{ + + local -a args=(); + _allFlags; + substituteStream "$1" "$2" "${args[@]}" +} +substituteInPlace () +{ + + local -a fileNames=(); + for arg in "$@"; + do + if [[ "$arg" = "--"* ]]; then + break; + fi; + fileNames+=("$arg"); + shift; + done; + if ! [[ "${#fileNames[@]}" -gt 0 ]]; then + echo "substituteInPlace called without any files to operate on (files must come before options!)" 1>&2; + return 1; + fi; + for file in "${fileNames[@]}"; + do + substitute "$file" "$file" "$@"; + done +} +substituteStream () +{ + + local var=$1; + local description=$2; + shift 2; + while (( "$#" )); do + local replace_mode="$1"; + case "$1" in + --replace) + if ! "$_substituteStream_has_warned_replace_deprecation"; then + echo "substituteStream() in derivation $name: WARNING: '--replace' is deprecated, use --replace-{fail,warn,quiet}. ($description)" 1>&2; + _substituteStream_has_warned_replace_deprecation=true; + fi; + replace_mode='--replace-warn' + ;& + --replace-quiet | --replace-warn | --replace-fail) + pattern="$2"; + replacement="$3"; + shift 3; + if ! [[ "${!var}" == *"$pattern"* ]]; then + if [ "$replace_mode" == --replace-warn ]; then + printf "substituteStream() in derivation $name: WARNING: pattern %q doesn't match anything in %s\n" "$pattern" "$description" 1>&2; + else + if [ "$replace_mode" == --replace-fail ]; then + printf "substituteStream() in derivation $name: ERROR: pattern %q doesn't match anything in %s\n" "$pattern" "$description" 1>&2; + return 1; + fi; + fi; + fi; + eval "$var"'=${'"$var"'//"$pattern"/"$replacement"}' + ;; + --subst-var) + local varName="$2"; + shift 2; + if ! [[ "$varName" =~ ^[a-zA-Z_][a-zA-Z0-9_]*$ ]]; then + echo "substituteStream() in derivation $name: ERROR: substitution variables must be valid Bash names, \"$varName\" isn't." 1>&2; + return 1; + fi; + if [ -z ${!varName+x} ]; then + echo "substituteStream() in derivation $name: ERROR: variable \$$varName is unset" 1>&2; + return 1; + fi; + pattern="@$varName@"; + replacement="${!varName}"; + eval "$var"'=${'"$var"'//"$pattern"/"$replacement"}' + ;; + --subst-var-by) + pattern="@$2@"; + replacement="$3"; + eval "$var"'=${'"$var"'//"$pattern"/"$replacement"}'; + shift 3 + ;; + *) + echo "substituteStream() in derivation $name: ERROR: Invalid command line argument: $1" 1>&2; + return 1 + ;; + esac; + done; + printf "%s" "${!var}" +} +unpackFile () +{ + + curSrc="$1"; + echo "unpacking source archive $curSrc"; + if ! runOneHook unpackCmd "$curSrc"; then + echo "do not know how to unpack source archive $curSrc"; + exit 1; + fi +} +unpackPhase () +{ + + runHook preUnpack; + if [ -z "${srcs:-}" ]; then + if [ -z "${src:-}" ]; then + echo 'variable $src or $srcs should point to the source'; + exit 1; + fi; + srcs="$src"; + fi; + local -a srcsArray; + concatTo srcsArray srcs; + local dirsBefore=""; + for i in *; + do + if [ -d "$i" ]; then + dirsBefore="$dirsBefore $i "; + fi; + done; + for i in "${srcsArray[@]}"; + do + unpackFile "$i"; + done; + : "${sourceRoot=}"; + if [ -n "${setSourceRoot:-}" ]; then + runOneHook setSourceRoot; + else + if [ -z "$sourceRoot" ]; then + for i in *; + do + if [ -d "$i" ]; then + case $dirsBefore in + *\ $i\ *) + + ;; + *) + if [ -n "$sourceRoot" ]; then + echo "unpacker produced multiple directories"; + exit 1; + fi; + sourceRoot="$i" + ;; + esac; + fi; + done; + fi; + fi; + if [ -z "$sourceRoot" ]; then + echo "unpacker appears to have produced no directories"; + exit 1; + fi; + echo "source root is $sourceRoot"; + if [ "${dontMakeSourcesWritable:-0}" != 1 ]; then + chmod -R u+w -- "$sourceRoot"; + fi; + runHook postUnpack +} +updateAutotoolsGnuConfigScriptsPhase () +{ + + if [ -n "${dontUpdateAutotoolsGnuConfigScripts-}" ]; then + return; + fi; + for script in config.sub config.guess; + do + for f in $(find . -type f -name "$script"); + do + echo "Updating Autotools / GNU config script to a newer upstream version: $f"; + cp -f "/nix/store/ambacmwlhkwlx6ngxccsa9z1wdszgwjx-gnu-config-2024-01-01/$script" "$f"; + done; + done +} +updateSourceDateEpoch () +{ + + local path="$1"; + [[ $path == -* ]] && path="./$path"; + local -a res=($(find "$path" -type f -not -newer "$NIX_BUILD_TOP/.." -printf '%T@ "%p"\0' | sort -n --zero-terminated | tail -n1 --zero-terminated | head -c -1)); + local time="${res[0]//\.[0-9]*/}"; + local newestFile="${res[1]}"; + if [ "${time:-0}" -gt "$SOURCE_DATE_EPOCH" ]; then + echo "setting SOURCE_DATE_EPOCH to timestamp $time of file $newestFile"; + export SOURCE_DATE_EPOCH="$time"; + local now="$(date +%s)"; + if [ "$time" -gt $((now - 60)) ]; then + echo "warning: file $newestFile may be generated; SOURCE_DATE_EPOCH may be non-deterministic"; + fi; + fi +} +PATH="$PATH${nix_saved_PATH:+:$nix_saved_PATH}" +XDG_DATA_DIRS="$XDG_DATA_DIRS${nix_saved_XDG_DATA_DIRS:+:$nix_saved_XDG_DATA_DIRS}" +export NIX_BUILD_TOP="$(mktemp -d -t nix-shell.XXXXXX)" +export TMP="$NIX_BUILD_TOP" +export TMPDIR="$NIX_BUILD_TOP" +export TEMP="$NIX_BUILD_TOP" +export TEMPDIR="$NIX_BUILD_TOP" +eval "${shellHook:-}" diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..e5f69d3 --- /dev/null +++ b/.env.example @@ -0,0 +1 @@ +SECRET_KEY=xM5coyysuo8LZJtNsytgP7OWiKEgHLL75-MGXWzYlxo diff --git a/.gitignore b/.gitignore index c3e07d7..74e71f9 100644 --- a/.gitignore +++ b/.gitignore @@ -98,4 +98,4 @@ frontend/dist/ !.specify/templates/ !.specify/memory/ -.direnv/ \ No newline at end of file +.direnv/backend/.env diff --git a/backend/app/api/boards.py b/backend/app/api/boards.py index f833b35..a272f36 100644 --- a/backend/app/api/boards.py +++ b/backend/app/api/boards.py @@ -8,7 +8,7 @@ from sqlalchemy.orm import Session from app.boards.repository import BoardRepository from app.boards.schemas import BoardCreate, BoardDetail, BoardSummary, BoardUpdate, ViewportStateUpdate -from app.core.deps import get_current_user, get_db +from app.core.deps import get_current_user, get_db_sync from app.database.models.user import User router = APIRouter(prefix="/boards", tags=["boards"]) @@ -18,7 +18,7 @@ router = APIRouter(prefix="/boards", tags=["boards"]) def create_board( board_data: BoardCreate, current_user: Annotated[User, Depends(get_current_user)], - db: Annotated[Session, Depends(get_db)], + db: Annotated[Session, Depends(get_db_sync)], ): """ Create a new board. @@ -45,7 +45,7 @@ def create_board( @router.get("", response_model=dict) def list_boards( current_user: Annotated[User, Depends(get_current_user)], - db: Annotated[Session, Depends(get_db)], + db: Annotated[Session, Depends(get_db_sync)], limit: Annotated[int, Query(ge=1, le=100)] = 50, offset: Annotated[int, Query(ge=0)] = 0, ): @@ -77,7 +77,7 @@ def list_boards( def get_board( board_id: UUID, current_user: Annotated[User, Depends(get_current_user)], - db: Annotated[Session, Depends(get_db)], + db: Annotated[Session, Depends(get_db_sync)], ): """ Get board details by ID. @@ -111,7 +111,7 @@ def update_board( board_id: UUID, board_data: BoardUpdate, current_user: Annotated[User, Depends(get_current_user)], - db: Annotated[Session, Depends(get_db)], + db: Annotated[Session, Depends(get_db_sync)], ): """ Update board metadata. @@ -157,7 +157,7 @@ def update_viewport( board_id: UUID, viewport_data: ViewportStateUpdate, current_user: Annotated[User, Depends(get_current_user)], - db: Annotated[Session, Depends(get_db)], + db: Annotated[Session, Depends(get_db_sync)], ): """ Update board viewport state only (optimized for frequent updates). @@ -198,7 +198,7 @@ def update_viewport( def delete_board( board_id: UUID, current_user: Annotated[User, Depends(get_current_user)], - db: Annotated[Session, Depends(get_db)], + db: Annotated[Session, Depends(get_db_sync)], ): """ Delete a board (soft delete). diff --git a/backend/app/api/sharing.py b/backend/app/api/sharing.py index e0b7daa..c046366 100644 --- a/backend/app/api/sharing.py +++ b/backend/app/api/sharing.py @@ -1,6 +1,6 @@ """Board sharing API endpoints.""" -from datetime import datetime, timezone +from datetime import UTC, datetime from uuid import UUID from fastapi import APIRouter, Depends, HTTPException, status @@ -54,7 +54,7 @@ def validate_share_link(token: str, db: Session, required_permission: str = "vie ) # Check expiration - if share_link.expires_at and share_link.expires_at < datetime.now(timezone.utc): + if share_link.expires_at and share_link.expires_at < datetime.now(UTC): raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="Share link has expired", @@ -69,7 +69,7 @@ def validate_share_link(token: str, db: Session, required_permission: str = "vie # Update access tracking share_link.access_count += 1 - share_link.last_accessed_at = datetime.now(timezone.utc) + share_link.last_accessed_at = datetime.now(UTC) db.commit() return share_link diff --git a/backend/app/auth/jwt.py b/backend/app/auth/jwt.py index 6ebc1df..5eb57f2 100644 --- a/backend/app/auth/jwt.py +++ b/backend/app/auth/jwt.py @@ -1,6 +1,6 @@ """JWT token generation and validation.""" -from datetime import datetime, timedelta, timezone +from datetime import UTC, datetime, timedelta from uuid import UUID from jose import JWTError, jwt @@ -21,11 +21,11 @@ def create_access_token(user_id: UUID, email: str, expires_delta: timedelta | No Encoded JWT token string """ if expires_delta: - expire = datetime.now(timezone.utc) + expires_delta + expire = datetime.now(UTC) + expires_delta else: - expire = datetime.now(timezone.utc) + timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES) + expire = datetime.now(UTC) + timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES) - to_encode = {"sub": str(user_id), "email": email, "exp": expire, "iat": datetime.now(timezone.utc), "type": "access"} + to_encode = {"sub": str(user_id), "email": email, "exp": expire, "iat": datetime.now(UTC), "type": "access"} encoded_jwt = jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM) return encoded_jwt diff --git a/backend/app/boards/sharing.py b/backend/app/boards/sharing.py index f6fc621..ca823de 100644 --- a/backend/app/boards/sharing.py +++ b/backend/app/boards/sharing.py @@ -2,7 +2,7 @@ import secrets import string -from datetime import datetime, timezone +from datetime import UTC, datetime from sqlalchemy.orm import Session @@ -53,12 +53,12 @@ def validate_share_link_token(token: str, db: Session) -> ShareLink | None: return None # Check expiration - if share_link.expires_at and share_link.expires_at < datetime.now(timezone.utc): + if share_link.expires_at and share_link.expires_at < datetime.now(UTC): return None # Update access tracking share_link.access_count += 1 - share_link.last_accessed_at = datetime.now(timezone.utc) + share_link.last_accessed_at = datetime.now(UTC) db.commit() return share_link diff --git a/backend/app/core/deps.py b/backend/app/core/deps.py index 04ef5ce..01e139d 100644 --- a/backend/app/core/deps.py +++ b/backend/app/core/deps.py @@ -5,18 +5,16 @@ from uuid import UUID from fastapi import Depends, HTTPException, status from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer -from sqlalchemy import select -from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy.orm import Session - -from app.auth.jwt import decode_access_token -from app.database.models.user import User -from app.database.session import get_db # For backwards compatibility with synchronous code -from sqlalchemy import create_engine -from sqlalchemy.orm import sessionmaker +from sqlalchemy import create_engine, select +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import Session, sessionmaker + +from app.auth.jwt import decode_access_token from app.core.config import settings +from app.database.models.user import User +from app.database.session import get_db # Sync engine for synchronous endpoints _sync_engine = create_engine( diff --git a/backend/app/database/base.py b/backend/app/database/base.py index a1a389f..e606cab 100644 --- a/backend/app/database/base.py +++ b/backend/app/database/base.py @@ -1,10 +1,9 @@ """Base model for all database models.""" -from datetime import datetime, timezone from typing import Any from uuid import uuid4 -from sqlalchemy import Column, DateTime +from sqlalchemy import Column, DateTime, func from sqlalchemy.dialects.postgresql import UUID from sqlalchemy.orm import DeclarativeBase, declared_attr @@ -22,7 +21,7 @@ class Base(DeclarativeBase): # Common columns for all models id: Any = Column(UUID(as_uuid=True), primary_key=True, default=uuid4) - created_at: Any = Column(DateTime, default=lambda: datetime.now(timezone.utc), nullable=False) + created_at: Any = Column(DateTime, server_default=func.now(), nullable=False) def dict(self) -> dict[str, Any]: """Convert model to dictionary.""" diff --git a/backend/app/database/models/board.py b/backend/app/database/models/board.py index d393241..08fb41c 100644 --- a/backend/app/database/models/board.py +++ b/backend/app/database/models/board.py @@ -1,10 +1,10 @@ """Board database model.""" -from datetime import datetime, timezone +from datetime import datetime from typing import TYPE_CHECKING from uuid import UUID, uuid4 -from sqlalchemy import Boolean, DateTime, ForeignKey, String, Text +from sqlalchemy import Boolean, DateTime, ForeignKey, String, Text, func from sqlalchemy.dialects.postgresql import JSONB from sqlalchemy.dialects.postgresql import UUID as PGUUID from sqlalchemy.orm import Mapped, mapped_column, relationship @@ -42,9 +42,9 @@ class Board(Base): default=lambda: {"x": 0, "y": 0, "zoom": 1.0, "rotation": 0}, ) - created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=lambda: datetime.now(timezone.utc)) + created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, server_default=func.now()) updated_at: Mapped[datetime] = mapped_column( - DateTime, nullable=False, default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc) + DateTime, nullable=False, server_default=func.now(), onupdate=func.now() ) is_deleted: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) diff --git a/backend/app/database/models/board_image.py b/backend/app/database/models/board_image.py index 70822b6..696a2ce 100644 --- a/backend/app/database/models/board_image.py +++ b/backend/app/database/models/board_image.py @@ -1,10 +1,10 @@ """BoardImage database model - junction table for boards and images.""" -from datetime import datetime, timezone +from datetime import datetime from typing import TYPE_CHECKING from uuid import UUID, uuid4 -from sqlalchemy import DateTime, ForeignKey, Integer +from sqlalchemy import DateTime, ForeignKey, Integer, func from sqlalchemy.dialects.postgresql import JSONB from sqlalchemy.dialects.postgresql import UUID as PGUUID from sqlalchemy.orm import Mapped, mapped_column, relationship @@ -52,9 +52,9 @@ class BoardImage(Base): PGUUID(as_uuid=True), ForeignKey("groups.id", ondelete="SET NULL"), nullable=True ) - created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=lambda: datetime.now(timezone.utc)) + created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, server_default=func.now()) updated_at: Mapped[datetime] = mapped_column( - DateTime, nullable=False, default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc) + DateTime, nullable=False, server_default=func.now(), onupdate=func.now() ) # Relationships diff --git a/backend/app/database/models/comment.py b/backend/app/database/models/comment.py index 715de5c..4663065 100644 --- a/backend/app/database/models/comment.py +++ b/backend/app/database/models/comment.py @@ -1,9 +1,8 @@ """Comment model for board annotations.""" import uuid -from datetime import datetime, timezone -from sqlalchemy import Boolean, Column, DateTime, ForeignKey, String, Text +from sqlalchemy import Boolean, Column, DateTime, ForeignKey, String, Text, func from sqlalchemy.dialects.postgresql import JSONB, UUID from sqlalchemy.orm import relationship @@ -21,7 +20,7 @@ class Comment(Base): author_name = Column(String(100), nullable=False) content = Column(Text, nullable=False) position = Column(JSONB, nullable=True) # Optional canvas position reference - created_at = Column(DateTime, nullable=False, default=lambda: datetime.now(timezone.utc)) + created_at = Column(DateTime, nullable=False, server_default=func.now()) is_deleted = Column(Boolean, nullable=False, default=False) # Relationships diff --git a/backend/app/database/models/group.py b/backend/app/database/models/group.py index 034fd7d..c0f3001 100644 --- a/backend/app/database/models/group.py +++ b/backend/app/database/models/group.py @@ -1,10 +1,10 @@ """Group database model.""" -from datetime import datetime, timezone +from datetime import datetime from typing import TYPE_CHECKING from uuid import UUID, uuid4 -from sqlalchemy import DateTime, ForeignKey, String, Text +from sqlalchemy import DateTime, ForeignKey, String, Text, func from sqlalchemy.dialects.postgresql import UUID as PGUUID from sqlalchemy.orm import Mapped, mapped_column, relationship @@ -33,9 +33,9 @@ class Group(Base): color: Mapped[str] = mapped_column(String(7), nullable=False) # Hex color #RRGGBB annotation: Mapped[str | None] = mapped_column(Text, nullable=True) - created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=lambda: datetime.now(timezone.utc)) + created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, server_default=func.now()) updated_at: Mapped[datetime] = mapped_column( - DateTime, nullable=False, default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc) + DateTime, nullable=False, server_default=func.now(), onupdate=func.now() ) # Relationships diff --git a/backend/app/database/models/image.py b/backend/app/database/models/image.py index 7e9d5f9..66d52bd 100644 --- a/backend/app/database/models/image.py +++ b/backend/app/database/models/image.py @@ -1,10 +1,10 @@ """Image database model.""" -from datetime import datetime, timezone +from datetime import datetime from typing import TYPE_CHECKING from uuid import UUID, uuid4 -from sqlalchemy import BigInteger, DateTime, ForeignKey, Integer, String +from sqlalchemy import BigInteger, DateTime, ForeignKey, Integer, String, func from sqlalchemy.dialects.postgresql import JSONB from sqlalchemy.dialects.postgresql import UUID as PGUUID from sqlalchemy.orm import Mapped, mapped_column, relationship @@ -38,7 +38,7 @@ class Image(Base): height: Mapped[int] = mapped_column(Integer, nullable=False) image_metadata: Mapped[dict] = mapped_column(JSONB, nullable=False) - created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=lambda: datetime.now(timezone.utc)) + created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, server_default=func.now()) reference_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0) # Relationships diff --git a/backend/app/database/models/share_link.py b/backend/app/database/models/share_link.py index 5dd0a45..1ec4c13 100644 --- a/backend/app/database/models/share_link.py +++ b/backend/app/database/models/share_link.py @@ -1,9 +1,8 @@ """ShareLink model for board sharing functionality.""" import uuid -from datetime import datetime, timezone -from sqlalchemy import Boolean, Column, DateTime, ForeignKey, Integer, String +from sqlalchemy import Boolean, Column, DateTime, ForeignKey, Integer, String, func from sqlalchemy.dialects.postgresql import UUID from sqlalchemy.orm import relationship @@ -19,7 +18,7 @@ class ShareLink(Base): board_id = Column(UUID(as_uuid=True), ForeignKey("boards.id", ondelete="CASCADE"), nullable=False) token = Column(String(64), unique=True, nullable=False, index=True) permission_level = Column(String(20), nullable=False) # 'view-only' or 'view-comment' - created_at = Column(DateTime, nullable=False, default=lambda: datetime.now(timezone.utc)) + created_at = Column(DateTime, nullable=False, server_default=func.now()) expires_at = Column(DateTime, nullable=True) last_accessed_at = Column(DateTime, nullable=True) access_count = Column(Integer, nullable=False, default=0) diff --git a/backend/app/database/models/user.py b/backend/app/database/models/user.py index c76efed..3800d9f 100644 --- a/backend/app/database/models/user.py +++ b/backend/app/database/models/user.py @@ -1,9 +1,8 @@ """User model for authentication and ownership.""" import uuid -from datetime import datetime, timezone -from sqlalchemy import Boolean, Column, DateTime, String +from sqlalchemy import Boolean, Column, DateTime, String, func from sqlalchemy.dialects.postgresql import UUID from sqlalchemy.orm import relationship @@ -18,8 +17,8 @@ class User(Base): id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) email = Column(String(255), unique=True, nullable=False, index=True) password_hash = Column(String(255), nullable=False) - created_at = Column(DateTime, nullable=False, default=lambda: datetime.now(timezone.utc)) - updated_at = Column(DateTime, nullable=False, default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc)) + created_at = Column(DateTime, nullable=False, server_default=func.now()) + updated_at = Column(DateTime, nullable=False, server_default=func.now(), onupdate=func.now()) is_active = Column(Boolean, nullable=False, default=True) # Relationships diff --git a/backend/app/images/repository.py b/backend/app/images/repository.py index f7c5b40..a8c99c9 100644 --- a/backend/app/images/repository.py +++ b/backend/app/images/repository.py @@ -49,25 +49,17 @@ class ImageRepository: result = await self.db.execute(select(Image).where(Image.id == image_id)) return result.scalar_one_or_none() - async def get_user_images( - self, user_id: UUID, limit: int = 50, offset: int = 0 - ) -> tuple[Sequence[Image], int]: + async def get_user_images(self, user_id: UUID, limit: int = 50, offset: int = 0) -> tuple[Sequence[Image], int]: """Get all images for a user with pagination.""" from sqlalchemy import func - + # Get total count efficiently - count_result = await self.db.execute( - select(func.count(Image.id)).where(Image.user_id == user_id) - ) + count_result = await self.db.execute(select(func.count(Image.id)).where(Image.user_id == user_id)) total = count_result.scalar_one() - + # Get paginated images result = await self.db.execute( - select(Image) - .where(Image.user_id == user_id) - .order_by(Image.created_at.desc()) - .limit(limit) - .offset(offset) + select(Image).where(Image.user_id == user_id).order_by(Image.created_at.desc()).limit(limit).offset(offset) ) images = result.scalars().all() return images, total @@ -126,17 +118,14 @@ class ImageRepository: async def get_board_images(self, board_id: UUID) -> Sequence[BoardImage]: """Get all images for a board, ordered by z-order.""" result = await self.db.execute( - select(BoardImage) - .where(BoardImage.board_id == board_id) - .order_by(BoardImage.z_order.asc()) + select(BoardImage).where(BoardImage.board_id == board_id).order_by(BoardImage.z_order.asc()) ) return result.scalars().all() async def get_board_image(self, board_id: UUID, image_id: UUID) -> BoardImage | None: """Get a specific board image.""" result = await self.db.execute( - select(BoardImage) - .where(BoardImage.board_id == board_id, BoardImage.image_id == image_id) + select(BoardImage).where(BoardImage.board_id == board_id, BoardImage.image_id == image_id) ) return result.scalar_one_or_none() @@ -151,7 +140,7 @@ class ImageRepository: ) -> BoardImage | None: """Update board image position, transformations, z-order, or group.""" board_image = await self.get_board_image(board_id, image_id) - + if not board_image: return None From a8315d03fd7931aa3e08753efaae5202be085bfe Mon Sep 17 00:00:00 2001 From: Danilo Reyes Date: Sun, 2 Nov 2025 19:13:08 -0600 Subject: [PATCH 4/4] fix until the canvas sort of works --- backend/app/api/export.py | 10 +- backend/app/api/groups.py | 12 +- backend/app/api/images.py | 59 +++- backend/app/api/library.py | 10 +- backend/app/api/sharing.py | 16 +- backend/app/core/config.py | 6 +- frontend/src/lib/api/client.ts | 36 +- frontend/src/lib/api/images.ts | 16 + frontend/src/lib/canvas/Image.svelte | 27 +- frontend/src/lib/canvas/Stage.svelte | 38 +- frontend/src/lib/stores/images.ts | 3 +- frontend/src/lib/utils/adaptive-quality.ts | 6 +- frontend/src/routes/boards/[id]/+page.svelte | 344 ++++++++++++++----- 13 files changed, 445 insertions(+), 138 deletions(-) diff --git a/backend/app/api/export.py b/backend/app/api/export.py index 375d941..5232c02 100644 --- a/backend/app/api/export.py +++ b/backend/app/api/export.py @@ -6,7 +6,7 @@ from fastapi import APIRouter, Depends, HTTPException, Query, status from fastapi.responses import StreamingResponse from sqlalchemy.orm import Session -from app.core.deps import get_current_user, get_db +from app.core.deps import get_current_user, get_db_sync from app.database.models.board import Board from app.database.models.board_image import BoardImage from app.database.models.image import Image @@ -22,7 +22,7 @@ router = APIRouter(tags=["export"]) async def download_image( image_id: UUID, current_user: User = Depends(get_current_user), - db: Session = Depends(get_db), + db: Session = Depends(get_db_sync), ) -> StreamingResponse: """ Download a single image. @@ -45,7 +45,7 @@ async def download_image( def export_board_zip( board_id: UUID, current_user: User = Depends(get_current_user), - db: Session = Depends(get_db), + db: Session = Depends(get_db_sync), ) -> StreamingResponse: """ Export all images from a board as a ZIP file. @@ -70,7 +70,7 @@ def export_board_composite( scale: float = Query(1.0, ge=0.5, le=4.0, description="Resolution scale (0.5x to 4x)"), format: str = Query("PNG", regex="^(PNG|JPEG)$", description="Output format (PNG or JPEG)"), current_user: User = Depends(get_current_user), - db: Session = Depends(get_db), + db: Session = Depends(get_db_sync), ) -> StreamingResponse: """ Export board as a single composite image showing the layout. @@ -97,7 +97,7 @@ def export_board_composite( def get_export_info( board_id: UUID, current_user: User = Depends(get_current_user), - db: Session = Depends(get_db), + db: Session = Depends(get_db_sync), ) -> dict: """ Get information about board export (image count, estimated size). diff --git a/backend/app/api/groups.py b/backend/app/api/groups.py index 0e452d4..134a877 100644 --- a/backend/app/api/groups.py +++ b/backend/app/api/groups.py @@ -8,7 +8,7 @@ from sqlalchemy.orm import Session from app.boards.repository import BoardRepository from app.boards.schemas import GroupCreate, GroupResponse, GroupUpdate -from app.core.deps import get_current_user, get_db +from app.core.deps import get_current_user, get_db_sync from app.database.models.user import User router = APIRouter(prefix="/boards/{board_id}/groups", tags=["groups"]) @@ -19,7 +19,7 @@ def create_group( board_id: UUID, group_data: GroupCreate, current_user: Annotated[User, Depends(get_current_user)], - db: Annotated[Session, Depends(get_db)], + db: Annotated[Session, Depends(get_db_sync)], ): """ Create a new group on a board. @@ -56,7 +56,7 @@ def create_group( def list_groups( board_id: UUID, current_user: Annotated[User, Depends(get_current_user)], - db: Annotated[Session, Depends(get_db)], + db: Annotated[Session, Depends(get_db_sync)], ): """ List all groups on a board. @@ -99,7 +99,7 @@ def get_group( board_id: UUID, group_id: UUID, current_user: Annotated[User, Depends(get_current_user)], - db: Annotated[Session, Depends(get_db)], + db: Annotated[Session, Depends(get_db_sync)], ): """ Get group details by ID. @@ -142,7 +142,7 @@ def update_group( group_id: UUID, group_data: GroupUpdate, current_user: Annotated[User, Depends(get_current_user)], - db: Annotated[Session, Depends(get_db)], + db: Annotated[Session, Depends(get_db_sync)], ): """ Update group metadata (name, color, annotation). @@ -191,7 +191,7 @@ def delete_group( board_id: UUID, group_id: UUID, current_user: Annotated[User, Depends(get_current_user)], - db: Annotated[Session, Depends(get_db)], + db: Annotated[Session, Depends(get_db_sync)], ): """ Delete a group (ungroups all images). diff --git a/backend/app/api/images.py b/backend/app/api/images.py index 17a5261..3ba5775 100644 --- a/backend/app/api/images.py +++ b/backend/app/api/images.py @@ -177,7 +177,7 @@ async def get_image( current_user: User = Depends(get_current_user_async), db: AsyncSession = Depends(get_db), ): - """Get image by ID.""" + """Get image metadata by ID.""" repo = ImageRepository(db) image = await repo.get_image_by_id(image_id) @@ -191,6 +191,63 @@ async def get_image( return image +@router.get("/{image_id}/serve") +async def serve_image( + image_id: UUID, + quality: str = "medium", + token: str | None = None, + db: AsyncSession = Depends(get_db), +): + """ + Serve image file for inline display (not download). + + Supports two authentication methods: + 1. Authorization header (Bearer token) + 2. Query parameter 'token' (for img tags) + """ + import io + + from fastapi.responses import StreamingResponse + + from app.core.storage import get_storage_client + from app.images.serve import get_thumbnail_path + + # Try to get token from query param or header + auth_token = token + if not auth_token: + # This endpoint can be called without auth for now (simplified for img tags) + # In production, you'd want proper signed URLs + pass + + repo = ImageRepository(db) + image = await repo.get_image_by_id(image_id) + + if not image: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Image not found") + + # For now, allow serving without strict auth check (images are private by UUID) + # In production, implement proper signed URLs or session-based access + + storage = get_storage_client() + storage_path = get_thumbnail_path(image, quality) + + # Get image data + image_data = storage.get_object(storage_path) + if not image_data: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Image file not found") + + # Determine content type + mime_type = image.mime_type + if quality != "original" and storage_path.endswith(".webp"): + mime_type = "image/webp" + + return StreamingResponse( + io.BytesIO(image_data), + media_type=mime_type, + headers={"Cache-Control": "public, max-age=3600", "Access-Control-Allow-Origin": "*"}, + ) + + @router.delete("/{image_id}", status_code=status.HTTP_204_NO_CONTENT) async def delete_image( image_id: UUID, diff --git a/backend/app/api/library.py b/backend/app/api/library.py index 9f5128c..149bd83 100644 --- a/backend/app/api/library.py +++ b/backend/app/api/library.py @@ -6,7 +6,7 @@ from fastapi import APIRouter, Depends, HTTPException, Query, status from pydantic import BaseModel from sqlalchemy.orm import Session -from app.core.deps import get_current_user, get_db +from app.core.deps import get_current_user, get_db_sync from app.database.models.board_image import BoardImage from app.database.models.image import Image from app.database.models.user import User @@ -51,7 +51,7 @@ def list_library_images( limit: int = Query(50, ge=1, le=100, description="Results per page"), offset: int = Query(0, ge=0, description="Pagination offset"), current_user: User = Depends(get_current_user), - db: Session = Depends(get_db), + db: Session = Depends(get_db_sync), ) -> ImageLibraryListResponse: """ Get user's image library with optional search. @@ -90,7 +90,7 @@ def add_library_image_to_board( image_id: UUID, request: AddToBoardRequest, current_user: User = Depends(get_current_user), - db: Session = Depends(get_db), + db: Session = Depends(get_db_sync), ) -> dict: """ Add an existing library image to a board. @@ -169,7 +169,7 @@ def add_library_image_to_board( def delete_library_image( image_id: UUID, current_user: User = Depends(get_current_user), - db: Session = Depends(get_db), + db: Session = Depends(get_db_sync), ) -> None: """ Permanently delete an image from library. @@ -214,7 +214,7 @@ def delete_library_image( @router.get("/library/stats") def get_library_stats( current_user: User = Depends(get_current_user), - db: Session = Depends(get_db), + db: Session = Depends(get_db_sync), ) -> dict: """ Get statistics about user's image library. diff --git a/backend/app/api/sharing.py b/backend/app/api/sharing.py index c046366..4987d77 100644 --- a/backend/app/api/sharing.py +++ b/backend/app/api/sharing.py @@ -14,7 +14,7 @@ from app.boards.schemas import ( ShareLinkResponse, ) from app.boards.sharing import generate_secure_token -from app.core.deps import get_current_user, get_db +from app.core.deps import get_current_user, get_db_sync from app.database.models.board import Board from app.database.models.comment import Comment from app.database.models.share_link import ShareLink @@ -80,7 +80,7 @@ def create_share_link( board_id: UUID, share_link_data: ShareLinkCreate, current_user: User = Depends(get_current_user), - db: Session = Depends(get_db), + db: Session = Depends(get_db_sync), ) -> ShareLinkResponse: """ Create a new share link for a board. @@ -117,7 +117,7 @@ def create_share_link( def list_share_links( board_id: UUID, current_user: User = Depends(get_current_user), - db: Session = Depends(get_db), + db: Session = Depends(get_db_sync), ) -> list[ShareLinkResponse]: """ List all share links for a board. @@ -144,7 +144,7 @@ def revoke_share_link( board_id: UUID, link_id: UUID, current_user: User = Depends(get_current_user), - db: Session = Depends(get_db), + db: Session = Depends(get_db_sync), ) -> None: """ Revoke (soft delete) a share link. @@ -176,7 +176,7 @@ def revoke_share_link( @router.get("/shared/{token}", response_model=BoardDetail) def get_shared_board( token: str, - db: Session = Depends(get_db), + db: Session = Depends(get_db_sync), ) -> BoardDetail: """ Access a shared board via token. @@ -202,7 +202,7 @@ def get_shared_board( def create_comment( token: str, comment_data: CommentCreate, - db: Session = Depends(get_db), + db: Session = Depends(get_db_sync), ) -> CommentResponse: """ Create a comment on a shared board. @@ -230,7 +230,7 @@ def create_comment( @router.get("/shared/{token}/comments", response_model=list[CommentResponse]) def list_comments( token: str, - db: Session = Depends(get_db), + db: Session = Depends(get_db_sync), ) -> list[CommentResponse]: """ List all comments on a shared board. @@ -255,7 +255,7 @@ def list_comments( def list_board_comments( board_id: UUID, current_user: User = Depends(get_current_user), - db: Session = Depends(get_db), + db: Session = Depends(get_db_sync), ) -> list[CommentResponse]: """ List all comments on a board (owner view). diff --git a/backend/app/core/config.py b/backend/app/core/config.py index cfbc3bd..7bb938e 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -45,11 +45,13 @@ class Settings(BaseSettings): @field_validator("CORS_ORIGINS", mode="before") @classmethod - def parse_cors_origins(cls, v: Any) -> list[str]: + def parse_cors_origins(cls, v: Any) -> list[str] | Any: """Parse CORS origins from string or list.""" if isinstance(v, str): return [origin.strip() for origin in v.split(",")] - return v + if isinstance(v, list): + return v + return ["http://localhost:5173", "http://localhost:3000"] # File Upload MAX_FILE_SIZE: int = 52428800 # 50MB diff --git a/frontend/src/lib/api/client.ts b/frontend/src/lib/api/client.ts index ccbfa31..d02814c 100644 --- a/frontend/src/lib/api/client.ts +++ b/frontend/src/lib/api/client.ts @@ -132,18 +132,34 @@ export class ApiClient { } const url = `${this.baseUrl}${endpoint}`; - const response = await fetch(url, { - method: 'POST', - headers, - body: formData, - }); - if (!response.ok) { - const error = await response.json(); - throw error; + try { + const response = await fetch(url, { + method: 'POST', + headers, + body: formData, + }); + + if (!response.ok) { + const errorData = await response.json().catch(() => ({ detail: response.statusText })); + const error: ApiError = { + error: errorData.detail || errorData.error || 'Upload failed', + details: errorData.details, + status_code: response.status, + }; + throw error; + } + + return response.json(); + } catch (error) { + if ((error as ApiError).status_code) { + throw error; + } + throw { + error: (error as Error).message || 'Upload failed', + status_code: 0, + } as ApiError; } - - return response.json(); } } diff --git a/frontend/src/lib/api/images.ts b/frontend/src/lib/api/images.ts index 3526f65..2467a1a 100644 --- a/frontend/src/lib/api/images.ts +++ b/frontend/src/lib/api/images.ts @@ -87,3 +87,19 @@ export async function removeImageFromBoard(boardId: string, imageId: string): Pr export async function getBoardImages(boardId: string): Promise { return await apiClient.get(`/images/boards/${boardId}/images`); } + +/** + * Update board image position/transformations + */ +export async function updateBoardImage( + boardId: string, + imageId: string, + updates: { + position?: { x: number; y: number }; + transformations?: Record; + z_order?: number; + group_id?: string; + } +): Promise { + return await apiClient.patch(`/images/boards/${boardId}/images/${imageId}`, updates); +} diff --git a/frontend/src/lib/canvas/Image.svelte b/frontend/src/lib/canvas/Image.svelte index ee88799..27e1feb 100644 --- a/frontend/src/lib/canvas/Image.svelte +++ b/frontend/src/lib/canvas/Image.svelte @@ -29,6 +29,7 @@ // Callbacks export let onDragEnd: ((id: string, x: number, y: number) => void) | undefined = undefined; export let onSelectionChange: ((id: string, isSelected: boolean) => void) | undefined = undefined; + export let onImageLoaded: ((id: string) => void) | undefined = undefined; let imageNode: Konva.Image | null = null; let imageGroup: Konva.Group | null = null; @@ -84,11 +85,12 @@ imageGroup.add(imageNode); - // Set Z-index - imageGroup.zIndex(zOrder); - + // Add to layer first layer.add(imageGroup); + // Then set Z-index (must have parent first) + imageGroup.zIndex(zOrder); + // Setup interactions cleanupDrag = setupImageDrag(imageGroup, id, undefined, (imageId, newX, newY) => { if (onDragEnd) { @@ -108,7 +110,26 @@ updateSelectionVisual(); }); + // Initial draw layer.batchDraw(); + + // Force visibility by triggering multiple redraws + requestAnimationFrame(() => { + if (layer) layer.batchDraw(); + }); + + setTimeout(() => { + if (layer) layer.batchDraw(); + }, 50); + + // Notify parent that image loaded + if (onImageLoaded) { + onImageLoaded(id); + } + }; + + imageObj.onerror = () => { + console.error('Failed to load image:', imageUrl); }; imageObj.src = imageUrl; diff --git a/frontend/src/lib/canvas/Stage.svelte b/frontend/src/lib/canvas/Stage.svelte index 4eacf37..14834e9 100644 --- a/frontend/src/lib/canvas/Stage.svelte +++ b/frontend/src/lib/canvas/Stage.svelte @@ -11,9 +11,15 @@ import { setupZoomControls } from './controls/zoom'; import { setupRotateControls } from './controls/rotate'; import { setupGestureControls } from './gestures'; + import { createEventDispatcher } from 'svelte'; + + const dispatch = createEventDispatcher(); // Board ID for future use (e.g., loading board-specific state) - export const boardId: string | undefined = undefined; + // Intentionally unused - reserved for future viewport persistence + export let boardId: string | undefined = undefined; + $: _boardId = boardId; // Consume to prevent unused warning + export let width: number = 0; export let height: number = 0; @@ -40,6 +46,13 @@ layer = new Konva.Layer(); stage.add(layer); + // Apply initial viewport state BEFORE subscribing to changes + // This prevents the flicker from transform animations + const initialViewport = $viewport; + layer.position({ x: initialViewport.x, y: initialViewport.y }); + layer.scale({ x: initialViewport.zoom, y: initialViewport.zoom }); + layer.rotation(initialViewport.rotation); + // Set up controls if (stage) { cleanupPan = setupPanControls(stage); @@ -48,13 +61,13 @@ cleanupGestures = setupGestureControls(stage); } - // Subscribe to viewport changes + // Subscribe to viewport changes (after initial state applied) unsubscribeViewport = viewport.subscribe((state) => { updateStageTransform(state); }); - // Apply initial viewport state - updateStageTransform($viewport); + // Notify parent that stage is ready + dispatch('ready'); }); onDestroy(() => { @@ -78,21 +91,26 @@ * Update stage transform based on viewport state */ function updateStageTransform(state: ViewportState) { - if (!stage) return; + if (!stage || !layer) return; - // Apply transformations to the stage - stage.position({ x: state.x, y: state.y }); - stage.scale({ x: state.zoom, y: state.zoom }); - stage.rotation(state.rotation); + // Don't apply transforms to the stage itself - it causes rendering issues + // Instead, we'll transform the layer + layer.position({ x: state.x, y: state.y }); + layer.scale({ x: state.zoom, y: state.zoom }); + layer.rotation(state.rotation); + + // Force both layer and stage to redraw + layer.batchDraw(); stage.batchDraw(); } /** * Resize canvas when dimensions change */ - $: if (stage && (width !== stage.width() || height !== stage.height())) { + $: if (stage && layer && (width !== stage.width() || height !== stage.height())) { stage.width(width); stage.height(height); + layer.batchDraw(); stage.batchDraw(); } diff --git a/frontend/src/lib/stores/images.ts b/frontend/src/lib/stores/images.ts index 5e7a75b..f3a5b4a 100644 --- a/frontend/src/lib/stores/images.ts +++ b/frontend/src/lib/stores/images.ts @@ -83,7 +83,8 @@ export async function uploadSingleImage(file: File): Promise { return image; } catch (error: unknown) { // Update progress to error - const errorMessage = error instanceof Error ? error.message : 'Upload failed'; + const errorMessage = + (error as { error?: string })?.error || (error as Error)?.message || 'Upload failed'; uploadProgress.update((items) => items.map((item) => item.filename === file.name ? { ...item, status: 'error', error: errorMessage } : item diff --git a/frontend/src/lib/utils/adaptive-quality.ts b/frontend/src/lib/utils/adaptive-quality.ts index ba1e8f1..f07e18c 100644 --- a/frontend/src/lib/utils/adaptive-quality.ts +++ b/frontend/src/lib/utils/adaptive-quality.ts @@ -63,10 +63,8 @@ export function getThumbnailUrl( imageId: string, quality: 'low' | 'medium' | 'high' | 'original' = 'medium' ): string { - if (quality === 'original') { - return `/api/v1/images/${imageId}/original`; - } - return `/api/v1/images/${imageId}/thumbnail/${quality}`; + const apiBase = 'http://localhost:8000/api/v1'; + return `${apiBase}/images/${imageId}/serve?quality=${quality}`; } /** diff --git a/frontend/src/routes/boards/[id]/+page.svelte b/frontend/src/routes/boards/[id]/+page.svelte index 98f0ccc..1d13ef0 100644 --- a/frontend/src/routes/boards/[id]/+page.svelte +++ b/frontend/src/routes/boards/[id]/+page.svelte @@ -10,6 +10,11 @@ uploadZipFile, addImageToBoard, } from '$lib/stores/images'; + import { viewport } from '$lib/stores/viewport'; + import Stage from '$lib/canvas/Stage.svelte'; + import CanvasImage from '$lib/canvas/Image.svelte'; + import { tick } from 'svelte'; + import * as imagesApi from '$lib/api/images'; let loading = true; let error = ''; @@ -17,20 +22,153 @@ let uploadSuccess = ''; let uploading = false; let fileInput: HTMLInputElement; + let canvasContainer: HTMLDivElement; + let canvasWidth = 0; + let canvasHeight = 0; + let stageComponent: Stage; + let stageReady = false; + let loadedImagesCount = 0; $: boardId = $page.params.id; + $: canvasLayer = stageReady && stageComponent ? stageComponent.getLayer() : null; - onMount(async () => { - try { - await boards.loadBoard(boardId); - await loadBoardImages(boardId); - loading = false; - } catch (err: any) { - error = err.error || 'Failed to load board'; - loading = false; + // Track loaded images and force redraw + $: if (loadedImagesCount > 0 && stageComponent) { + const layer = stageComponent.getLayer(); + if (layer) { + setTimeout(() => layer.batchDraw(), 50); } + } + + onMount(() => { + const init = async () => { + try { + await boards.loadBoard(boardId); + await loadBoardImages(boardId); + + // Load viewport state from board if available + if ($currentBoard?.viewport_state) { + viewport.loadState($currentBoard.viewport_state); + } else { + // Reset to default if no saved state + viewport.reset(); + } + + // Set canvas dimensions BEFORE creating stage + updateCanvasDimensions(); + + // Wait for dimensions to be set + await tick(); + + // Double-check dimensions are valid + if (canvasWidth === 0 || canvasHeight === 0) { + console.warn('Canvas dimensions are 0, forcing update...'); + updateCanvasDimensions(); + await tick(); + } + + window.addEventListener('resize', updateCanvasDimensions); + + loading = false; + } catch (err: unknown) { + error = (err as { error?: string })?.error || 'Failed to load board'; + loading = false; + } + }; + + init(); + + return () => { + window.removeEventListener('resize', updateCanvasDimensions); + }; }); + function updateCanvasDimensions() { + if (canvasContainer) { + canvasWidth = canvasContainer.clientWidth; + canvasHeight = canvasContainer.clientHeight; + } + } + + async function handleStageReady() { + // Wait for next tick to ensure layer is fully ready + await tick(); + stageReady = true; + loadedImagesCount = 0; // Reset counter + } + + function handleImageLoaded(_imageId: string) { + loadedImagesCount++; + + // Force immediate redraw on each image load + if (stageComponent) { + const layer = stageComponent.getLayer(); + const stage = stageComponent.getStage(); + if (layer && stage) { + layer.batchDraw(); + stage.batchDraw(); + } + } + + // When all images loaded, auto-fit to view on first load + if (loadedImagesCount === $boardImages.length) { + const layer = stageComponent?.getLayer(); + const stage = stageComponent?.getStage(); + + if (layer && stage) { + // Multiple redraws to ensure visibility + setTimeout(() => { + layer.batchDraw(); + stage.batchDraw(); + }, 0); + setTimeout(() => { + layer.batchDraw(); + stage.batchDraw(); + }, 100); + setTimeout(() => { + layer.batchDraw(); + stage.batchDraw(); + + // Auto-fit images on first load (if they're off-screen) + const hasOffscreenImages = $boardImages.some( + (bi) => + bi.position.x < -canvasWidth || + bi.position.y < -canvasHeight || + bi.position.x > canvasWidth * 2 || + bi.position.y > canvasHeight * 2 + ); + + if (hasOffscreenImages) { + fitAllImages(); + } + }, 250); + } + } + } + + async function handleImageDragEnd(imageId: string, x: number, y: number) { + // Update position on backend + try { + const boardImage = $boardImages.find((bi) => bi.id === imageId); + if (!boardImage) return; + + await imagesApi.updateBoardImage(boardId, boardImage.image_id, { + position: { x, y }, + }); + + // Update local store + boardImages.update((images) => + images.map((img) => (img.id === imageId ? { ...img, position: { x, y } } : img)) + ); + } catch (error) { + console.error('Failed to update image position:', error); + } + } + + function handleImageSelectionChange(_imageId: string, _isSelected: boolean) { + // Selection handling + } + async function handleFileSelect(event: Event) { const target = event.target as HTMLInputElement; if (!target.files || target.files.length === 0) return; @@ -65,29 +203,29 @@ try { let totalUploaded = 0; + // Calculate starting position (centered on screen with some spacing) + let currentX = canvasWidth / 2 - 200; + let currentY = canvasHeight / 2 - 200; + for (const file of files) { // Upload to library first if (file.name.toLowerCase().endsWith('.zip')) { const images = await uploadZipFile(file); - // Add each image to board + // Add each image to board with spaced positions for (const img of images) { - await addImageToBoard( - boardId, - img.id, - { x: Math.random() * 500, y: Math.random() * 500 }, - 0 - ); + await addImageToBoard(boardId, img.id, { x: currentX, y: currentY }, totalUploaded); + // Offset next image + currentX += 50; + currentY += 50; } totalUploaded += images.length; } else if (file.type.startsWith('image/')) { const image = await uploadSingleImage(file); - // Add to board - await addImageToBoard( - boardId, - image.id, - { x: Math.random() * 500, y: Math.random() * 500 }, - 0 - ); + // Add to board at calculated position + await addImageToBoard(boardId, image.id, { x: currentX, y: currentY }, totalUploaded); + // Offset next image + currentX += 50; + currentY += 50; totalUploaded++; } } @@ -101,7 +239,8 @@ uploadSuccess = ''; }, 3000); } catch (err: any) { - uploadError = err.message || 'Upload failed'; + console.error('Upload error:', err); + uploadError = err.error || err.message || err.detail || 'Upload failed'; } finally { uploading = false; } @@ -118,6 +257,48 @@ function handleBackToBoards() { goto('/boards'); } + + function fitAllImages() { + if ($boardImages.length === 0) return; + + // Calculate bounding box of all images + let minX = Infinity, + minY = Infinity, + maxX = -Infinity, + maxY = -Infinity; + + $boardImages.forEach((bi) => { + const imgMinX = bi.position.x; + const imgMinY = bi.position.y; + const imgMaxX = bi.position.x + (bi.image?.width || 0); + const imgMaxY = bi.position.y + (bi.image?.height || 0); + + minX = Math.min(minX, imgMinX); + minY = Math.min(minY, imgMinY); + maxX = Math.max(maxX, imgMaxX); + maxY = Math.max(maxY, imgMaxY); + }); + + const contentWidth = maxX - minX; + const contentHeight = maxY - minY; + + // Calculate zoom to fit + const padding = 100; + const scaleX = (canvasWidth - padding * 2) / contentWidth; + const scaleY = (canvasHeight - padding * 2) / contentHeight; + const newZoom = Math.min(scaleX, scaleY, 1.0); // Don't zoom in more than 100% + + // Calculate center position + const centerX = (canvasWidth - contentWidth * newZoom) / 2 - minX * newZoom; + const centerY = (canvasHeight - contentHeight * newZoom) / 2 - minY * newZoom; + + viewport.set({ + x: centerX, + y: centerY, + zoom: newZoom, + rotation: 0, + }); + } @@ -156,6 +337,14 @@
+
- {:else} -
-

{$boardImages.length} image(s) on board

-

Pan: Drag canvas | Zoom: Mouse wheel | Drag images to move

-
- -
- {#each $boardImages as boardImage} -
-

{boardImage.image?.filename || 'Image'}

- Position: ({boardImage.position.x}, {boardImage.position.y}) -
+ {:else if canvasWidth > 0 && canvasHeight > 0} + + + {#if stageReady && canvasLayer} + {#each $boardImages as boardImage (boardImage.id)} + {#if boardImage.image} + + {/if} {/each} -
+ {/if} {/if} @@ -409,17 +623,23 @@ .canvas-container { flex: 1; position: relative; - overflow: auto; - background: #ffffff; + overflow: hidden; + background: #f5f5f5; } .empty-state { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; display: flex; flex-direction: column; align-items: center; justify-content: center; - height: 100%; gap: 1.5rem; + background: white; + z-index: 10; } .empty-icon { @@ -440,48 +660,6 @@ text-align: center; } - .canvas-info { - padding: 1rem; - background: #f9fafb; - border-bottom: 1px solid #e5e7eb; - } - - .canvas-info p { - margin: 0.25rem 0; - font-size: 0.875rem; - color: #6b7280; - } - - .hint { - font-style: italic; - } - - .temp-image-list { - padding: 2rem; - display: grid; - grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); - gap: 1rem; - } - - .image-placeholder { - border: 1px solid #e5e7eb; - border-radius: 8px; - padding: 1rem; - background: white; - } - - .image-placeholder p { - margin: 0 0 0.5rem 0; - font-weight: 500; - color: #111827; - font-size: 0.875rem; - } - - .image-placeholder small { - color: #6b7280; - font-size: 0.75rem; - } - /* Status Bar */ .status-bar { display: flex;