feat: Add comprehensive backend validation, toast notifications, and Tokyo Night theme

- Backend: Add server-side validation with go-playground/validator/v10
- Frontend: Add toast notifications for API errors with @zerodevx/svelte-toast
- UI: Complete Tokyo Night theme redesign with modern animations
- Docs: Update COMPLETE_DOCUMENTATION.md and README.md with all enhancements
- Validation: Email format, password strength, and input sanitization
- UX: Real-time error feedback, loading states, and responsive design
This commit is contained in:
2026-01-21 20:01:18 -05:00
parent 7448dfff30
commit 55c42f1f99
84 changed files with 7749 additions and 4 deletions
+736
View File
@@ -0,0 +1,736 @@
# Ebook Reader and Library Manager - Complete Documentation
## Overview
A self-hosted ebook reader and library management system built with:
- **Backend**: Go API with Echo framework, PostgreSQL database with sqlc code generation, serving static frontend
- **Frontend**: Svelte with Vite, TypeScript, and TailwindCSS (built to static files)
- **Database**: PostgreSQL with migrations
- **Deployment**: Fully containerized with Docker and Docker Compose (single Go service)
## Features
- Ebook library management (CRUD operations)
- Reading progress tracking
- Responsive web interface
- RESTful API (accessible by web, mobile, etc.)
- Type-safe database queries with sqlc
- Modern UI with TailwindCSS
- Single-container deployment (Go serves everything)
## Quick Start
### Prerequisites
- Docker and Docker Compose
- Git (for cloning)
### Running the Application
1. Clone the repository:
```bash
git clone <repository-url>
cd bookmann
```
2. Start all services:
```bash
docker-compose up --build
```
3. Access the application:
- Application: http://localhost:8765 (serves both frontend and API)
- Database: localhost:5432 (postgres/password)
## Project Structure
```
bookmann/
├── backend/
│ ├── cmd/server/
│ │ └── main.go # Application entry point
│ ├── internal/
│ │ ├── config/
│ │ │ └── config.go # Configuration management
│ │ ├── database/
│ │ │ ├── connection.go # Database connection (pgx pool)
│ │ │ └── queries/
│ │ │ └── queries.sql # SQL queries for sqlc
│ │ └── handlers/
│ │ └── ebook.go # HTTP handlers
│ ├── migrations/
│ │ └── 001_create_tables.up.sql # Database schema
│ ├── Dockerfile # Backend Docker image (builds frontend)
│ ├── go.mod # Go dependencies
│ ├── go.sum
│ └── sqlc.yaml # sqlc configuration
├── frontend/
│ ├── src/
│ │ ├── lib/
│ │ │ ├── api.ts # API client with auth & error handling
│ │ │ ├── auth.ts # Auth state management
│ │ │ └── toast.ts # Toast notification utilities
│ │ ├── routes/
│ │ │ ├── login/
│ │ │ │ └── +page.svelte # Login page (Tokyo Night theme)
│ │ │ ├── register/
│ │ │ │ └── +page.svelte # Registration page (Tokyo Night theme)
│ │ │ ├── +layout.svelte # Root layout with auth & toasts
│ │ │ └── +page.svelte # Library page (Tokyo Night theme)
│ │ ├── app.css # Global styles with Tokyo Night theme
│ │ ├── app.d.ts # TypeScript declarations
│ │ └── app.html # HTML template with Inter font
│ ├── package.json # Node dependencies with toast library
│ ├── svelte.config.js # SvelteKit config (adapter-static)
│ ├── tailwind.config.js # Tailwind config with Tokyo Night colors
│ ├── postcss.config.js # PostCSS configuration (Tailwind v4)
├── docker-compose.yml # Multi-service orchestration (db + backend only)
├── README.md # Basic README
└── .gitignore
```
## Backend Documentation
### Go Modules
Dependencies in go.mod:
- github.com/labstack/echo/v4 v4.11.3 - Web framework
- github.com/jackc/pgx/v5 v5.4.3 - PostgreSQL driver (pgx pool)
- github.com/golang-jwt/jwt/v5 v5.3.0 - JWT handling
- github.com/google/uuid v1.4.0 - UUID generation
- github.com/go-playground/validator/v10 v10.30.1 - Input validation
- golang.org/x/crypto v0.46.0 - Cryptographic functions (bcrypt)
### Configuration
Environment variables (with defaults):
- SERVER_PORT=8765
- DATABASE_HOST=localhost
- DATABASE_PORT=5432
- DATABASE_USER=postgres
- DATABASE_PASSWORD=password
- DATABASE_NAME=ebookdb
- JWT_SECRET=your-secret-key
- UPLOAD_PATH=./uploads
### Database Schema
Tables:
#### users
```sql
CREATE TABLE users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
email VARCHAR(255) UNIQUE NOT NULL,
username VARCHAR(255) UNIQUE NOT NULL,
password_hash VARCHAR(255) NOT NULL,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
```
#### ebooks
```sql
CREATE TABLE ebooks (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
title VARCHAR(255) NOT NULL,
author VARCHAR(255),
isbn VARCHAR(13),
description TEXT,
file_path VARCHAR(500) NOT NULL,
file_size BIGINT,
mime_type VARCHAR(100),
cover_image_path VARCHAR(500),
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
```
#### reading_progress
```sql
CREATE TABLE reading_progress (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
ebook_id UUID NOT NULL REFERENCES ebooks(id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
current_page INTEGER DEFAULT 0,
total_pages INTEGER,
last_read_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
UNIQUE(ebook_id, user_id)
);
```
#### reading_progress
```sql
CREATE TABLE reading_progress (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
ebook_id UUID NOT NULL REFERENCES ebooks(id) ON DELETE CASCADE,
user_id VARCHAR(100) NOT NULL,
current_page INTEGER DEFAULT 0,
total_pages INTEGER,
last_read_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
UNIQUE(ebook_id, user_id)
);
```
Indexes:
- idx_users_email ON users(email)
- idx_users_username ON users(username)
- idx_ebooks_title ON ebooks(title)
- idx_ebooks_author ON ebooks(author)
- idx_reading_progress_ebook_id ON reading_progress(ebook_id)
- idx_reading_progress_user_id ON reading_progress(user_id)
## Authentication
The application uses JWT (JSON Web Tokens) for authentication. The flow is:
1. **Register** or **Login** via `/api/auth/register` or `/api/auth/login`
2. Receive JWT token in response
3. Include token in `Authorization: Bearer <token>` header for protected requests
4. Token expires after 24 hours
**Security Features:**
- Passwords hashed with bcrypt (cost factor 10)
- JWT signed with configurable secret key
- User data properly isolated per authenticated user
- Unique constraints on email and username
### API Endpoints
All endpoints now include comprehensive server-side validation. Invalid requests return detailed error messages in the response body.
#### Auth (Public)
**POST /api/auth/register**
- Body: { email: string, username: string, password: string }
- Returns: { token: string, user: UserProfile }
**POST /api/auth/login**
- Body: { login: string, password: string } (login can be email or username)
- Returns: { token: string, user: UserProfile }
**GET /api/auth/profile** (requires JWT)
- Returns: UserProfile
#### Ebooks (requires JWT)
**GET /api/ebooks**
- Query params: limit (default: 20), offset (default: 0)
- Returns: Array of ebook objects
**GET /api/ebooks/:id**
- Path param: id (UUID)
- Returns: Single ebook object
**POST /api/ebooks**
- Body: Ebook data (JSON)
- Required fields: title, file_path
- Returns: Created ebook object
**PUT /api/ebooks/:id**
- Path param: id (UUID)
- Body: Updated ebook data (JSON)
- Returns: Updated ebook object
**DELETE /api/ebooks/:id**
- Path param: id (UUID)
- Returns: 204 No Content
#### Reading Progress (requires JWT)
**GET /api/ebooks/:id/progress**
- Path param: ebookId
- Returns: Reading progress object for authenticated user
**PUT /api/ebooks/:id/progress**
- Path param: ebookId
- Body: { current_page: number, total_pages?: number }
- Returns: Updated progress object for authenticated user
### Type Definitions
#### Ebook
```typescript
interface Ebook {
id: string;
title: string;
author: string | null;
isbn: string | null;
description: string | null;
file_path: string;
file_size: number | null;
mime_type: string | null;
cover_image_path: string | null;
created_at: string;
updated_at: string;
}
```
#### UserProfile
```typescript
interface UserProfile {
id: string;
email: string;
username: string;
created_at?: string;
}
```
#### ReadingProgress
```typescript
interface ReadingProgress {
ebook_id: string;
user_id: string;
current_page: number;
total_pages: number | null;
last_read_at: string;
}
```
## Frontend Documentation
### SvelteKit Setup
- Framework: SvelteKit with TypeScript
- Build tool: Vite
- Styling: TailwindCSS
- Authentication: JWT-based with localStorage token storage
- State Management: Svelte stores for reactive auth state
- Routing: Protected routes with auth-based conditional rendering
- UI Components: Custom components with Tailwind classes
### Key Files
#### src/lib/api.ts
API client functions for all backend endpoints with JWT authentication and error handling:
- **Auth functions**: registerUser(), loginUser(), getUserProfile()
- **Ebook functions**: fetchEbooks(), fetchEbook(), createEbook(), updateEbook(), deleteEbook()
- **Progress functions**: fetchReadingProgress(), updateReadingProgress() (user-specific via token)
- **Error handling**: Automatic toast notifications for API errors with parsed error messages
- All functions automatically include Bearer token headers when authenticated
#### src/lib/auth.ts
Authentication state management store:
- Reactive auth state with Svelte stores
- JWT token persistence in localStorage
- Login/logout functionality
- Auth state initialization
#### src/lib/toast.ts
Toast notification utilities:
- showError() - Display error toasts with Tokyo Night red styling
- showSuccess() - Display success toasts with Tokyo Night green styling
- Integrated with API error handling for automatic user feedback
#### src/routes/login/+page.svelte
Login page with form validation and error handling
#### src/routes/register/+page.svelte
Registration page with password confirmation and validation
#### src/routes/+layout.svelte
Root layout with authentication:
- Auth state initialization and management
- Conditional rendering based on login status
- Navigation header with user info and logout
- Auth forms for unauthenticated users
### Styling
Tokyo Night Theme with TailwindCSS:
- **Color Palette**: Custom Tokyo Night colors (#1a1b26 backgrounds, #7aa2f7 accents)
- **Typography**: Inter font family loaded from Google Fonts
- **Animations**: Custom fade-in and slide-in animations
- **Components**: Pre-built button, card, input, and navigation styles
- **Dark Theme**: Complete dark mode implementation with proper contrast
- **Responsive Design**: Mobile-first approach with breakpoint optimizations
- **Interactive Elements**: Hover effects, focus states, and smooth transitions
### Authentication Flow
**Frontend Implementation:**
- **State Management**: Reactive auth store with login/logout actions
- **Token Persistence**: JWT tokens stored in localStorage with session persistence
- **Route Protection**: Layout component handles auth-based conditional rendering
- **API Integration**: All API calls include auth headers automatically
- **Form Validation**: Client-side validation with error display
- **User Experience**: Seamless transitions between auth states
## Docker Configuration
### Backend Dockerfile
- Multi-stage build with Go and Node.js stages
- Alpine Linux for small final image
- Frontend built to static files during Docker build
- sqlc code generation during build
- Binary compilation with CGO disabled
- Static files copied to final image
### Docker Compose Services
#### db (PostgreSQL)
- Image: postgres:15-alpine
- Health check with pg_isready
- Volume for data persistence
- Init scripts from migrations folder
#### backend (Go API + Static Frontend)
- Build from backend/Dockerfile
- Environment variables for configuration
- Health check for application availability
- Depends on database health
- Volume for uploads directory
- Serves both API and static frontend files
### Networking
Services communicate via Docker networks:
- backend serves static files for frontend and API endpoints
- backend connects to db:5432
- All services restart automatically on failure
## Development Setup
### Backend Development
```bash
cd backend
go mod tidy
go run github.com/sqlc-dev/sqlc/cmd/sqlc@latest generate
go run cmd/server/main.go
```
### Frontend Development
For development, run the frontend separately:
```bash
cd frontend
npm install
npm run dev -- --open
```
Note: For production, frontend is built statically and served by the Go backend. API calls should proxy to the backend (e.g., via Vite proxy in dev).
### Database Development
```bash
# Connect to database
psql -h localhost -p 5432 -U postgres -d ebookdb
# Run migrations manually (if needed)
# Migrations run automatically in Docker
```
## Deployment
### Production Considerations
1. **Environment Variables**: Change default passwords, JWT secret, and database credentials
2. **Database**: Use managed PostgreSQL in production with connection pooling
3. **File Storage**: Implement cloud storage for ebook files (S3, etc.)
4. **Authentication**: JWT secrets should be strong and rotated regularly
5. **HTTPS**: Configure SSL certificates for secure transmission
6. **Rate Limiting**: Add rate limiting for auth endpoints to prevent brute force
7. **User Management**: Consider email verification, password reset, account locking
8. **Monitoring**: Add logging and monitoring for auth failures
9. **Backup**: Regular database backups including user data
### Scaling
- Database can be moved to separate instance
- Backend can be scaled horizontally with load balancer
- Frontend static files can be served from CDN
- File storage can use cloud storage (S3, etc.)
## User Journey
### Complete User Flow
1. **Registration**: User visits `/register`, fills form, receives JWT token
2. **Login**: User can login with email or username, receives JWT token
3. **Token Storage**: Frontend stores JWT in localStorage
4. **API Access**: All subsequent API calls include Bearer token
5. **Library Access**: User sees their ebook library with personal reading progress
6. **Progress Tracking**: Reading progress is automatically associated with user
7. **Session Management**: User stays logged in across browser sessions
8. **Logout**: User can logout, clearing token and redirecting to login
### Security Features
- **Password Hashing**: Bcrypt with cost factor 10
- **JWT Tokens**: 24-hour expiration, signed with configurable secret
- **User Isolation**: All data is user-specific and properly segregated
- **Session Persistence**: Secure token storage with automatic validation
- **API Protection**: All sensitive operations require valid authentication
## API Usage Examples
### Registering a User
```bash
curl -X POST http://localhost:8765/api/auth/register \
-H "Content-Type: application/json" \
-d '{
"email": "user@example.com",
"username": "testuser",
"password": "securepassword"
}'
```
### Logging in a User
```bash
curl -X POST http://localhost:8765/api/auth/login \
-H "Content-Type: application/json" \
-d '{
"login": "user@example.com",
"password": "securepassword"
}'
```
### Creating an Ebook (requires JWT token)
```bash
curl -X POST -H "Authorization: Bearer YOUR_JWT_TOKEN" \
-H "Content-Type: application/json" \
http://localhost:8765/api/ebooks \
-d '{
"title": "Sample Book",
"author": "Sample Author",
"file_path": "/uploads/sample.epub",
"file_size": 1024000,
"mime_type": "application/epub+zip"
}'
```
### Getting Reading Progress (requires JWT token)
```bash
curl -H "Authorization: Bearer YOUR_JWT_TOKEN" \
http://localhost:8765/api/ebooks/123e4567-e89b-12d3-a456-426614174000/progress
```
### Updating Progress (requires JWT token)
```bash
curl -X PUT -H "Authorization: Bearer YOUR_JWT_TOKEN" \
-H "Content-Type: application/json" \
http://localhost:8765/api/ebooks/123e4567-e89b-12d3-a456-426614174000/progress \
-d '{"current_page": 45, "total_pages": 200}'
```
### Register User
```bash
curl -X POST http://localhost:8765/api/auth/register \
-H "Content-Type: application/json" \
-d '{
"email": "user@example.com",
"username": "testuser",
"password": "securepassword"
}'
```
### Login User
```bash
curl -X POST http://localhost:8765/api/auth/login \
-H "Content-Type: application/json" \
-d '{
"login": "user@example.com",
"password": "securepassword"
}'
```
### Getting Reading Progress (requires JWT token)
```bash
curl -H "Authorization: Bearer YOUR_JWT_TOKEN" \
http://localhost:8765/api/ebooks/123e4567-e89b-12d3-a456-426614174000/progress
```
### Updating Progress (requires JWT token)
```bash
curl -X PUT -H "Authorization: Bearer YOUR_JWT_TOKEN" \
-H "Content-Type: application/json" \
http://localhost:8765/api/ebooks/123e4567-e89b-12d3-a456-426614174000/progress \
-d '{"current_page": 45, "total_pages": 200}'
```
## Recent Changes (Complete Multi-User Auth System)
As of the latest updates, the application has been completely transformed into a full multi-user ebook management system:
- **Multi-User Authentication**: Complete user system with registration/login, supporting both email and username login, JWT-based sessions, and bcrypt password hashing
- **Security**: Passwords stored with industry-standard bcrypt hashing, JWT tokens for API access, user data isolation, secure token management
- **API Evolution**: Auth endpoints added, reading progress now user-specific (removed :userId params), all ebook operations require authentication
- **Frontend Auth Integration**: Svelte frontend fully updated with login/register forms, JWT token management in localStorage, reactive auth state store, protected routes with automatic redirects
- **User Experience**: Seamless auth flow with form validation, session persistence, user dashboard, logout functionality
- **Architecture**: Single Go service serving both static frontend and API, no Node.js dependency
- **Database**: New users table, updated reading_progress with user foreign keys, comprehensive indexing
- **Testing**: Bruno collection updated with auth flows and Bearer token authentication
- **Cleanup**: Removed obsolete Docker files for frontend containerization
## Latest Updates (Enhanced Validation, UI/UX & Tokyo Night Theme)
### Backend API Validation Enhancement
- **Server-Side Validation**: Added comprehensive input validation using `github.com/go-playground/validator/v10`
- **Request Validation**: All API endpoints now validate request data before processing
- **Error Responses**: Detailed validation error messages returned for invalid requests
- **Security**: Prevents malformed data from reaching the database layer
#### Validation Rules Added:
- **User Registration**: Email format validation, username length (3-50 chars), password minimum length (6+ chars)
- **User Login**: Required email/username and password fields
- **Ebook Creation**: Required title and file_path, minimum title length (1-500 chars), optional file size validation
- **Ebook Updates**: Title validation when provided, cover image path validation
- **Reading Progress**: Required current_page (≥0), optional total_pages (≥1)
### Frontend Toast Notifications
- **Error Notifications**: Real-time toast popups for all API errors using `@zerodevx/svelte-toast`
- **User Feedback**: Immediate visual feedback for failed operations (login, registration, CRUD operations)
- **Error Parsing**: Automatically extracts and displays backend validation messages
- **Non-Intrusive Design**: Toasts appear without disrupting user workflow
### Tokyo Night Theme Redesign
- **Complete UI Overhaul**: Transformed the entire frontend with the popular Tokyo Night dark theme
- **Color Palette**: Deep blue backgrounds (#1a1b26), cyan accents (#7dcfff), and carefully chosen contrast colors
- **Modern Design**: Gradient backgrounds, smooth animations, and professional card-based layouts
- **Enhanced Typography**: Inter font family for improved readability
- **Interactive Elements**: Hover effects, focus states, and smooth transitions throughout
#### Tokyo Night Color Scheme:
```css
Background: #1a1b26 (dark blue-gray)
Secondary Background: #16161e (darker blue)
Highlights: #292e42 (medium blue-gray)
Foreground: #a9b1d6 (light blue-gray)
Accent Blue: #7aa2f7
Accent Cyan: #7dcfff
Accent Red: #f7768e
Accent Green: #9ece6a
Accent Purple: #bb9af7
```
### Enhanced User Experience Features
- **Loading States**: Beautiful animated spinners during data fetching
- **Form Validation**: Real-time client-side validation with visual feedback
- **Responsive Design**: Optimized for all screen sizes with mobile-first approach
- **Animation System**: Fade-in and slide-in animations for page transitions
- **Error Handling**: Comprehensive error states with user-friendly messages
- **Empty States**: Attractive placeholders when no data is available
- **Visual Hierarchy**: Clear information architecture with proper spacing and typography
### Technical Improvements
- **Performance**: Optimized CSS compilation with direct color values for better build performance
- **Accessibility**: Proper contrast ratios and focus management
- **Code Organization**: Clean separation of concerns with utility functions
- **Type Safety**: Full TypeScript integration with proper error typing
- **Build Optimization**: Efficient static file generation and caching
### Updated Dependencies
- **Backend**: Added `github.com/go-playground/validator/v10` for validation
- **Frontend**: Added `@zerodevx/svelte-toast` for notifications
- **Fonts**: Added Google Fonts Inter for enhanced typography
- **Tailwind**: Extended configuration with custom animations and Tokyo Night colors
### User Interface Components
- **Authentication Pages**: Redesigned login/register forms with card layouts and icons
- **Library View**: Grid-based ebook display with hover effects and metadata
- **Navigation**: Clean header with user information and logout functionality
- **Loading Screens**: Beautiful animated loading states
- **Error Displays**: User-friendly error messages with appropriate styling
- **Toast Notifications**: Non-intrusive error feedback system
This comprehensive update transforms the application into a modern, professional ebook management system with excellent user experience, robust validation, and beautiful dark theme design.
## Troubleshooting
### Common Issues
1. **Database Connection Failed**
- Check if PostgreSQL container is running
- Verify DATABASE_* environment variables
- Check database logs: `docker-compose logs db`
2. **Application Not Accessible**
- Verify backend container is running
- Check backend logs: `docker-compose logs backend`
- Test health endpoint: `curl http://localhost:8765/`
3. **Authentication Issues**
- Verify JWT_SECRET environment variable is set
- Check token expiration (24 hours)
- Ensure Bearer token format: `Authorization: Bearer <token>`
- Test auth endpoints first: `/api/auth/register`, `/api/auth/login`
- Check validation errors in API responses (frontend shows toast notifications)
4. **Validation Errors**
- Frontend displays validation errors via toast notifications
- Check API response body for detailed error messages
- Ensure request data matches validation rules (email format, password length, etc.)
5. **Frontend Not Loading**
- Check backend logs for static file serving
- Verify frontend built correctly in Docker
- Check browser console for errors
- Ensure Tokyo Night theme CSS compiled properly
6. **API Authorization Errors**
- Confirm JWT token is valid and not expired
- Check middleware logs for auth failures
- Verify protected endpoints use correct HTTP methods
7. **Build Failures**
- Clear Docker cache: `docker system prune -a`
- Rebuild: `docker-compose up --build --force-recreate`
### Logs
```bash
# All services
docker-compose logs
# Specific service
docker-compose logs backend
docker-compose logs frontend
docker-compose logs db
# Follow logs
docker-compose logs -f backend
```
## Contributing
1. Fork the repository
2. Create a feature branch
3. Make changes with proper testing
4. Submit a pull request
## License
MIT License - see LICENSE file for details
## Future Enhancements
- Email verification for user registration
- Password reset functionality
- User profile management (update email/username, change password)
- Email notifications (welcome, password reset, etc.)
- User roles and permissions (admin, regular user)
- Ebook file upload and processing with validation
- EPUB/PDF reader component with bookmarking
- Advanced search and filtering (by author, genre, etc.)
- Categories and tags for ebooks
- Reading statistics and analytics dashboard
- Social features (sharing reading lists, reviews)
- Mobile app development (React Native/Flutter)
- Cloud storage integration (S3, Google Drive)
- Backup and restore functionality
- Admin panel for user management
- OAuth integration (Google, GitHub, Apple login)
- Two-factor authentication (2FA)
- Session management and device tracking
---
This documentation covers the complete setup, configuration, and usage of the ebook reader system. For specific implementation details, refer to the source code comments and type definitions.
+149 -2
View File
@@ -1,3 +1,150 @@
# bookmann # 📚 Ebook Reader and Library Manager
A Self-Hosted Ebook Manager and Reader in Go/Svelte. A self-hosted ebook reader and library management system built with Go, PostgreSQL, and Svelte (integrated into a single service) featuring a beautiful Tokyo Night dark theme.
## ✨ Features
- **🔒 Multi-User Authentication**: Complete user system with registration/login, JWT-based sessions, and bcrypt password hashing
- **✅ Server-Side Validation**: Comprehensive input validation with detailed error messages
- **🔔 Toast Notifications**: Real-time error feedback with beautiful toast popups
- **🌙 Tokyo Night Theme**: Stunning dark theme with smooth animations and modern UI
- **📖 Reading Progress**: User-specific reading progress tracking
- **🔧 RESTful API**: Clean API endpoints with JWT authentication
- **🐳 Docker Ready**: Single-container deployment with PostgreSQL
- **🧪 API Testing**: Complete Bruno collection for testing all endpoints
- **📱 Responsive Design**: Mobile-first responsive interface
## Quick Start
### Prerequisites
- Docker and Docker Compose
### Running the Application
1. Clone the repository
2. Run the application:
```bash
docker-compose up --build
```
3. Access the application at http://localhost:8765
### Database
PostgreSQL runs on port 5432 with default credentials:
- Database: ebookdb
- User: postgres
- Password: password
## Development
### Backend
```bash
cd backend
go mod tidy
go run github.com/sqlc-dev/sqlc/cmd/sqlc@latest generate
go run cmd/server/main.go
```
### Frontend
For development, run the frontend separately:
```bash
cd frontend
npm install
npm run dev
```
**✨ Enhanced Features:**
- **Authentication**: Login/Register forms with Tokyo Night styling at `/login` and `/register`
- **JWT Management**: Secure token storage with localStorage and session persistence
- **Protected Routes**: Automatic redirects with reactive auth state management
- **Toast Notifications**: Real-time error feedback for all API operations
- **Input Validation**: Client-side and server-side validation with detailed error messages
- **Tokyo Night Theme**: Beautiful dark theme with smooth animations and modern UI components
- **Responsive Design**: Mobile-first approach with optimized layouts for all screen sizes
**Note**: For production, the frontend is built to static files and served by the Go backend.
## API Endpoints
### Auth (Public)
- `POST /api/auth/register` - Register new user
- `POST /api/auth/login` - Login user (email or username)
- `GET /api/auth/profile` - Get user profile (requires JWT)
### Ebooks (Protected)
- `GET /api/ebooks` - List ebooks
- `GET /api/ebooks/:id` - Get specific ebook
- `POST /api/ebooks` - Create new ebook
- `PUT /api/ebooks/:id` - Update ebook
- `DELETE /api/ebooks/:id` - Delete ebook
### Reading Progress (Protected)
- `GET /api/ebooks/:id/progress` - Get reading progress
- `PUT /api/ebooks/:id/progress` - Update reading progress
## API Testing
Use the included Bruno collection in the `bruno/` directory for testing the API:
1. Install [Bruno](https://www.usebruno.com/)
2. Import the `bruno/` folder as a collection
3. Select the "localhost" environment
4. Run the application and test the endpoints
## Project Structure
```
.
├── backend/
│ ├── cmd/server/ # Application entry point
│ ├── internal/
│ │ ├── config/ # Configuration management
│ │ ├── database/ # Database connection and queries
│ │ └── handlers/ # HTTP handlers (auth + ebooks)
│ ├── migrations/ # Database migrations
│ └── sqlc.yaml # sqlc configuration
├── frontend/
│ ├── src/
│ │ ├── lib/
│ │ │ ├── api.ts # API client with auth & toast notifications
│ │ │ ├── auth.ts # Auth state management
│ │ │ └── toast.ts # Toast notification utilities
│ │ └── routes/
│ │ ├── login/ # Login page (Tokyo Night theme)
│ │ ├── register/ # Registration page (Tokyo Night theme)
│ │ └── ... # Other routes (Tokyo Night theme)
│ ├── app.css # Global styles with Tokyo Night theme
│ └── package.json # Build dependencies with toast library
├── bruno/ # Bruno API testing collection
├── docker-compose.yml
└── README.md
```
## 🎨 Recent Enhancements
### Backend Improvements
- **Server-Side Validation**: Added comprehensive input validation using `go-playground/validator/v10`
- **Enhanced Security**: All API endpoints now validate requests before processing
- **Better Error Handling**: Detailed validation error messages for improved debugging
### Frontend Redesign
- **Tokyo Night Theme**: Complete UI overhaul with beautiful dark theme colors
- **Toast Notifications**: Real-time error feedback using `@zerodevx/svelte-toast`
- **Modern UI Components**: Card-based layouts, smooth animations, and responsive design
- **Enhanced UX**: Loading states, form validation, and improved user feedback
### Technical Updates
- **Dependencies**: Added validation and toast libraries
- **Typography**: Inter font family for improved readability
- **Animations**: Custom fade-in and slide-in animations
- **Performance**: Optimized CSS compilation and build process
## License
MIT
+10
View File
@@ -0,0 +1,10 @@
.git
.gitignore
README.md
*.md
.env
.DS_Store
.vscode
.idea
tmp/
logs/
+58
View File
@@ -0,0 +1,58 @@
# Build stage
FROM golang:1.25-alpine AS builder
WORKDIR /app
# Install Node.js and npm
RUN apk add --no-cache nodejs npm
# Install sqlc
RUN go install github.com/sqlc-dev/sqlc/cmd/sqlc@latest
# Copy backend source code
COPY backend/ ./backend/
# Copy frontend source for building
COPY frontend/package*.json ./frontend/
WORKDIR /app/frontend
# Install frontend dependencies (cached if package.json unchanged)
RUN npm ci
# Copy frontend source
COPY frontend/ ./
# Build frontend
RUN npm run build
# Go back to root
WORKDIR /app
# Generate sqlc code
RUN cd backend && sqlc generate
# Build the application
RUN cd backend && CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o ../main ./cmd/server
# Final stage
FROM alpine:latest
RUN apk --no-cache add ca-certificates
WORKDIR /root/
# Copy the binary from builder stage
COPY --from=builder /app/main .
# Copy migrations (if needed for initialization)
COPY --from=builder /app/backend/migrations ./migrations
# Copy static files
COPY --from=builder /app/frontend/build ./static
# Expose port
EXPOSE 8080
# Run the binary
CMD ["./main"]
+74
View File
@@ -0,0 +1,74 @@
package main
import (
"bookmann/internal/config"
"bookmann/internal/database"
"bookmann/internal/handlers"
"log"
"github.com/go-playground/validator/v10"
"github.com/golang-jwt/jwt/v5"
"github.com/labstack/echo/v4"
"github.com/labstack/echo/v4/middleware"
)
// CustomValidator wraps the go-playground validator
type CustomValidator struct {
validator *validator.Validate
}
func (cv *CustomValidator) Validate(i interface{}) error {
return cv.validator.Struct(i)
}
func main() {
cfg := config.LoadConfig()
pool, err := database.NewConnection(cfg.DatabaseURL())
if err != nil {
log.Fatal("Failed to connect to database:", err)
}
defer pool.Close()
queries := database.New(pool)
e := echo.New()
// Set up validator
e.Validator = &CustomValidator{validator: validator.New()}
// Middleware
e.Use(middleware.Logger())
e.Use(middleware.Recover())
e.Use(middleware.CORS())
// Auth routes (no auth required)
auth := handlers.NewAuthHandler(queries, cfg.JWTSecret)
e.POST("/api/auth/register", auth.Register)
e.POST("/api/auth/login", auth.Login)
// JWT middleware for protected routes
jwtMiddleware := middleware.JWTWithConfig(middleware.JWTConfig{
SigningKey: []byte(cfg.JWTSecret),
ContextKey: "user",
SuccessHandler: func(c echo.Context) {
token := c.Get("user").(*jwt.Token)
claims := token.Claims.(jwt.MapClaims)
c.Set("user_id", claims["user_id"])
},
})
// Protected routes
protected := e.Group("/api", jwtMiddleware)
protected.GET("/auth/profile", auth.GetProfile)
// Routes
handlers.SetupRoutes(protected, queries)
// Serve static files
e.Static("/", "static")
// Start server
log.Printf("Starting server on port %s", cfg.ServerPort)
e.Logger.Fatal(e.Start(":" + cfg.ServerPort))
}
+33
View File
@@ -0,0 +1,33 @@
module bookmann
go 1.25
require (
github.com/go-playground/validator/v10 v10.30.1
github.com/golang-jwt/jwt/v5 v5.3.0
github.com/google/uuid v1.4.0
github.com/jackc/pgx/v5 v5.4.3
github.com/labstack/echo/v4 v4.11.3
golang.org/x/crypto v0.46.0
)
require (
github.com/gabriel-vasile/mimetype v1.4.12 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/golang-jwt/jwt v3.2.2+incompatible // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect
github.com/jackc/puddle/v2 v2.2.1 // indirect
github.com/labstack/gommon v0.4.0 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.19 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasttemplate v1.2.2 // indirect
golang.org/x/net v0.47.0 // indirect
golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.39.0 // indirect
golang.org/x/text v0.32.0 // indirect
golang.org/x/time v0.3.0 // indirect
)
+74
View File
@@ -0,0 +1,74 @@
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw=
github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w=
github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM=
github.com/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keLg81eXfW3O+oY=
github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I=
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/google/uuid v1.4.0 h1:MtMxsa51/r9yyhkyLsVeVt0B+BGQZzpQiTQ4eHZ8bc4=
github.com/google/uuid v1.4.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk=
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.4.3 h1:cxFyXhxlvAifxnkKKdlxv8XqUf59tDlYjnV5YYfsJJY=
github.com/jackc/pgx/v5 v5.4.3/go.mod h1:Ig06C2Vu0t5qXC60W8sqIthScaEnFvojjj9dSljmHRA=
github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk=
github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/labstack/echo/v4 v4.11.3 h1:Upyu3olaqSHkCjs1EJJwQ3WId8b8b1hxbogyommKktM=
github.com/labstack/echo/v4 v4.11.3/go.mod h1:UcGuQ8V6ZNRmSweBIJkPvGfwCMIlFmiqrPqiEBfPYws=
github.com/labstack/gommon v0.4.0 h1:y7cvthEAEbU0yHOf4axH8ZG2NH8knB9iNSoTO8dyIk8=
github.com/labstack/gommon v0.4.0/go.mod h1:uW6kP17uPlLJsD3ijUYn3/M5bAxtlZhMI6m3MFxTMTM=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/mattn/go-colorable v0.1.11/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA=
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
github.com/valyala/fasttemplate v1.2.1/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ=
github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo=
github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ=
golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU=
golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0=
golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20211103235746-7861aae1554b/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
golang.org/x/time v0.3.0 h1:rg5rLMjNzMS1RkNLzCG38eapWhnYLFYXDXj2gOlr8j4=
golang.org/x/time v0.3.0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+42
View File
@@ -0,0 +1,42 @@
package config
import (
"fmt"
"os"
)
type Config struct {
ServerPort string
JWTSecret string
UploadPath string
DatabaseHost string
DatabasePort string
DatabaseUser string
DatabasePassword string
DatabaseName string
}
func LoadConfig() *Config {
return &Config{
ServerPort: getEnv("SERVER_PORT", "8080"),
DatabaseHost: getEnv("DATABASE_HOST", "localhost"),
DatabasePort: getEnv("DATABASE_PORT", "5432"),
DatabaseUser: getEnv("DATABASE_USER", "postgres"),
DatabasePassword: getEnv("DATABASE_PASSWORD", "password"),
DatabaseName: getEnv("DATABASE_NAME", "ebookdb"),
JWTSecret: getEnv("JWT_SECRET", "your-secret-key"),
UploadPath: getEnv("UPLOAD_PATH", "./uploads"),
}
}
func (c *Config) DatabaseURL() string {
return fmt.Sprintf("postgres://%s:%s@%s:%s/%s?sslmode=disable",
c.DatabaseUser, c.DatabasePassword, c.DatabaseHost, c.DatabasePort, c.DatabaseName)
}
func getEnv(key, defaultValue string) string {
if value := os.Getenv(key); value != "" {
return value
}
return defaultValue
}
+20
View File
@@ -0,0 +1,20 @@
package database
import (
"context"
"github.com/jackc/pgx/v5/pgxpool"
)
func NewConnection(databaseURL string) (*pgxpool.Pool, error) {
pool, err := pgxpool.New(context.Background(), databaseURL)
if err != nil {
return nil, err
}
if err := pool.Ping(context.Background()); err != nil {
return nil, err
}
return pool, nil
}
+32
View File
@@ -0,0 +1,32 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.30.0
package database
import (
"context"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
)
type DBTX interface {
Exec(context.Context, string, ...interface{}) (pgconn.CommandTag, error)
Query(context.Context, string, ...interface{}) (pgx.Rows, error)
QueryRow(context.Context, string, ...interface{}) pgx.Row
}
func New(db DBTX) *Queries {
return &Queries{db: db}
}
type Queries struct {
db DBTX
}
func (q *Queries) WithTx(tx pgx.Tx) *Queries {
return &Queries{
db: tx,
}
}
+41
View File
@@ -0,0 +1,41 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.30.0
package database
import (
"github.com/jackc/pgx/v5/pgtype"
)
type Ebooks struct {
ID pgtype.UUID `db:"id" json:"id"`
Title string `db:"title" json:"title"`
Author pgtype.Text `db:"author" json:"author"`
Isbn pgtype.Text `db:"isbn" json:"isbn"`
Description pgtype.Text `db:"description" json:"description"`
FilePath string `db:"file_path" json:"file_path"`
FileSize pgtype.Int8 `db:"file_size" json:"file_size"`
MimeType pgtype.Text `db:"mime_type" json:"mime_type"`
CoverImagePath pgtype.Text `db:"cover_image_path" json:"cover_image_path"`
CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"`
UpdatedAt pgtype.Timestamptz `db:"updated_at" json:"updated_at"`
}
type ReadingProgress struct {
ID pgtype.UUID `db:"id" json:"id"`
EbookID pgtype.UUID `db:"ebook_id" json:"ebook_id"`
UserID pgtype.UUID `db:"user_id" json:"user_id"`
CurrentPage pgtype.Int4 `db:"current_page" json:"current_page"`
TotalPages pgtype.Int4 `db:"total_pages" json:"total_pages"`
LastReadAt pgtype.Timestamptz `db:"last_read_at" json:"last_read_at"`
}
type Users struct {
ID pgtype.UUID `db:"id" json:"id"`
Email string `db:"email" json:"email"`
Username string `db:"username" json:"username"`
PasswordHash string `db:"password_hash" json:"password_hash"`
CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"`
UpdatedAt pgtype.Timestamptz `db:"updated_at" json:"updated_at"`
}
+29
View File
@@ -0,0 +1,29 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.30.0
package database
import (
"context"
"github.com/jackc/pgx/v5/pgtype"
)
type Querier interface {
CreateEbook(ctx context.Context, arg CreateEbookParams) (Ebooks, error)
CreateUser(ctx context.Context, arg CreateUserParams) (Users, error)
DeleteEbook(ctx context.Context, id pgtype.UUID) error
DeleteReadingProgress(ctx context.Context, arg DeleteReadingProgressParams) error
GetEbook(ctx context.Context, id pgtype.UUID) (Ebooks, error)
GetReadingProgress(ctx context.Context, arg GetReadingProgressParams) (ReadingProgress, error)
GetUser(ctx context.Context, id pgtype.UUID) (GetUserRow, error)
GetUserByEmail(ctx context.Context, email string) (Users, error)
GetUserByEmailOrUsername(ctx context.Context, email string) (Users, error)
GetUserByUsername(ctx context.Context, username string) (Users, error)
ListEbooks(ctx context.Context, arg ListEbooksParams) ([]Ebooks, error)
UpdateEbook(ctx context.Context, arg UpdateEbookParams) (Ebooks, error)
UpdateReadingProgress(ctx context.Context, arg UpdateReadingProgressParams) (ReadingProgress, error)
}
var _ Querier = (*Queries)(nil)
+356
View File
@@ -0,0 +1,356 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.30.0
// source: queries.sql
package database
import (
"context"
"github.com/jackc/pgx/v5/pgtype"
)
const CreateEbook = `-- name: CreateEbook :one
INSERT INTO ebooks (title, author, isbn, description, file_path, file_size, mime_type, cover_image_path)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
RETURNING id, title, author, isbn, description, file_path, file_size, mime_type, cover_image_path, created_at, updated_at
`
type CreateEbookParams struct {
Title string `db:"title" json:"title"`
Author pgtype.Text `db:"author" json:"author"`
Isbn pgtype.Text `db:"isbn" json:"isbn"`
Description pgtype.Text `db:"description" json:"description"`
FilePath string `db:"file_path" json:"file_path"`
FileSize pgtype.Int8 `db:"file_size" json:"file_size"`
MimeType pgtype.Text `db:"mime_type" json:"mime_type"`
CoverImagePath pgtype.Text `db:"cover_image_path" json:"cover_image_path"`
}
func (q *Queries) CreateEbook(ctx context.Context, arg CreateEbookParams) (Ebooks, error) {
row := q.db.QueryRow(ctx, CreateEbook,
arg.Title,
arg.Author,
arg.Isbn,
arg.Description,
arg.FilePath,
arg.FileSize,
arg.MimeType,
arg.CoverImagePath,
)
var i Ebooks
err := row.Scan(
&i.ID,
&i.Title,
&i.Author,
&i.Isbn,
&i.Description,
&i.FilePath,
&i.FileSize,
&i.MimeType,
&i.CoverImagePath,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const CreateUser = `-- name: CreateUser :one
INSERT INTO users (email, username, password_hash)
VALUES ($1, $2, $3)
RETURNING id, email, username, password_hash, created_at, updated_at
`
type CreateUserParams struct {
Email string `db:"email" json:"email"`
Username string `db:"username" json:"username"`
PasswordHash string `db:"password_hash" json:"password_hash"`
}
func (q *Queries) CreateUser(ctx context.Context, arg CreateUserParams) (Users, error) {
row := q.db.QueryRow(ctx, CreateUser, arg.Email, arg.Username, arg.PasswordHash)
var i Users
err := row.Scan(
&i.ID,
&i.Email,
&i.Username,
&i.PasswordHash,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const DeleteEbook = `-- name: DeleteEbook :exec
DELETE FROM ebooks WHERE id = $1
`
func (q *Queries) DeleteEbook(ctx context.Context, id pgtype.UUID) error {
_, err := q.db.Exec(ctx, DeleteEbook, id)
return err
}
const DeleteReadingProgress = `-- name: DeleteReadingProgress :exec
DELETE FROM reading_progress WHERE ebook_id = $1 AND user_id = $2
`
type DeleteReadingProgressParams struct {
EbookID pgtype.UUID `db:"ebook_id" json:"ebook_id"`
UserID pgtype.UUID `db:"user_id" json:"user_id"`
}
func (q *Queries) DeleteReadingProgress(ctx context.Context, arg DeleteReadingProgressParams) error {
_, err := q.db.Exec(ctx, DeleteReadingProgress, arg.EbookID, arg.UserID)
return err
}
const GetEbook = `-- name: GetEbook :one
SELECT id, title, author, isbn, description, file_path, file_size, mime_type, cover_image_path, created_at, updated_at FROM ebooks WHERE id = $1
`
func (q *Queries) GetEbook(ctx context.Context, id pgtype.UUID) (Ebooks, error) {
row := q.db.QueryRow(ctx, GetEbook, id)
var i Ebooks
err := row.Scan(
&i.ID,
&i.Title,
&i.Author,
&i.Isbn,
&i.Description,
&i.FilePath,
&i.FileSize,
&i.MimeType,
&i.CoverImagePath,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const GetReadingProgress = `-- name: GetReadingProgress :one
SELECT id, ebook_id, user_id, current_page, total_pages, last_read_at FROM reading_progress WHERE ebook_id = $1 AND user_id = $2
`
type GetReadingProgressParams struct {
EbookID pgtype.UUID `db:"ebook_id" json:"ebook_id"`
UserID pgtype.UUID `db:"user_id" json:"user_id"`
}
func (q *Queries) GetReadingProgress(ctx context.Context, arg GetReadingProgressParams) (ReadingProgress, error) {
row := q.db.QueryRow(ctx, GetReadingProgress, arg.EbookID, arg.UserID)
var i ReadingProgress
err := row.Scan(
&i.ID,
&i.EbookID,
&i.UserID,
&i.CurrentPage,
&i.TotalPages,
&i.LastReadAt,
)
return i, err
}
const GetUser = `-- name: GetUser :one
SELECT id, email, username, created_at, updated_at FROM users WHERE id = $1
`
type GetUserRow struct {
ID pgtype.UUID `db:"id" json:"id"`
Email string `db:"email" json:"email"`
Username string `db:"username" json:"username"`
CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"`
UpdatedAt pgtype.Timestamptz `db:"updated_at" json:"updated_at"`
}
func (q *Queries) GetUser(ctx context.Context, id pgtype.UUID) (GetUserRow, error) {
row := q.db.QueryRow(ctx, GetUser, id)
var i GetUserRow
err := row.Scan(
&i.ID,
&i.Email,
&i.Username,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const GetUserByEmail = `-- name: GetUserByEmail :one
SELECT id, email, username, password_hash, created_at, updated_at FROM users WHERE email = $1
`
func (q *Queries) GetUserByEmail(ctx context.Context, email string) (Users, error) {
row := q.db.QueryRow(ctx, GetUserByEmail, email)
var i Users
err := row.Scan(
&i.ID,
&i.Email,
&i.Username,
&i.PasswordHash,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const GetUserByEmailOrUsername = `-- name: GetUserByEmailOrUsername :one
SELECT id, email, username, password_hash, created_at, updated_at FROM users WHERE email = $1 OR username = $1
`
func (q *Queries) GetUserByEmailOrUsername(ctx context.Context, email string) (Users, error) {
row := q.db.QueryRow(ctx, GetUserByEmailOrUsername, email)
var i Users
err := row.Scan(
&i.ID,
&i.Email,
&i.Username,
&i.PasswordHash,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const GetUserByUsername = `-- name: GetUserByUsername :one
SELECT id, email, username, password_hash, created_at, updated_at FROM users WHERE username = $1
`
func (q *Queries) GetUserByUsername(ctx context.Context, username string) (Users, error) {
row := q.db.QueryRow(ctx, GetUserByUsername, username)
var i Users
err := row.Scan(
&i.ID,
&i.Email,
&i.Username,
&i.PasswordHash,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const ListEbooks = `-- name: ListEbooks :many
SELECT id, title, author, isbn, description, file_path, file_size, mime_type, cover_image_path, created_at, updated_at FROM ebooks ORDER BY created_at DESC LIMIT $1 OFFSET $2
`
type ListEbooksParams struct {
Limit int32 `db:"limit" json:"limit"`
Offset int32 `db:"offset" json:"offset"`
}
func (q *Queries) ListEbooks(ctx context.Context, arg ListEbooksParams) ([]Ebooks, error) {
rows, err := q.db.Query(ctx, ListEbooks, arg.Limit, arg.Offset)
if err != nil {
return nil, err
}
defer rows.Close()
items := []Ebooks{}
for rows.Next() {
var i Ebooks
if err := rows.Scan(
&i.ID,
&i.Title,
&i.Author,
&i.Isbn,
&i.Description,
&i.FilePath,
&i.FileSize,
&i.MimeType,
&i.CoverImagePath,
&i.CreatedAt,
&i.UpdatedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const UpdateEbook = `-- name: UpdateEbook :one
UPDATE ebooks SET
title = $2,
author = $3,
isbn = $4,
description = $5,
cover_image_path = $6,
updated_at = NOW()
WHERE id = $1
RETURNING id, title, author, isbn, description, file_path, file_size, mime_type, cover_image_path, created_at, updated_at
`
type UpdateEbookParams struct {
ID pgtype.UUID `db:"id" json:"id"`
Title string `db:"title" json:"title"`
Author pgtype.Text `db:"author" json:"author"`
Isbn pgtype.Text `db:"isbn" json:"isbn"`
Description pgtype.Text `db:"description" json:"description"`
CoverImagePath pgtype.Text `db:"cover_image_path" json:"cover_image_path"`
}
func (q *Queries) UpdateEbook(ctx context.Context, arg UpdateEbookParams) (Ebooks, error) {
row := q.db.QueryRow(ctx, UpdateEbook,
arg.ID,
arg.Title,
arg.Author,
arg.Isbn,
arg.Description,
arg.CoverImagePath,
)
var i Ebooks
err := row.Scan(
&i.ID,
&i.Title,
&i.Author,
&i.Isbn,
&i.Description,
&i.FilePath,
&i.FileSize,
&i.MimeType,
&i.CoverImagePath,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const UpdateReadingProgress = `-- name: UpdateReadingProgress :one
INSERT INTO reading_progress (ebook_id, user_id, current_page, total_pages, last_read_at)
VALUES ($1, $2, $3, $4, NOW())
ON CONFLICT (ebook_id, user_id)
DO UPDATE SET
current_page = EXCLUDED.current_page,
total_pages = EXCLUDED.total_pages,
last_read_at = NOW()
RETURNING id, ebook_id, user_id, current_page, total_pages, last_read_at
`
type UpdateReadingProgressParams struct {
EbookID pgtype.UUID `db:"ebook_id" json:"ebook_id"`
UserID pgtype.UUID `db:"user_id" json:"user_id"`
CurrentPage pgtype.Int4 `db:"current_page" json:"current_page"`
TotalPages pgtype.Int4 `db:"total_pages" json:"total_pages"`
}
func (q *Queries) UpdateReadingProgress(ctx context.Context, arg UpdateReadingProgressParams) (ReadingProgress, error) {
row := q.db.QueryRow(ctx, UpdateReadingProgress,
arg.EbookID,
arg.UserID,
arg.CurrentPage,
arg.TotalPages,
)
var i ReadingProgress
err := row.Scan(
&i.ID,
&i.EbookID,
&i.UserID,
&i.CurrentPage,
&i.TotalPages,
&i.LastReadAt,
)
return i, err
}
@@ -0,0 +1,57 @@
-- name: CreateUser :one
INSERT INTO users (email, username, password_hash)
VALUES ($1, $2, $3)
RETURNING *;
-- name: GetUserByEmail :one
SELECT * FROM users WHERE email = $1;
-- name: GetUserByUsername :one
SELECT * FROM users WHERE username = $1;
-- name: GetUserByEmailOrUsername :one
SELECT * FROM users WHERE email = $1 OR username = $1;
-- name: GetUser :one
SELECT id, email, username, created_at, updated_at FROM users WHERE id = $1;
-- name: GetEbook :one
SELECT * FROM ebooks WHERE id = $1;
-- name: ListEbooks :many
SELECT * FROM ebooks ORDER BY created_at DESC LIMIT $1 OFFSET $2;
-- name: CreateEbook :one
INSERT INTO ebooks (title, author, isbn, description, file_path, file_size, mime_type, cover_image_path)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
RETURNING *;
-- name: UpdateEbook :one
UPDATE ebooks SET
title = $2,
author = $3,
isbn = $4,
description = $5,
cover_image_path = $6,
updated_at = NOW()
WHERE id = $1
RETURNING *;
-- name: DeleteEbook :exec
DELETE FROM ebooks WHERE id = $1;
-- name: GetReadingProgress :one
SELECT * FROM reading_progress WHERE ebook_id = $1 AND user_id = $2;
-- name: UpdateReadingProgress :one
INSERT INTO reading_progress (ebook_id, user_id, current_page, total_pages, last_read_at)
VALUES ($1, $2, $3, $4, NOW())
ON CONFLICT (ebook_id, user_id)
DO UPDATE SET
current_page = EXCLUDED.current_page,
total_pages = EXCLUDED.total_pages,
last_read_at = NOW()
RETURNING *;
-- name: DeleteReadingProgress :exec
DELETE FROM reading_progress WHERE ebook_id = $1 AND user_id = $2;
+166
View File
@@ -0,0 +1,166 @@
package handlers
import (
"bookmann/internal/database"
"net/http"
"time"
"github.com/golang-jwt/jwt/v5"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgtype"
"github.com/labstack/echo/v4"
"golang.org/x/crypto/bcrypt"
)
type AuthHandler struct {
db *database.Queries
jwtKey []byte
}
func NewAuthHandler(db *database.Queries, jwtSecret string) *AuthHandler {
return &AuthHandler{
db: db,
jwtKey: []byte(jwtSecret),
}
}
type RegisterRequest struct {
Email string `json:"email" validate:"required,email"`
Username string `json:"username" validate:"required,min=3,max=50"`
Password string `json:"password" validate:"required,min=6"`
}
type LoginRequest struct {
Login string `json:"login" validate:"required"` // email or username
Password string `json:"password" validate:"required"`
}
type AuthResponse struct {
Token string `json:"token"`
User UserProfile `json:"user"`
}
type UserProfile struct {
ID string `json:"id"`
Email string `json:"email"`
Username string `json:"username"`
}
// Register handles POST /api/auth/register
func (h *AuthHandler) Register(c echo.Context) error {
var req RegisterRequest
if err := c.Bind(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"})
}
if err := c.Validate(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
}
// Check if user already exists
if _, err := h.db.GetUserByEmail(c.Request().Context(), req.Email); err == nil {
return c.JSON(http.StatusConflict, map[string]string{"error": "email already exists"})
}
if _, err := h.db.GetUserByUsername(c.Request().Context(), req.Username); err == nil {
return c.JSON(http.StatusConflict, map[string]string{"error": "username already exists"})
}
// Hash password
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to hash password"})
}
// Create user
user, err := h.db.CreateUser(c.Request().Context(), database.CreateUserParams{
Email: req.Email,
Username: req.Username,
PasswordHash: string(hashedPassword),
})
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
// Generate JWT
token, err := h.generateJWT(uuid.UUID(user.ID.Bytes).String())
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to generate token"})
}
return c.JSON(http.StatusCreated, AuthResponse{
Token: token,
User: UserProfile{
ID: uuid.UUID(user.ID.Bytes).String(),
Email: user.Email,
Username: user.Username,
},
})
}
// Login handles POST /api/auth/login
func (h *AuthHandler) Login(c echo.Context) error {
var req LoginRequest
if err := c.Bind(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"})
}
if err := c.Validate(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
}
// Get user by email or username
user, err := h.db.GetUserByEmailOrUsername(c.Request().Context(), req.Login)
if err != nil {
return c.JSON(http.StatusUnauthorized, map[string]string{"error": "invalid credentials"})
}
// Check password
if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(req.Password)); err != nil {
return c.JSON(http.StatusUnauthorized, map[string]string{"error": "invalid credentials"})
}
// Generate JWT
token, err := h.generateJWT(uuid.UUID(user.ID.Bytes).String())
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to generate token"})
}
return c.JSON(http.StatusOK, AuthResponse{
Token: token,
User: UserProfile{
ID: uuid.UUID(user.ID.Bytes).String(),
Email: user.Email,
Username: user.Username,
},
})
}
// GetProfile handles GET /api/auth/profile
func (h *AuthHandler) GetProfile(c echo.Context) error {
userID := c.Get("user_id").(string)
userUUID, err := uuid.Parse(userID)
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid user id"})
}
user, err := h.db.GetUser(c.Request().Context(), pgtype.UUID{Bytes: userUUID, Valid: true})
if err != nil {
return c.JSON(http.StatusNotFound, map[string]string{"error": "user not found"})
}
return c.JSON(http.StatusOK, UserProfile{
ID: uuid.UUID(user.ID.Bytes).String(),
Email: user.Email,
Username: user.Username,
})
}
func (h *AuthHandler) generateJWT(userID string) (string, error) {
claims := jwt.MapClaims{
"user_id": userID,
"exp": time.Now().Add(24 * time.Hour).Unix(),
"iat": time.Now().Unix(),
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
return token.SignedString(h.jwtKey)
}
+252
View File
@@ -0,0 +1,252 @@
package handlers
import (
"bookmann/internal/database"
"net/http"
"strconv"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgtype"
"github.com/labstack/echo/v4"
)
type Handler struct {
db *database.Queries
}
func NewHandler(db *database.Queries) *Handler {
return &Handler{
db: db,
}
}
func SetupRoutes(g *echo.Group, db *database.Queries) {
h := NewHandler(db)
g.GET("/ebooks", h.ListEbooks)
g.GET("/ebooks/:id", h.GetEbook)
g.POST("/ebooks", h.CreateEbook)
g.PUT("/ebooks/:id", h.UpdateEbook)
g.DELETE("/ebooks/:id", h.DeleteEbook)
g.GET("/ebooks/:id/progress", h.GetReadingProgress)
g.PUT("/ebooks/:id/progress", h.UpdateReadingProgress)
}
// ListEbooks handles GET /api/ebooks
func (h *Handler) ListEbooks(c echo.Context) error {
limitStr := c.QueryParam("limit")
offsetStr := c.QueryParam("offset")
limit := int32(20) // default
if limitStr != "" {
if l, err := strconv.Atoi(limitStr); err == nil {
limit = int32(l)
}
}
offset := int32(0)
if offsetStr != "" {
if o, err := strconv.Atoi(offsetStr); err == nil {
offset = int32(o)
}
}
ebooks, err := h.db.ListEbooks(c.Request().Context(), database.ListEbooksParams{
Limit: limit,
Offset: offset,
})
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
return c.JSON(http.StatusOK, ebooks)
}
// GetEbook handles GET /api/ebooks/:id
func (h *Handler) GetEbook(c echo.Context) error {
idStr := c.Param("id")
id, err := uuid.Parse(idStr)
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid id"})
}
ebook, err := h.db.GetEbook(c.Request().Context(), pgtype.UUID{Bytes: id, Valid: true})
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
return c.JSON(http.StatusOK, ebook)
}
// CreateEbookRequest represents the request for creating an ebook
type CreateEbookRequest struct {
Title string `json:"title" validate:"required,min=1,max=500"`
Author string `json:"author"`
ISBN string `json:"isbn"`
Description string `json:"description"`
FilePath string `json:"file_path" validate:"required"`
FileSize int64 `json:"file_size" validate:"required,min=1"`
MimeType string `json:"mime_type" validate:"required"`
CoverImagePath string `json:"cover_image_path"`
}
// CreateEbook handles POST /api/ebooks
func (h *Handler) CreateEbook(c echo.Context) error {
var req CreateEbookRequest
if err := c.Bind(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"})
}
if err := c.Validate(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
}
ebook, err := h.db.CreateEbook(c.Request().Context(), database.CreateEbookParams{
Title: req.Title,
Author: pgtype.Text{String: req.Author, Valid: req.Author != ""},
Isbn: pgtype.Text{String: req.ISBN, Valid: req.ISBN != ""},
Description: pgtype.Text{String: req.Description, Valid: req.Description != ""},
FilePath: req.FilePath,
FileSize: pgtype.Int8{Int64: req.FileSize, Valid: req.FileSize > 0},
MimeType: pgtype.Text{String: req.MimeType, Valid: req.MimeType != ""},
CoverImagePath: pgtype.Text{String: req.CoverImagePath, Valid: req.CoverImagePath != ""},
})
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
return c.JSON(http.StatusCreated, ebook)
}
// UpdateEbookRequest represents the request for updating an ebook
type UpdateEbookRequest struct {
Title string `json:"title" validate:"required,min=1,max=500"`
Author string `json:"author"`
ISBN string `json:"isbn"`
Description string `json:"description"`
CoverImagePath string `json:"cover_image_path"`
}
// UpdateEbook handles PUT /api/ebooks/:id
func (h *Handler) UpdateEbook(c echo.Context) error {
idStr := c.Param("id")
id, err := uuid.Parse(idStr)
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid id"})
}
var req UpdateEbookRequest
if err := c.Bind(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"})
}
if err := c.Validate(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
}
ebook, err := h.db.UpdateEbook(c.Request().Context(), database.UpdateEbookParams{
ID: pgtype.UUID{Bytes: id, Valid: true},
Title: req.Title,
Author: pgtype.Text{String: req.Author, Valid: req.Author != ""},
Isbn: pgtype.Text{String: req.ISBN, Valid: req.ISBN != ""},
Description: pgtype.Text{String: req.Description, Valid: req.Description != ""},
CoverImagePath: pgtype.Text{String: req.CoverImagePath, Valid: req.CoverImagePath != ""},
})
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
return c.JSON(http.StatusOK, ebook)
}
// DeleteEbook handles DELETE /api/ebooks/:id
func (h *Handler) DeleteEbook(c echo.Context) error {
idStr := c.Param("id")
id, err := uuid.Parse(idStr)
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid id"})
}
err = h.db.DeleteEbook(c.Request().Context(), pgtype.UUID{Bytes: id, Valid: true})
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
return c.NoContent(http.StatusNoContent)
}
// GetReadingProgress handles GET /api/ebooks/:id/progress
func (h *Handler) GetReadingProgress(c echo.Context) error {
ebookIdStr := c.Param("id")
userID := c.Get("user_id").(string)
ebookId, err := uuid.Parse(ebookIdStr)
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid ebook id"})
}
userUUID, err := uuid.Parse(userID)
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid user"})
}
progress, err := h.db.GetReadingProgress(c.Request().Context(), database.GetReadingProgressParams{
EbookID: pgtype.UUID{Bytes: ebookId, Valid: true},
UserID: pgtype.UUID{Bytes: userUUID, Valid: true},
})
if err != nil {
// If no progress found, return default
return c.JSON(http.StatusOK, map[string]interface{}{
"ebook_id": ebookIdStr,
"user_id": userID,
"current_page": 0,
"total_pages": nil,
})
}
return c.JSON(http.StatusOK, progress)
}
// UpdateReadingProgressRequest represents the request for updating reading progress
type UpdateReadingProgressRequest struct {
CurrentPage int32 `json:"current_page" validate:"required,min=0"`
TotalPages int32 `json:"total_pages" validate:"omitempty,min=1"`
}
// UpdateReadingProgress handles PUT /api/ebooks/:id/progress
func (h *Handler) UpdateReadingProgress(c echo.Context) error {
ebookIdStr := c.Param("id")
userID := c.Get("user_id").(string)
ebookId, err := uuid.Parse(ebookIdStr)
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid ebook id"})
}
userUUID, err := uuid.Parse(userID)
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid user"})
}
var req UpdateReadingProgressRequest
if err := c.Bind(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"})
}
if err := c.Validate(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
}
progress, err := h.db.UpdateReadingProgress(c.Request().Context(), database.UpdateReadingProgressParams{
EbookID: pgtype.UUID{Bytes: ebookId, Valid: true},
UserID: pgtype.UUID{Bytes: userUUID, Valid: true},
CurrentPage: pgtype.Int4{Int32: req.CurrentPage, Valid: true},
TotalPages: pgtype.Int4{Int32: req.TotalPages, Valid: req.TotalPages > 0},
})
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
return c.JSON(http.StatusOK, progress)
}
+1
View File
@@ -0,0 +1 @@
01f32890b5356f7cc554a887ad06bc0a298c4dd3
@@ -0,0 +1,43 @@
-- Create users table
CREATE TABLE users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
email VARCHAR(255) UNIQUE NOT NULL,
username VARCHAR(255) UNIQUE NOT NULL,
password_hash VARCHAR(255) NOT NULL,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
-- Create ebooks table
CREATE TABLE ebooks (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
title VARCHAR(255) NOT NULL,
author VARCHAR(255),
isbn VARCHAR(13),
description TEXT,
file_path VARCHAR(500) NOT NULL,
file_size BIGINT,
mime_type VARCHAR(100),
cover_image_path VARCHAR(500),
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
-- Create reading_progress table
CREATE TABLE reading_progress (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
ebook_id UUID NOT NULL REFERENCES ebooks(id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
current_page INTEGER DEFAULT 0,
total_pages INTEGER,
last_read_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
UNIQUE(ebook_id, user_id)
);
-- Create indexes
CREATE INDEX idx_users_email ON users(email);
CREATE INDEX idx_users_username ON users(username);
CREATE INDEX idx_ebooks_title ON ebooks(title);
CREATE INDEX idx_ebooks_author ON ebooks(author);
CREATE INDEX idx_reading_progress_ebook_id ON reading_progress(ebook_id);
CREATE INDEX idx_reading_progress_user_id ON reading_progress(user_id);
+977
View File
@@ -0,0 +1,977 @@
{
"name": "backend",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "backend",
"version": "1.0.0",
"dependencies": {
"bcryptjs": "^2.4.3",
"body-parser": "^1.20.2",
"cors": "^2.8.5",
"express": "^4.18.2",
"jsonwebtoken": "^9.0.2"
}
},
"node_modules/accepts": {
"version": "1.3.8",
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
"integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==",
"license": "MIT",
"dependencies": {
"mime-types": "~2.1.34",
"negotiator": "0.6.3"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/array-flatten": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
"license": "MIT"
},
"node_modules/bcryptjs": {
"version": "2.4.3",
"resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-2.4.3.tgz",
"integrity": "sha512-V/Hy/X9Vt7f3BbPJEi8BdVFMByHi+jNXrYkW3huaybV/kQ0KJg0Y6PkEMbn+zeT+i+SiKZ/HMqJGIIt4LZDqNQ==",
"license": "MIT"
},
"node_modules/body-parser": {
"version": "1.20.4",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz",
"integrity": "sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==",
"license": "MIT",
"dependencies": {
"bytes": "~3.1.2",
"content-type": "~1.0.5",
"debug": "2.6.9",
"depd": "2.0.0",
"destroy": "~1.2.0",
"http-errors": "~2.0.1",
"iconv-lite": "~0.4.24",
"on-finished": "~2.4.1",
"qs": "~6.14.0",
"raw-body": "~2.5.3",
"type-is": "~1.6.18",
"unpipe": "~1.0.0"
},
"engines": {
"node": ">= 0.8",
"npm": "1.2.8000 || >= 1.4.16"
}
},
"node_modules/buffer-equal-constant-time": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
"license": "BSD-3-Clause"
},
"node_modules/bytes": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
"integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/call-bind-apply-helpers": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"function-bind": "^1.1.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/call-bound": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
"integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
"get-intrinsic": "^1.3.0"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/content-disposition": {
"version": "0.5.4",
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
"integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==",
"license": "MIT",
"dependencies": {
"safe-buffer": "5.2.1"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/content-type": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz",
"integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/cookie": {
"version": "0.7.2",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
"integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/cookie-signature": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz",
"integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==",
"license": "MIT"
},
"node_modules/cors": {
"version": "2.8.5",
"resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz",
"integrity": "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==",
"license": "MIT",
"dependencies": {
"object-assign": "^4",
"vary": "^1"
},
"engines": {
"node": ">= 0.10"
}
},
"node_modules/debug": {
"version": "2.6.9",
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
"integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
"license": "MIT",
"dependencies": {
"ms": "2.0.0"
}
},
"node_modules/depd": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
"integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/destroy": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz",
"integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==",
"license": "MIT",
"engines": {
"node": ">= 0.8",
"npm": "1.2.8000 || >= 1.4.16"
}
},
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.1",
"es-errors": "^1.3.0",
"gopd": "^1.2.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/ecdsa-sig-formatter": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
"integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==",
"license": "Apache-2.0",
"dependencies": {
"safe-buffer": "^5.0.1"
}
},
"node_modules/ee-first": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
"integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
"license": "MIT"
},
"node_modules/encodeurl": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
"integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/es-define-property": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-errors": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-object-atoms": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
"integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/escape-html": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
"integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
"license": "MIT"
},
"node_modules/etag": {
"version": "1.8.1",
"resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
"integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/express": {
"version": "4.22.1",
"resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz",
"integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==",
"license": "MIT",
"dependencies": {
"accepts": "~1.3.8",
"array-flatten": "1.1.1",
"body-parser": "~1.20.3",
"content-disposition": "~0.5.4",
"content-type": "~1.0.4",
"cookie": "~0.7.1",
"cookie-signature": "~1.0.6",
"debug": "2.6.9",
"depd": "2.0.0",
"encodeurl": "~2.0.0",
"escape-html": "~1.0.3",
"etag": "~1.8.1",
"finalhandler": "~1.3.1",
"fresh": "~0.5.2",
"http-errors": "~2.0.0",
"merge-descriptors": "1.0.3",
"methods": "~1.1.2",
"on-finished": "~2.4.1",
"parseurl": "~1.3.3",
"path-to-regexp": "~0.1.12",
"proxy-addr": "~2.0.7",
"qs": "~6.14.0",
"range-parser": "~1.2.1",
"safe-buffer": "5.2.1",
"send": "~0.19.0",
"serve-static": "~1.16.2",
"setprototypeof": "1.2.0",
"statuses": "~2.0.1",
"type-is": "~1.6.18",
"utils-merge": "1.0.1",
"vary": "~1.1.2"
},
"engines": {
"node": ">= 0.10.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/finalhandler": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz",
"integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==",
"license": "MIT",
"dependencies": {
"debug": "2.6.9",
"encodeurl": "~2.0.0",
"escape-html": "~1.0.3",
"on-finished": "~2.4.1",
"parseurl": "~1.3.3",
"statuses": "~2.0.2",
"unpipe": "~1.0.0"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/forwarded": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
"integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/fresh": {
"version": "0.5.2",
"resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz",
"integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/function-bind": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/get-intrinsic": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
"es-define-property": "^1.0.1",
"es-errors": "^1.3.0",
"es-object-atoms": "^1.1.1",
"function-bind": "^1.1.2",
"get-proto": "^1.0.1",
"gopd": "^1.2.0",
"has-symbols": "^1.1.0",
"hasown": "^2.0.2",
"math-intrinsics": "^1.1.0"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/get-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
"license": "MIT",
"dependencies": {
"dunder-proto": "^1.0.1",
"es-object-atoms": "^1.0.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/gopd": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/has-symbols": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/hasown": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
"integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
"license": "MIT",
"dependencies": {
"function-bind": "^1.1.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/http-errors": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
"integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==",
"license": "MIT",
"dependencies": {
"depd": "~2.0.0",
"inherits": "~2.0.4",
"setprototypeof": "~1.2.0",
"statuses": "~2.0.2",
"toidentifier": "~1.0.1"
},
"engines": {
"node": ">= 0.8"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/iconv-lite": {
"version": "0.4.24",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
"integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",
"license": "MIT",
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/inherits": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC"
},
"node_modules/ipaddr.js": {
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
"integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",
"license": "MIT",
"engines": {
"node": ">= 0.10"
}
},
"node_modules/jsonwebtoken": {
"version": "9.0.3",
"resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz",
"integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==",
"license": "MIT",
"dependencies": {
"jws": "^4.0.1",
"lodash.includes": "^4.3.0",
"lodash.isboolean": "^3.0.3",
"lodash.isinteger": "^4.0.4",
"lodash.isnumber": "^3.0.3",
"lodash.isplainobject": "^4.0.6",
"lodash.isstring": "^4.0.1",
"lodash.once": "^4.0.0",
"ms": "^2.1.1",
"semver": "^7.5.4"
},
"engines": {
"node": ">=12",
"npm": ">=6"
}
},
"node_modules/jsonwebtoken/node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
"node_modules/jwa": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz",
"integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==",
"license": "MIT",
"dependencies": {
"buffer-equal-constant-time": "^1.0.1",
"ecdsa-sig-formatter": "1.0.11",
"safe-buffer": "^5.0.1"
}
},
"node_modules/jws": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz",
"integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==",
"license": "MIT",
"dependencies": {
"jwa": "^2.0.1",
"safe-buffer": "^5.0.1"
}
},
"node_modules/lodash.includes": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
"integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==",
"license": "MIT"
},
"node_modules/lodash.isboolean": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz",
"integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==",
"license": "MIT"
},
"node_modules/lodash.isinteger": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz",
"integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==",
"license": "MIT"
},
"node_modules/lodash.isnumber": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz",
"integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==",
"license": "MIT"
},
"node_modules/lodash.isplainobject": {
"version": "4.0.6",
"resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz",
"integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==",
"license": "MIT"
},
"node_modules/lodash.isstring": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz",
"integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==",
"license": "MIT"
},
"node_modules/lodash.once": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz",
"integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==",
"license": "MIT"
},
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/media-typer": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
"integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/merge-descriptors": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz",
"integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/methods": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz",
"integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/mime": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz",
"integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==",
"license": "MIT",
"bin": {
"mime": "cli.js"
},
"engines": {
"node": ">=4"
}
},
"node_modules/mime-db": {
"version": "1.52.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/mime-types": {
"version": "2.1.35",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
"license": "MIT",
"dependencies": {
"mime-db": "1.52.0"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/ms": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
"license": "MIT"
},
"node_modules/negotiator": {
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
"integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/object-assign": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/object-inspect": {
"version": "1.13.4",
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
"integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/on-finished": {
"version": "2.4.1",
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
"integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==",
"license": "MIT",
"dependencies": {
"ee-first": "1.1.1"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/parseurl": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
"integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/path-to-regexp": {
"version": "0.1.12",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz",
"integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==",
"license": "MIT"
},
"node_modules/proxy-addr": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
"integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==",
"license": "MIT",
"dependencies": {
"forwarded": "0.2.0",
"ipaddr.js": "1.9.1"
},
"engines": {
"node": ">= 0.10"
}
},
"node_modules/qs": {
"version": "6.14.1",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz",
"integrity": "sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==",
"license": "BSD-3-Clause",
"dependencies": {
"side-channel": "^1.1.0"
},
"engines": {
"node": ">=0.6"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/range-parser": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
"integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/raw-body": {
"version": "2.5.3",
"resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz",
"integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==",
"license": "MIT",
"dependencies": {
"bytes": "~3.1.2",
"http-errors": "~2.0.1",
"iconv-lite": "~0.4.24",
"unpipe": "~1.0.0"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/safe-buffer": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/safer-buffer": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"license": "MIT"
},
"node_modules/semver": {
"version": "7.7.3",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz",
"integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==",
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/send": {
"version": "0.19.2",
"resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz",
"integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==",
"license": "MIT",
"dependencies": {
"debug": "2.6.9",
"depd": "2.0.0",
"destroy": "1.2.0",
"encodeurl": "~2.0.0",
"escape-html": "~1.0.3",
"etag": "~1.8.1",
"fresh": "~0.5.2",
"http-errors": "~2.0.1",
"mime": "1.6.0",
"ms": "2.1.3",
"on-finished": "~2.4.1",
"range-parser": "~1.2.1",
"statuses": "~2.0.2"
},
"engines": {
"node": ">= 0.8.0"
}
},
"node_modules/send/node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
"node_modules/serve-static": {
"version": "1.16.3",
"resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz",
"integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==",
"license": "MIT",
"dependencies": {
"encodeurl": "~2.0.0",
"escape-html": "~1.0.3",
"parseurl": "~1.3.3",
"send": "~0.19.1"
},
"engines": {
"node": ">= 0.8.0"
}
},
"node_modules/setprototypeof": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
"license": "ISC"
},
"node_modules/side-channel": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
"integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"object-inspect": "^1.13.3",
"side-channel-list": "^1.0.0",
"side-channel-map": "^1.0.1",
"side-channel-weakmap": "^1.0.2"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/side-channel-list": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz",
"integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"object-inspect": "^1.13.3"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/side-channel-map": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
"integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
"license": "MIT",
"dependencies": {
"call-bound": "^1.0.2",
"es-errors": "^1.3.0",
"get-intrinsic": "^1.2.5",
"object-inspect": "^1.13.3"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/side-channel-weakmap": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
"integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
"license": "MIT",
"dependencies": {
"call-bound": "^1.0.2",
"es-errors": "^1.3.0",
"get-intrinsic": "^1.2.5",
"object-inspect": "^1.13.3",
"side-channel-map": "^1.0.1"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/statuses": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
"integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/toidentifier": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
"integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",
"license": "MIT",
"engines": {
"node": ">=0.6"
}
},
"node_modules/type-is": {
"version": "1.6.18",
"resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
"integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==",
"license": "MIT",
"dependencies": {
"media-typer": "0.3.0",
"mime-types": "~2.1.24"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/unpipe": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
"integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/utils-merge": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
"integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==",
"license": "MIT",
"engines": {
"node": ">= 0.4.0"
}
},
"node_modules/vary": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
"integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
}
}
}
+16
View File
@@ -0,0 +1,16 @@
{
"name": "backend",
"version": "1.0.0",
"main": "server.js",
"scripts": {
"start": "node server.js",
"dev": "node server.js"
},
"dependencies": {
"express": "^4.18.2",
"bcryptjs": "^2.4.3",
"jsonwebtoken": "^9.0.2",
"cors": "^2.8.5",
"body-parser": "^1.20.2"
}
}
+1
View File
@@ -0,0 +1 @@
a6aeff4b597d6c2bdc67497032e46b85148c773e
+107
View File
@@ -0,0 +1,107 @@
const express = require('express');
const bcrypt = require('bcryptjs');
const jwt = require('jsonwebtoken');
const cors = require('cors');
const bodyParser = require('body-parser');
const app = express();
const PORT = 8080;
const JWT_SECRET = 'your-secret-key'; // In production, use env var
app.use(cors());
app.use(bodyParser.json());
// In-memory user store (for demo purposes)
const users = [];
// Password validation regex
const passwordRegex = /^(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]{8,}$/;
// Middleware to verify token
function authenticateToken(req, res, next) {
const authHeader = req.headers['authorization'];
const token = authHeader && authHeader.split(' ')[1];
if (!token) return res.status(401).json({ error: 'Access token required' });
jwt.verify(token, JWT_SECRET, (err, user) => {
if (err) return res.status(403).json({ error: 'Invalid token' });
req.user = user;
next();
});
}
// Auth routes
app.post('/api/auth/register', async (req, res) => {
const { email, username, password } = req.body;
if (!email || !username || !password) {
return res.status(400).json({ error: 'All fields are required' });
}
// Check if password meets requirements
if (!passwordRegex.test(password)) {
return res.status(400).json({
error: 'Password must be at least 8 characters and include at least one uppercase letter, one number, and one symbol'
});
}
// Check if user already exists
const existingUser = users.find(u => u.email === email || u.username === username);
if (existingUser) {
return res.status(400).json({ error: 'User already exists' });
}
try {
const passwordHash = await bcrypt.hash(password, 10);
const user = { id: users.length + 1, email, username, passwordHash };
users.push(user);
const token = jwt.sign({ id: user.id, email: user.email, username: user.username }, JWT_SECRET);
res.json({ token, user: { id: user.id, email: user.email, username: user.username } });
} catch (error) {
res.status(500).json({ error: 'Registration failed' });
}
});
app.post('/api/auth/login', async (req, res) => {
const { login, password } = req.body; // login can be email or username
if (!login || !password) {
return res.status(400).json({ error: 'Username and password are required' });
}
const user = users.find(u => u.email === login || u.username === login);
if (!user) {
return res.status(401).json({ error: 'Invalid credentials' });
}
try {
const isValid = await bcrypt.compare(password, user.passwordHash);
if (!isValid) {
return res.status(401).json({ error: 'Invalid credentials' });
}
const token = jwt.sign({ id: user.id, email: user.email, username: user.username }, JWT_SECRET);
res.json({ token, user: { id: user.id, email: user.email, username: user.username } });
} catch (error) {
res.status(500).json({ error: 'Login failed' });
}
});
app.get('/api/auth/profile', authenticateToken, (req, res) => {
res.json(req.user);
});
// Stub routes for ebooks (to avoid errors)
app.get('/api/ebooks', authenticateToken, (req, res) => {
res.json([]);
});
app.get('/api/ebooks/:id', authenticateToken, (req, res) => {
res.json({ id: req.params.id, title: 'Stub' });
});
app.listen(PORT, () => {
console.log(`Server running on http://localhost:${PORT}`);
});
+17
View File
@@ -0,0 +1,17 @@
version: "2"
sql:
- engine: "postgresql"
schema: "migrations"
queries: "internal/database/queries"
gen:
go:
package: "database"
out: "internal/database"
sql_package: "pgx/v5"
emit_db_tags: true
emit_prepared_queries: true
emit_interface: true
emit_exact_table_names: true
emit_empty_slices: true
emit_exported_queries: true
emit_json_tags: true
+1
View File
@@ -0,0 +1 @@
export const env={}
@@ -0,0 +1 @@
.static{position:static}.container{width:100%}.mx-auto{margin-inline:auto}.line-clamp-2{-webkit-line-clamp:2;-webkit-box-orient:vertical;display:-webkit-box;overflow:hidden}.line-clamp-3{-webkit-line-clamp:3;-webkit-box-orient:vertical;display:-webkit-box;overflow:hidden}.contents{display:contents}.flex{display:flex}.grid{display:grid}.aspect-\[3\/4\]{aspect-ratio:3/4}.h-full{height:100%}.w-full{width:100%}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.items-center{align-items:center}.justify-center{justify-content:center}.overflow-hidden{overflow:hidden}.object-cover{-o-object-fit:cover;object-fit:cover}.text-center{text-align:center}.transition-shadow{transition-property:box-shadow;transition-timing-function:var(--tw-ease,ease);transition-duration:var(--tw-duration,0s)}
@@ -0,0 +1 @@
.line-clamp-2.svelte-1uha8ag{display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}.line-clamp-3.svelte-1uha8ag{display:-webkit-box;-webkit-line-clamp:3;-webkit-box-orient:vertical;overflow:hidden}
@@ -0,0 +1 @@
import{b as c,h as _,a as o,E as d,r as b,H as E,s as T,c as p,d as f}from"./DnPHkIdi.js";import{B as y}from"./kSfNJqxT.js";function v(t,i,h=!1){_&&o();var e=new y(t),u=h?d:0;function n(a,r){if(_){const l=b(t)===E;if(a===l){var s=T();p(s),e.anchor=s,f(!1),e.ensure(a,r),f(!0);return}}e.ensure(a,r)}c(()=>{var a=!1;i((r,s=!0)=>{a=!0,n(s,r)}),a||n(!1,null)},u)}export{v as i};
@@ -0,0 +1 @@
import{t as o,q as n,C as c,v as l}from"./DnPHkIdi.js";function u(e){throw new Error("https://svelte.dev/e/lifecycle_outside_component")}function r(e){n===null&&u(),c&&n.l!==null?a(n).m.push(e):o(()=>{const t=l(e);if(typeof t=="function")return t})}function a(e){var t=e.l;return t.u??={a:[],b:[],m:[]}}export{r as o};
@@ -0,0 +1 @@
import{h as c,N as f,L as p,g as a,e as g}from"./DnPHkIdi.js";const d=Symbol("is custom element"),h=Symbol("is html");function M(s,r,o,i){var t=A(s);c&&(t[r]=s.getAttribute(r),r==="src"||r==="srcset"||r==="href"&&s.nodeName==="LINK")||t[r]!==(t[r]=o)&&(r==="loading"&&(s[p]=o),o==null?s.removeAttribute(r):typeof o!="string"&&N(s).includes(r)?s[r]=o:s.setAttribute(r,o))}function A(s){return s.__attributes??={[d]:s.nodeName.includes("-"),[h]:s.namespaceURI===f}}var e=new Map;function N(s){var r=s.getAttribute("is")||s.nodeName,o=e.get(r);if(o)return o;e.set(r,o=[]);for(var i,t=s,_=Element.prototype;_!==t;){i=g(t);for(var n in i)i[n].set&&o.push(n);t=a(t)}return o}export{M as s};
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
import{k as d,O as f,a3 as v,a4 as u,a5 as E,a6 as p,h as i,m as o,a7 as h,a as T,a8 as N,c as g}from"./DnPHkIdi.js";function y(r){var n=document.createElement("template");return n.innerHTML=r.replaceAll("<!>","<!---->"),n.content}function a(r,n){var e=u;e.nodes===null&&(e.nodes={start:r,end:n,a:null,t:null})}function A(r,n){var e=(n&E)!==0,l=(n&p)!==0,t,_=!r.startsWith("<!>");return()=>{if(i)return a(o,null),o;t===void 0&&(t=y(_?r:"<!>"+r),e||(t=f(t)));var s=l||v?document.importNode(t,!0):t.cloneNode(!0);if(e){var c=f(s),m=s.lastChild;a(c,m)}else a(s,s);return s}}function M(r=""){if(!i){var n=d(r+"");return a(n,n),n}var e=o;return e.nodeType!==N&&(e.before(e=d()),g(e)),a(e,e),e}function O(){if(i)return a(o,null),o;var r=document.createDocumentFragment(),n=document.createComment(""),e=d();return r.append(n,e),a(n,e),r}function C(r,n){if(i){var e=u;((e.f&h)===0||e.nodes.end===null)&&(e.nodes.end=o),T();return}r!==null&&r.before(n)}const w="5";typeof window<"u"&&((window.__svelte??={}).v??=new Set).add(w);export{C as a,a as b,O as c,A as f,M as t};
@@ -0,0 +1,2 @@
import{aa as U,y as P,V as j,ab as G,v as J,ac as B,ad as W,m as d,h as c,a4 as b,b as K,a as X,Q as x,H as Z,l as _,ae as T,p as k,k as $,af as ee,ag as S,ah as y,ai as C,aj as te,ak as O,q as z,n as se,al as M,am as ie,an as V,ao as re,S as ne,j as A,c as D,ap as ae,s as he,aq as q,ar as oe,E as fe,as as le,at as ue,au as _e,av as de,aw as F,O as ce,ax as pe,a0 as ge,ay as I,d as w,az as ve,a1 as ye,T as me,aA as be,D as Ee,R as Te,aB as we,I as Re}from"./DnPHkIdi.js";import{b as Se}from"./DUCk1qN8.js";function De(t){let e=0,i=j(0),r;return()=>{U()&&(P(i),G(()=>(e===0&&(r=J(()=>t(()=>B(i)))),e+=1,()=>{W(()=>{e-=1,e===0&&(r?.(),r=void 0,B(i))})})))}}var Ne=fe|le|ue;function ke(t,e,i){new Ae(t,e,i)}class Ae{parent;is_pending=!1;#t;#v=c?d:null;#i;#l;#r;#s=null;#e=null;#n=null;#a=null;#o=null;#u=0;#h=0;#_=!1;#d=new Set;#c=new Set;#f=null;#b=De(()=>(this.#f=j(this.#u),()=>{this.#f=null}));constructor(e,i,r){this.#t=e,this.#i=i,this.#l=r,this.parent=b.b,this.is_pending=!!this.#i.pending,this.#r=K(()=>{if(b.b=this,c){const s=this.#v;X(),s.nodeType===x&&s.data===Z?this.#T():(this.#E(),this.#h===0&&(this.is_pending=!1))}else{var a=this.#y();try{this.#s=_(()=>r(a))}catch(s){this.error(s)}this.#h>0?this.#g():this.is_pending=!1}return()=>{this.#o?.remove()}},Ne),c&&(this.#t=d)}#E(){try{this.#s=_(()=>this.#l(this.#t))}catch(e){this.error(e)}}#T(){const e=this.#i.pending;e&&(this.#e=_(()=>e(this.#t)),T.enqueue(()=>{var i=this.#y();this.#s=this.#p(()=>(T.ensure(),_(()=>this.#l(i)))),this.#h>0?this.#g():(k(this.#e,()=>{this.#e=null}),this.is_pending=!1)}))}#y(){var e=this.#t;return this.is_pending&&(this.#o=$(),this.#t.before(this.#o),e=this.#o),e}defer_effect(e){ee(e,this.#d,this.#c)}is_rendered(){return!this.is_pending&&(!this.parent||this.parent.is_rendered())}has_pending_snippet(){return!!this.#i.pending}#p(e){var i=b,r=O,a=z;S(this.#r),y(this.#r),C(this.#r.ctx);try{return e()}catch(s){return te(s),null}finally{S(i),y(r),C(a)}}#g(){const e=this.#i.pending;this.#s!==null&&(this.#a=document.createDocumentFragment(),this.#a.append(this.#o),se(this.#s,this.#a)),this.#e===null&&(this.#e=_(()=>e(this.#t)))}#m(e){if(!this.has_pending_snippet()){this.parent&&this.parent.#m(e);return}if(this.#h+=e,this.#h===0){this.is_pending=!1;for(const i of this.#d)M(i,ie),V(i);for(const i of this.#c)M(i,re),V(i);this.#d.clear(),this.#c.clear(),this.#e&&k(this.#e,()=>{this.#e=null}),this.#a&&(this.#t.before(this.#a),this.#a=null)}}update_pending_count(e){this.#m(e),this.#u+=e,this.#f&&ne(this.#f,this.#u)}get_effect_pending(){return this.#b(),P(this.#f)}error(e){var i=this.#i.onerror;let r=this.#i.failed;if(this.#_||!i&&!r)throw e;this.#s&&(A(this.#s),this.#s=null),this.#e&&(A(this.#e),this.#e=null),this.#n&&(A(this.#n),this.#n=null),c&&(D(this.#v),ae(),D(he()));var a=!1,s=!1;const o=()=>{if(a){_e();return}a=!0,s&&oe(),T.ensure(),this.#u=0,this.#n!==null&&k(this.#n,()=>{this.#n=null}),this.is_pending=this.has_pending_snippet(),this.#s=this.#p(()=>(this.#_=!1,_(()=>this.#l(this.#t)))),this.#h>0?this.#g():this.is_pending=!1};var l=O;try{y(null),s=!0,i?.(e,o),s=!1}catch(f){q(f,this.#r&&this.#r.parent)}finally{y(l)}r&&W(()=>{this.#n=this.#p(()=>{T.ensure(),this.#_=!0;try{return _(()=>{r(this.#t,()=>e,()=>o)})}catch(f){return q(f,this.#r.parent),null}finally{this.#_=!1}})})}}const Oe=["touchstart","touchmove"];function Fe(t){return Oe.includes(t)}const Ie=new Set,H=new Set;let L=null;function R(t){var e=this,i=e.ownerDocument,r=t.type,a=t.composedPath?.()||[],s=a[0]||t.target;L=t;var o=0,l=L===t&&t.__root;if(l){var f=a.indexOf(l);if(f!==-1&&(e===document||e===window)){t.__root=e;return}var p=a.indexOf(e);if(p===-1)return;f<=p&&(o=f)}if(s=a[o]||t.target,s!==e){de(t,"currentTarget",{configurable:!0,get(){return s||i}});var N=O,u=b;y(null),S(null);try{for(var n,h=[];s!==null;){var g=s.assignedSlot||s.parentNode||s.host||null;try{var m=s["__"+r];m!=null&&(!s.disabled||t.target===s)&&m.call(s,t)}catch(E){n?h.push(E):n=E}if(t.cancelBubble||g===e||g===null)break;s=g}if(n){for(let E of h)queueMicrotask(()=>{throw E});throw n}}finally{t.__root=e,delete t.currentTarget,y(N),S(u)}}}function Ce(t,e){var i=e==null?"":typeof e=="object"?e+"":e;i!==(t.__t??=t.nodeValue)&&(t.__t=i,t.nodeValue=i+"")}function Ye(t,e){return Q(t,e)}function Me(t,e){F(),e.intro=e.intro??!1;const i=e.target,r=c,a=d;try{for(var s=ce(i);s&&(s.nodeType!==x||s.data!==pe);)s=ge(s);if(!s)throw I;w(!0),D(s);const o=Q(t,{...e,anchor:s});return w(!1),o}catch(o){if(o instanceof Error&&o.message.split(`
`).some(l=>l.startsWith("https://svelte.dev/e/")))throw o;return o!==I&&console.warn("Failed to hydrate: ",o),e.recover===!1&&ve(),F(),ye(i),w(!1),Ye(t,e)}finally{w(r),D(a)}}const v=new Map;function Q(t,{target:e,anchor:i,props:r={},events:a,context:s,intro:o=!0}){F();var l=new Set,f=u=>{for(var n=0;n<u.length;n++){var h=u[n];if(!l.has(h)){l.add(h);var g=Fe(h);e.addEventListener(h,R,{passive:g});var m=v.get(h);m===void 0?(document.addEventListener(h,R,{passive:g}),v.set(h,1)):v.set(h,m+1)}}};f(me(Ie)),H.add(f);var p=void 0,N=be(()=>{var u=i??e.appendChild($());return ke(u,{pending:()=>{}},n=>{if(s){Ee({});var h=z;h.c=s}if(a&&(r.$$events=a),c&&Se(n,null),p=t(n,r)||{},c&&(b.nodes.end=d,d===null||d.nodeType!==x||d.data!==Te))throw we(),I;s&&Re()}),()=>{for(var n of l){e.removeEventListener(n,R);var h=v.get(n);--h===0?(document.removeEventListener(n,R),v.delete(n)):v.set(n,h)}H.delete(f),u!==i&&u.parentNode?.removeChild(u)}});return Y.set(p,N),p}let Y=new WeakMap;function Ve(t,e){const i=Y.get(t);return i?(Y.delete(t),i(e)):Promise.resolve()}export{Me as h,Ye as m,Ce as s,Ve as u};
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
import{q as d,u as g,t as c,v as m,w as i,x as b,y as p,z as v,A as y,B as h}from"./DnPHkIdi.js";function x(n=!1){const s=d,e=s.l.u;if(!e)return;let f=()=>v(s.s);if(n){let a=0,t={};const _=y(()=>{let l=!1;const r=s.s;for(const o in r)r[o]!==t[o]&&(t[o]=r[o],l=!0);return l&&a++,a});f=()=>p(_)}e.b.length&&g(()=>{u(s,f),i(e.b)}),c(()=>{const a=m(()=>e.m.map(b));return()=>{for(const t of a)typeof t=="function"&&t()}}),e.a.length&&c(()=>{u(s,f),i(e.a)})}function u(n,s){if(n.l.s)for(const e of n.l.s)p(e);s()}h();export{x as i};
@@ -0,0 +1 @@
import{f as n,i as p,j as o,p as u,k as d,l,h as m,m as _,n as v,o as b}from"./DnPHkIdi.js";class w{anchor;#t=new Map;#s=new Map;#e=new Map;#i=new Set;#a=!0;constructor(e,s=!0){this.anchor=e,this.#a=s}#f=()=>{var e=n;if(this.#t.has(e)){var s=this.#t.get(e),t=this.#s.get(s);if(t)p(t),this.#i.delete(s);else{var a=this.#e.get(s);a&&(this.#s.set(s,a.effect),this.#e.delete(s),a.fragment.lastChild.remove(),this.anchor.before(a.fragment),t=a.effect)}for(const[i,f]of this.#t){if(this.#t.delete(i),i===e)break;const h=this.#e.get(f);h&&(o(h.effect),this.#e.delete(f))}for(const[i,f]of this.#s){if(i===s||this.#i.has(i))continue;const h=()=>{if(Array.from(this.#t.values()).includes(i)){var c=document.createDocumentFragment();v(f,c),c.append(d()),this.#e.set(i,{effect:f,fragment:c})}else o(f);this.#i.delete(i),this.#s.delete(i)};this.#a||!t?(this.#i.add(i),u(f,h,!1)):h()}}};#h=e=>{this.#t.delete(e);const s=Array.from(this.#t.values());for(const[t,a]of this.#e)s.includes(t)||(o(a.effect),this.#e.delete(t))};ensure(e,s){var t=n,a=b();if(s&&!this.#s.has(e)&&!this.#e.has(e))if(a){var i=document.createDocumentFragment(),f=d();i.append(f),this.#e.set(e,{effect:l(()=>s(f)),fragment:i})}else this.#s.set(e,l(()=>s(this.anchor)));if(this.#t.set(t,e),a){for(const[h,r]of this.#s)h===e?t.skipped_effects.delete(r):t.skipped_effects.add(r);for(const[h,r]of this.#e)h===e?t.skipped_effects.delete(r.effect):t.skipped_effects.add(r.effect);t.oncommit(this.#f),t.ondiscard(this.#h)}else m&&(this.anchor=_),this.#f()}}export{w as B};
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
import{l as o,a as r}from"../chunks/CKL1QNnB.js";export{o as load_css,r as start};
@@ -0,0 +1 @@
import{c as p,a as r,f as v}from"../chunks/DUCk1qN8.js";import{b as m,E as g,k as h,a9 as u,h as n,O as _,Q as y,a0 as o,d as f,c,m as w,F as E,G as x}from"../chunks/DnPHkIdi.js";import{B as T}from"../chunks/kSfNJqxT.js";import{s as b}from"../chunks/C9HAc536.js";function A(l,s,...a){var i=new T(l);m(()=>{const t=s()??null;i.ensure(t,t&&(e=>t(e,...a)))},g)}function F(l,s){let a=null,i=n;var t;if(n){a=w;for(var e=_(document.head);e!==null&&(e.nodeType!==y||e.data!==l);)e=o(e);if(e===null)f(!1);else{var d=o(e);e.remove(),c(d)}}n||(t=document.head.appendChild(h()));try{m(()=>s(t),u)}finally{i&&(f(!0),c(a))}}const M="data:image/svg+xml,%3csvg%20xmlns='http://www.w3.org/2000/svg'%20width='107'%20height='128'%20viewBox='0%200%20107%20128'%3e%3ctitle%3esvelte-logo%3c/title%3e%3cpath%20d='M94.157%2022.819c-10.4-14.885-30.94-19.297-45.792-9.835L22.282%2029.608A29.92%2029.92%200%200%200%208.764%2049.65a31.5%2031.5%200%200%200%203.108%2020.231%2030%2030%200%200%200-4.477%2011.183%2031.9%2031.9%200%200%200%205.448%2024.116c10.402%2014.887%2030.942%2019.297%2045.791%209.835l26.083-16.624A29.92%2029.92%200%200%200%2098.235%2078.35a31.53%2031.53%200%200%200-3.105-20.232%2030%2030%200%200%200%204.474-11.182%2031.88%2031.88%200%200%200-5.447-24.116'%20style='fill:%23ff3e00'/%3e%3cpath%20d='M45.817%20106.582a20.72%2020.72%200%200%201-22.237-8.243%2019.17%2019.17%200%200%201-3.277-14.503%2018%2018%200%200%201%20.624-2.435l.49-1.498%201.337.981a33.6%2033.6%200%200%200%2010.203%205.098l.97.294-.09.968a5.85%205.85%200%200%200%201.052%203.878%206.24%206.24%200%200%200%206.695%202.485%205.8%205.8%200%200%200%201.603-.704L69.27%2076.28a5.43%205.43%200%200%200%202.45-3.631%205.8%205.8%200%200%200-.987-4.371%206.24%206.24%200%200%200-6.698-2.487%205.7%205.7%200%200%200-1.6.704l-9.953%206.345a19%2019%200%200%201-5.296%202.326%2020.72%2020.72%200%200%201-22.237-8.243%2019.17%2019.17%200%200%201-3.277-14.502%2017.99%2017.99%200%200%201%208.13-12.052l26.081-16.623a19%2019%200%200%201%205.3-2.329%2020.72%2020.72%200%200%201%2022.237%208.243%2019.17%2019.17%200%200%201%203.277%2014.503%2018%2018%200%200%201-.624%202.435l-.49%201.498-1.337-.98a33.6%2033.6%200%200%200-10.203-5.1l-.97-.294.09-.968a5.86%205.86%200%200%200-1.052-3.878%206.24%206.24%200%200%200-6.696-2.485%205.8%205.8%200%200%200-1.602.704L37.73%2051.72a5.42%205.42%200%200%200-2.449%203.63%205.79%205.79%200%200%200%20.986%204.372%206.24%206.24%200%200%200%206.698%202.486%205.8%205.8%200%200%200%201.602-.704l9.952-6.342a19%2019%200%200%201%205.295-2.328%2020.72%2020.72%200%200%201%2022.237%208.242%2019.17%2019.17%200%200%201%203.277%2014.503%2018%2018%200%200%201-8.13%2012.053l-26.081%2016.622a19%2019%200%200%201-5.3%202.328'%20style='fill:%23fff'/%3e%3c/svg%3e";var k=v('<link rel="icon"/>');function O(l,s){var a=p();F("12qhfyh",t=>{var e=k();x(()=>b(e,"href",M)),r(t,e)});var i=E(a);A(i,()=>s.children),r(l,a)}export{O as component};
@@ -0,0 +1 @@
import{f as u,a as h}from"../chunks/DUCk1qN8.js";import{i as g}from"../chunks/VM9GfMNA.js";import{D as l,F as v,G as d,I as _,J as a,K as e,M as x}from"../chunks/DnPHkIdi.js";import{s as o}from"../chunks/DZQwV0xP.js";import{s as $,p}from"../chunks/CKL1QNnB.js";const k={get error(){return p.error},get status(){return p.status}};$.updated.check;const m=k;var b=u("<h1> </h1> <p> </p>",1);function J(i,f){l(f,!1),g();var t=b(),r=v(t),n=a(r,!0);e(r);var s=x(r,2),c=a(s,!0);e(s),d(()=>{o(n,m.status),o(c,m.error?.message)}),h(i,t),_()}export{J as component};
File diff suppressed because one or more lines are too long
+1
View File
@@ -0,0 +1 @@
{"version":"1768960079047"}
+37
View File
@@ -0,0 +1,37 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="modulepreload" href="/_app/immutable/entry/start.BrST8BG9.js">
<link rel="modulepreload" href="/_app/immutable/chunks/CKL1QNnB.js">
<link rel="modulepreload" href="/_app/immutable/chunks/DnPHkIdi.js">
<link rel="modulepreload" href="/_app/immutable/chunks/BOU_Z_Ye.js">
<link rel="modulepreload" href="/_app/immutable/entry/app.o34c63v3.js">
<link rel="modulepreload" href="/_app/immutable/chunks/DZQwV0xP.js">
<link rel="modulepreload" href="/_app/immutable/chunks/DUCk1qN8.js">
<link rel="modulepreload" href="/_app/immutable/chunks/6OCXa8_L.js">
<link rel="modulepreload" href="/_app/immutable/chunks/kSfNJqxT.js">
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">
<script>
{
__sveltekit_1nk8k67 = {
base: ""
};
const element = document.currentScript.parentElement;
Promise.all([
import("/_app/immutable/entry/start.BrST8BG9.js"),
import("/_app/immutable/entry/app.o34c63v3.js")
]).then(([kit, app]) => {
kit.start(app, element);
});
}
</script>
</div>
</body>
</html>
+3
View File
@@ -0,0 +1,3 @@
# allow crawling everything by default
User-agent: *
Disallow:
+47
View File
@@ -0,0 +1,47 @@
# Bruno API Tests for Ebook Reader
This directory contains Bruno collection for testing the Ebook Reader API with comprehensive REST documentation.
## Setup
1. Install Bruno: https://www.usebruno.com/
2. Open Bruno and import this collection folder
3. Select the "localhost" environment
4. Start the application with `docker-compose up --build`
5. Register/Login first, then use Bearer token for protected endpoints
## Available Tests
### Auth (Public Endpoints)
- **Register User**: POST /api/auth/register - Create new account
- **Login User**: POST /api/auth/login - Authenticate (email or username)
- **Get Profile**: GET /api/auth/profile - Get user info (requires token)
### Ebooks (Protected - JWT Required)
- **List Ebooks**: GET /api/ebooks - Paginated ebook list
- **Get Ebook**: GET /api/ebooks/:id - Single ebook details
- **Create Ebook**: POST /api/ebooks - Add new ebook
- **Update Ebook**: PUT /api/ebooks/:id - Modify ebook metadata
- **Delete Ebook**: DELETE /api/ebooks/:id - Remove ebook
### Reading Progress (Protected - JWT Required)
- **Get Reading Progress**: GET /api/ebooks/:id/progress - User's progress
- **Update Reading Progress**: PUT /api/ebooks/:id/progress - Update progress
## Documentation Features
Each request includes:
- **Detailed descriptions** of functionality
- **Parameter specifications** (required/optional, types)
- **Request/Response examples**
- **Error response codes** and meanings
- **Authentication requirements**
## Notes
- **Authentication Flow**: Register → Login → Use Bearer token for all other requests
- **JWT Tokens**: Valid for 24 hours, include in `Authorization: Bearer <token>` header
- **User Isolation**: Progress and data are user-specific
- **Variables**: Update `ebook_id` for testing specific ebooks
- **Security**: Passwords hashed with bcrypt, unique email/username constraints
- **JSON**: All requests/responses use JSON format
+23
View File
@@ -0,0 +1,23 @@
meta:
name: Get Profile
seq: 3
http:
method: get
url: "{{base_url}}/api/auth/profile"
auth:
type: bearer
docs: |
## Get User Profile
Retrieves the profile of the authenticated user.
**Authentication:** Required (Bearer token)
**Response:**
- `id` (string): User UUID
- `email` (string): User's email address
- `username` (string): User's username
**Error Responses:**
- 401: Invalid or missing JWT token
- 404: User not found
+29
View File
@@ -0,0 +1,29 @@
meta:
name: Login User
seq: 2
http:
method: post
url: "{{base_url}}/api/auth/login"
body:
type: json
data: |
{
"login": "test@example.com",
"password": "password123"
}
docs: |
## Login User
Authenticates a user and returns a JWT token.
**Request Body:**
- `login` (string, required): Email address or username
- `password` (string, required): User's password
**Response:**
- `token` (string): JWT authentication token (24h expiry)
- `user` (object): User profile with id, email, username
**Error Responses:**
- 401: Invalid credentials
- 400: Invalid request data
+31
View File
@@ -0,0 +1,31 @@
meta:
name: Register User
seq: 1
http:
method: post
url: "{{base_url}}/api/auth/register"
body:
type: json
data: |
{
"email": "test@example.com",
"username": "testuser",
"password": "password123"
}
docs: |
## Register User
Creates a new user account with email, username, and password.
**Request Body:**
- `email` (string, required): Valid email address
- `username` (string, required): Unique username (3-50 chars)
- `password` (string, required): Password (min 6 chars)
**Response:**
- `token` (string): JWT authentication token
- `user` (object): User profile with id, email, username
**Error Responses:**
- 409: Email or username already exists
- 400: Invalid request data
+4
View File
@@ -0,0 +1,4 @@
version: "1"
name: "Ebook Reader API"
type: collection
items: []
+43
View File
@@ -0,0 +1,43 @@
meta:
name: Create Ebook
seq: 3
http:
method: post
url: "{{base_url}}/api/ebooks"
auth:
type: bearer
body:
type: json
data: |
{
"title": "Sample Book",
"author": "Sample Author",
"isbn": "1234567890",
"description": "A sample ebook",
"file_path": "/uploads/sample.epub",
"file_size": 1024000,
"mime_type": "application/epub+zip",
"cover_image_path": "/uploads/cover.jpg"
}
docs: |
## Create Ebook
Creates a new ebook entry in the library.
**Authentication:** Required (Bearer token)
**Request Body:**
- `title` (string, required): Book title
- `author` (string, optional): Book author
- `isbn` (string, optional): ISBN number
- `description` (string, optional): Book description
- `file_path` (string, required): Path to ebook file
- `file_size` (number, optional): File size in bytes
- `mime_type` (string, optional): MIME type
- `cover_image_path` (string, optional): Path to cover image
**Response:** Created ebook object
**Error Responses:**
- 401: Invalid authentication
- 400: Invalid request data
+27
View File
@@ -0,0 +1,27 @@
meta:
name: Delete Ebook
seq: 5
http:
method: delete
url: "{{base_url}}/api/ebooks/{{ebook_id}}"
auth:
type: bearer
vars:
pre-request:
- name: ebook_id
value: "123e4567-e89b-12d3-a456-426614174000"
docs: |
## Delete Ebook
Removes an ebook from the library.
**Authentication:** Required (Bearer token)
**Path Parameters:**
- `id` (string): Ebook UUID
**Response:** 204 No Content
**Error Responses:**
- 401: Invalid authentication
- 404: Ebook not found
+27
View File
@@ -0,0 +1,27 @@
meta:
name: Get Ebook
seq: 2
http:
method: get
url: "{{base_url}}/api/ebooks/{{ebook_id}}"
auth:
type: bearer
vars:
pre-request:
- name: ebook_id
value: "123e4567-e89b-12d3-a456-426614174000"
docs: |
## Get Ebook
Retrieves details of a specific ebook.
**Authentication:** Required (Bearer token)
**Path Parameters:**
- `id` (string): Ebook UUID
**Response:** Complete ebook object with all metadata
**Error Responses:**
- 401: Invalid authentication
- 404: Ebook not found
+23
View File
@@ -0,0 +1,23 @@
meta:
name: List Ebooks
seq: 1
http:
method: get
url: "{{base_url}}/api/ebooks"
auth:
type: bearer
docs: |
## List Ebooks
Retrieves a paginated list of ebooks.
**Authentication:** Required (Bearer token)
**Query Parameters:**
- `limit` (number, optional): Number of results (default: 20, max: 100)
- `offset` (number, optional): Pagination offset (default: 0)
**Response:** Array of ebook objects with id, title, author, etc.
**Error Responses:**
- 401: Invalid authentication
+43
View File
@@ -0,0 +1,43 @@
meta:
name: Update Ebook
seq: 4
http:
method: put
url: "{{base_url}}/api/ebooks/{{ebook_id}}"
auth:
type: bearer
body:
type: json
data: |
{
"title": "Updated Book Title",
"author": "Updated Author",
"description": "Updated description"
}
vars:
pre-request:
- name: ebook_id
value: "123e4567-e89b-12d3-a456-426614174000"
docs: |
## Update Ebook
Updates an existing ebook's metadata.
**Authentication:** Required (Bearer token)
**Path Parameters:**
- `id` (string): Ebook UUID
**Request Body:** (all fields optional)
- `title` (string): Book title
- `author` (string): Book author
- `isbn` (string): ISBN number
- `description` (string): Book description
- `cover_image_path` (string): Path to cover image
**Response:** Updated ebook object
**Error Responses:**
- 401: Invalid authentication
- 404: Ebook not found
- 400: Invalid request data
+4
View File
@@ -0,0 +1,4 @@
name: localhost
variables:
- name: base_url
value: http://localhost:8765
+31
View File
@@ -0,0 +1,31 @@
meta:
name: Get Reading Progress
seq: 6
http:
method: get
url: "{{base_url}}/api/ebooks/{{ebook_id}}/progress"
auth:
type: bearer
vars:
pre-request:
- name: ebook_id
value: "123e4567-e89b-12d3-a456-426614174000"
docs: |
## Get Reading Progress
Retrieves the authenticated user's reading progress for an ebook.
**Authentication:** Required (Bearer token)
**Path Parameters:**
- `id` (string): Ebook UUID
**Response:**
- `ebook_id` (string): Ebook UUID
- `user_id` (string): User UUID
- `current_page` (number): Current page number
- `total_pages` (number, nullable): Total pages
- `last_read_at` (string): Last read timestamp
**Error Responses:**
- 401: Invalid authentication
@@ -0,0 +1,38 @@
meta:
name: Update Reading Progress
seq: 7
http:
method: put
url: "{{base_url}}/api/ebooks/{{ebook_id}}/progress"
auth:
type: bearer
body:
type: json
data: |
{
"current_page": 45,
"total_pages": 200
}
vars:
pre-request:
- name: ebook_id
value: "123e4567-e89b-12d3-a456-426614174000"
docs: |
## Update Reading Progress
Updates the authenticated user's reading progress for an ebook.
**Authentication:** Required (Bearer token)
**Path Parameters:**
- `id` (string): Ebook UUID
**Request Body:**
- `current_page` (number, required): Current page number
- `total_pages` (number, optional): Total pages in book
**Response:** Updated progress object
**Error Responses:**
- 401: Invalid authentication
- 400: Invalid request data
+3
View File
@@ -0,0 +1,3 @@
version: "1"
name: "Ebook Reader API Workspace"
type: workspace
+51
View File
@@ -0,0 +1,51 @@
version: "3.8"
services:
db:
image: postgres:15-alpine
environment:
POSTGRES_DB: ebookdb
POSTGRES_USER: postgres
POSTGRES_PASSWORD: ${DBPASS}
volumes:
- postgres_data:/var/lib/postgresql/data
- ./backend/migrations:/docker-entrypoint-initdb.d
ports:
- "5432:5432"
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 5s
timeout: 5s
retries: 5
env_file:
- .env
backend:
build:
context: .
dockerfile: ./backend/Dockerfile
environment:
DATABASE_HOST: db
DATABASE_PORT: 5432
DATABASE_USER: postgres
DATABASE_PASSWORD: ${DBPASS}
DATABASE_NAME: ebookdb
JWT_SECRET: ${JWT_SECRET}
SERVER_PORT: 8765
ports:
- "8765:8765"
depends_on:
db:
condition: service_healthy
volumes:
- ./backend/uploads:/app/uploads
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8765/"]
interval: 30s
timeout: 10s
retries: 3
env_file:
- .env
volumes:
postgres_data:
+23
View File
@@ -0,0 +1,23 @@
node_modules
# Output
.output
.vercel
.netlify
.wrangler
/.svelte-kit
/build
# OS
.DS_Store
Thumbs.db
# Env
.env
.env.*
!.env.example
!.env.test
# Vite
vite.config.js.timestamp-*
vite.config.ts.timestamp-*
+1
View File
@@ -0,0 +1 @@
engine-strict=true
+42
View File
@@ -0,0 +1,42 @@
# sv
Everything you need to build a Svelte project, powered by [`sv`](https://github.com/sveltejs/cli).
## Creating a project
If you're seeing this, you've probably already done this step. Congrats!
```sh
# create a new project
npx sv create my-app
```
To recreate this project with the same configuration:
```sh
# recreate this project
npx sv create --template minimal --types ts --install npm .
```
## Developing
Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server:
```sh
npm run dev
# or start the server and open the app in a new browser tab
npm run dev -- --open
```
## Building
To create a production version of your app:
```sh
npm run build
```
You can preview the production build with `npm run preview`.
> To deploy your app, you may need to install an [adapter](https://svelte.dev/docs/kit/adapters) for your target environment.
+2895
View File
File diff suppressed because it is too large Load Diff
+33
View File
@@ -0,0 +1,33 @@
{
"name": "frontend",
"private": true,
"version": "0.0.1",
"type": "module",
"scripts": {
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview",
"prepare": "svelte-kit sync || echo ''",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch"
},
"devDependencies": {
"@sveltejs/adapter-static": "^3.0.1",
"@sveltejs/kit": "^2.49.1",
"@sveltejs/vite-plugin-svelte": "^6.2.1",
"@tailwindcss/postcss": "^4.1.18",
"@tailwindcss/typography": "^0.5.19",
"autoprefixer": "^10.4.23",
"postcss": "^8.5.6",
"svelte": "^5.45.6",
"svelte-check": "^4.3.4",
"tailwindcss": "^4.1.18",
"typescript": "^5.9.3",
"vite": "^7.2.6"
},
"dependencies": {
"@zerodevx/svelte-toast": "^0.9.6",
"sveltekit-superforms": "^2.29.1",
"zod": "^4.3.5"
}
}
+6
View File
@@ -0,0 +1,6 @@
export default {
plugins: {
'@tailwindcss/postcss': {},
autoprefixer: {},
},
}
+135
View File
@@ -0,0 +1,135 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
html {
background-color: #1a1b26;
color: #a9b1d6;
}
body {
background-color: #1a1b26;
color: #a9b1d6;
font-family: 'Inter', system-ui, sans-serif;
}
/* Scrollbar styling */
::-webkit-scrollbar {
width: 8px;
}
::-webkit-scrollbar-track {
background-color: #16161e;
}
::-webkit-scrollbar-thumb {
background-color: #292e42;
border-radius: 9999px;
}
::-webkit-scrollbar-thumb:hover {
background-color: #565f89;
}
}
@layer components {
/* Custom button styles */
.btn-primary {
background-color: #7aa2f7;
color: white;
font-weight: 500;
padding: 0.5rem 1rem;
border-radius: 0.5rem;
transition: all 0.2s;
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05);
}
.btn-primary:hover {
background-color: #3b82f6;
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
}
.btn-secondary {
background-color: #292e42;
color: #a9b1d6;
font-weight: 500;
padding: 0.5rem 1rem;
border-radius: 0.5rem;
transition: all 0.2s;
}
.btn-secondary:hover {
background-color: #364a82;
}
.btn-danger {
background-color: #f7768e;
color: white;
font-weight: 500;
padding: 0.5rem 1rem;
border-radius: 0.5rem;
transition: all 0.2s;
}
.btn-danger:hover {
background-color: #dc2626;
}
/* Card styles */
.card {
background-color: #16161e;
border: 1px solid #292e42;
border-radius: 0.75rem;
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05);
transition: all 0.3s;
}
.card:hover {
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
}
.card-header {
border-bottom: 1px solid #292e42;
padding: 1.5rem;
}
.card-body {
padding: 1.5rem;
}
/* Input styles */
.input-field {
background-color: #1a1b26;
border: 1px solid #292e42;
border-radius: 0.5rem;
padding: 0.5rem 0.75rem;
color: #a9b1d6;
transition: all 0.2s;
}
.input-field:focus {
outline: none;
ring: 2px;
ring-color: #7aa2f7;
border-color: transparent;
}
.input-field::placeholder {
color: #565f89;
}
/* Navigation styles */
.nav-link {
color: #a9b1d6;
transition: color 0.2s;
}
.nav-link:hover {
color: #7aa2f7;
}
.nav-link.active {
color: #7aa2f7;
}
}
+13
View File
@@ -0,0 +1,13 @@
// See https://svelte.dev/docs/kit/types#app.d.ts
// for information about these interfaces
declare global {
namespace App {
// interface Error {}
// interface Locals {}
// interface PageData {}
// interface PageState {}
// interface Platform {}
}
}
export {};
+14
View File
@@ -0,0 +1,14 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>
+170
View File
@@ -0,0 +1,170 @@
import { showError } from './toast';
const API_BASE = import.meta.env.DEV ? 'http://localhost:8080/api' : '/api';
// Helper function to handle API responses and show error toasts
async function handleResponse<T>(response: Response, errorMessage: string): Promise<T> {
if (!response.ok) {
let message = errorMessage;
try {
const errorData = await response.json();
if (errorData.error) {
message = errorData.error;
}
} catch (e) {
// If we can't parse JSON, use the default message
}
showError(message);
throw new Error(message);
}
return response.json();
}
export interface User {
id: string;
email: string;
username: string;
}
export interface AuthResponse {
token: string;
user: User;
}
export interface Ebook {
id: string;
title: string;
author: string | null;
isbn: string | null;
description: string | null;
file_path: string;
file_size: number | null;
mime_type: string | null;
cover_image_path: string | null;
created_at: string;
updated_at: string;
}
export interface ReadingProgress {
ebook_id: string;
user_id: string;
current_page: number;
total_pages: number | null;
last_read_at: string;
}
// Get stored token
function getToken(): string | null {
if (typeof window !== 'undefined') {
return localStorage.getItem('auth_token');
}
return null;
}
// Create headers with auth
function createHeaders(): Record<string, string> {
const headers: Record<string, string> = {
'Content-Type': 'application/json',
};
const token = getToken();
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
return headers;
}
// Auth functions
export async function registerUser(email: string, username: string, password: string): Promise<AuthResponse> {
const response = await fetch(`${API_BASE}/auth/register`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, username, password }),
});
return handleResponse<AuthResponse>(response, 'Registration failed');
}
export async function loginUser(login: string, password: string): Promise<AuthResponse> {
const response = await fetch(`${API_BASE}/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ login, password }),
});
return handleResponse<AuthResponse>(response, 'Login failed');
}
export async function getUserProfile(): Promise<User> {
const response = await fetch(`${API_BASE}/auth/profile`, {
headers: createHeaders(),
});
return handleResponse<User>(response, 'Failed to get profile');
}
export async function fetchEbooks(limit: number = 20, offset: number = 0): Promise<Ebook[]> {
const response = await fetch(`${API_BASE}/ebooks?limit=${limit}&offset=${offset}`, {
headers: createHeaders(),
});
return handleResponse<Ebook[]>(response, 'Failed to fetch ebooks');
}
export async function fetchEbook(id: string): Promise<Ebook> {
const response = await fetch(`${API_BASE}/ebooks/${id}`, {
headers: createHeaders(),
});
return handleResponse<Ebook>(response, 'Failed to fetch ebook');
}
export async function createEbook(data: Partial<Ebook>): Promise<Ebook> {
const response = await fetch(`${API_BASE}/ebooks`, {
method: 'POST',
headers: createHeaders(),
body: JSON.stringify(data),
});
return handleResponse<Ebook>(response, 'Failed to create ebook');
}
export async function updateEbook(id: string, data: Partial<Ebook>): Promise<Ebook> {
const response = await fetch(`${API_BASE}/ebooks/${id}`, {
method: 'PUT',
headers: createHeaders(),
body: JSON.stringify(data),
});
return handleResponse<Ebook>(response, 'Failed to update ebook');
}
export async function deleteEbook(id: string): Promise<void> {
const response = await fetch(`${API_BASE}/ebooks/${id}`, {
method: 'DELETE',
headers: createHeaders(),
});
if (!response.ok) {
let message = 'Failed to delete ebook';
try {
const errorData = await response.json();
if (errorData.error) {
message = errorData.error;
}
} catch (e) {
// If we can't parse JSON, use the default message
}
showError(message);
throw new Error(message);
}
}
export async function fetchReadingProgress(ebookId: string): Promise<ReadingProgress> {
const response = await fetch(`${API_BASE}/ebooks/${ebookId}/progress`, {
headers: createHeaders(),
});
return handleResponse<ReadingProgress>(response, 'Failed to fetch reading progress');
}
export async function updateReadingProgress(ebookId: string, currentPage: number, totalPages?: number): Promise<ReadingProgress> {
const response = await fetch(`${API_BASE}/ebooks/${ebookId}/progress`, {
method: 'PUT',
headers: createHeaders(),
body: JSON.stringify({ current_page: currentPage, total_pages: totalPages }),
});
return handleResponse<ReadingProgress>(response, 'Failed to update reading progress');
}
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="107" height="128" viewBox="0 0 107 128"><title>svelte-logo</title><path d="M94.157 22.819c-10.4-14.885-30.94-19.297-45.792-9.835L22.282 29.608A29.92 29.92 0 0 0 8.764 49.65a31.5 31.5 0 0 0 3.108 20.231 30 30 0 0 0-4.477 11.183 31.9 31.9 0 0 0 5.448 24.116c10.402 14.887 30.942 19.297 45.791 9.835l26.083-16.624A29.92 29.92 0 0 0 98.235 78.35a31.53 31.53 0 0 0-3.105-20.232 30 30 0 0 0 4.474-11.182 31.88 31.88 0 0 0-5.447-24.116" style="fill:#ff3e00"/><path d="M45.817 106.582a20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.503 18 18 0 0 1 .624-2.435l.49-1.498 1.337.981a33.6 33.6 0 0 0 10.203 5.098l.97.294-.09.968a5.85 5.85 0 0 0 1.052 3.878 6.24 6.24 0 0 0 6.695 2.485 5.8 5.8 0 0 0 1.603-.704L69.27 76.28a5.43 5.43 0 0 0 2.45-3.631 5.8 5.8 0 0 0-.987-4.371 6.24 6.24 0 0 0-6.698-2.487 5.7 5.7 0 0 0-1.6.704l-9.953 6.345a19 19 0 0 1-5.296 2.326 20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.502 17.99 17.99 0 0 1 8.13-12.052l26.081-16.623a19 19 0 0 1 5.3-2.329 20.72 20.72 0 0 1 22.237 8.243 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-.624 2.435l-.49 1.498-1.337-.98a33.6 33.6 0 0 0-10.203-5.1l-.97-.294.09-.968a5.86 5.86 0 0 0-1.052-3.878 6.24 6.24 0 0 0-6.696-2.485 5.8 5.8 0 0 0-1.602.704L37.73 51.72a5.42 5.42 0 0 0-2.449 3.63 5.79 5.79 0 0 0 .986 4.372 6.24 6.24 0 0 0 6.698 2.486 5.8 5.8 0 0 0 1.602-.704l9.952-6.342a19 19 0 0 1 5.295-2.328 20.72 20.72 0 0 1 22.237 8.242 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-8.13 12.053l-26.081 16.622a19 19 0 0 1-5.3 2.328" style="fill:#fff"/></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

+53
View File
@@ -0,0 +1,53 @@
import { writable } from 'svelte/store';
import type { User } from './api';
export interface AuthState {
user: User | null;
token: string | null;
loading: boolean;
}
function createAuthStore() {
const initialState: AuthState = {
user: null,
token: null,
loading: true,
};
const { subscribe, set, update } = writable<AuthState>(initialState);
return {
subscribe,
login: (token: string, user: User) => {
if (typeof window !== 'undefined') {
localStorage.setItem('auth_token', token);
}
set({ user, token, loading: false });
},
logout: () => {
if (typeof window !== 'undefined') {
localStorage.removeItem('auth_token');
}
set({ user: null, token: null, loading: false });
},
setLoading: (loading: boolean) => {
update(state => ({ ...state, loading }));
},
initialize: () => {
if (typeof window !== 'undefined') {
const token = localStorage.getItem('auth_token');
if (token) {
// Token exists, but we need to validate it by fetching profile
// For now, just set loading to false and let components handle
set({ user: null, token, loading: false });
} else {
set({ user: null, token: null, loading: false });
}
} else {
set({ user: null, token: null, loading: false });
}
},
};
}
export const authStore = createAuthStore();
+1
View File
@@ -0,0 +1 @@
// place files you want to import through the `$lib` alias in this folder.
+21
View File
@@ -0,0 +1,21 @@
import { toast } from '@zerodevx/svelte-toast';
export function showError(message: string) {
toast.push(message, {
theme: {
'--toastBackground': '#f7768e',
'--toastBarBackground': '#f7768e',
'--toastColor': '#1a1b26',
}
});
}
export function showSuccess(message: string) {
toast.push(message, {
theme: {
'--toastBackground': '#9ece6a',
'--toastBarBackground': '#9ece6a',
'--toastColor': '#1a1b26',
}
});
}
+93
View File
@@ -0,0 +1,93 @@
<script lang="ts">
import favicon from '$lib/assets/favicon.svg';
import { authStore } from '$lib/auth';
import { onMount } from 'svelte';
import { SvelteToast } from '@zerodevx/svelte-toast';
import '../app.css';
let { children } = $props();
// Initialize auth state on mount
onMount(() => {
authStore.initialize();
});
// Subscribe to auth state
let auth = $state({ user: null as any, token: null as string | null, loading: true });
authStore.subscribe((state) => {
auth = state;
});
function logout() {
authStore.logout();
}
</script>
<svelte:head>
<link rel="icon" href={favicon} />
</svelte:head>
{#if auth.loading}
<div class="min-h-screen flex items-center justify-center bg-gradient-to-br from-tokyo-bg to-tokyo-bg-dark">
<div class="text-center">
<div class="animate-spin rounded-full h-16 w-16 border-4 border-tokyo-bg-highlight border-t-tokyo-blue mx-auto mb-4"></div>
<p class="text-tokyo-fg-dark animate-pulse">Loading your library...</p>
</div>
</div>
{:else if !auth.token}
<!-- Auth required -->
<div class="min-h-screen flex items-center justify-center bg-gradient-to-br from-tokyo-bg to-tokyo-bg-dark">
<div class="text-center animate-slide-in">
<div class="mb-8">
<h1 class="text-4xl font-bold text-tokyo-fg mb-2">📚 Ebook Reader</h1>
<p class="text-tokyo-fg-dark text-lg">Your personal digital library</p>
</div>
<div class="space-x-6">
<a
href="/login"
class="btn-primary inline-flex items-center text-sm font-medium shadow-lg hover:shadow-tokyo-purple/25"
>
Sign In
</a>
<a
href="/register"
class="btn-secondary inline-flex items-center text-sm font-medium"
>
Create Account
</a>
</div>
<div class="mt-12 text-tokyo-fg-dark text-sm">
<p>Organize • Read • Enjoy</p>
</div>
</div>
</div>
{:else}
<!-- Authenticated user -->
<header class="bg-tokyo-bg-dark border-b border-tokyo-bg-highlight shadow-lg">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex justify-between items-center py-4">
<div class="flex items-center space-x-4">
<h1 class="text-2xl font-bold text-tokyo-fg">📚 Ebook Reader</h1>
<span class="hidden sm:inline text-sm text-tokyo-fg-dark"></span>
<span class="hidden sm:inline text-sm text-tokyo-fg-dark">Welcome back, {auth.user?.username}</span>
</div>
<div class="flex items-center space-x-4">
<span class="text-sm text-tokyo-fg">Hello, <span class="text-tokyo-cyan font-medium">{auth.user?.username}</span></span>
<button
onclick={logout}
class="btn-danger inline-flex items-center text-sm font-medium shadow-lg hover:shadow-tokyo-red/25 transition-all duration-200"
>
Sign Out
</button>
</div>
</div>
</div>
</header>
<main class="max-w-7xl mx-auto py-8 sm:px-6 lg:px-8 min-h-[calc(100vh-80px)]">
{@render children()}
</main>
{/if}
<!-- Toast notifications -->
<SvelteToast />
+105
View File
@@ -0,0 +1,105 @@
<script lang="ts">
import { onMount } from 'svelte';
import { fetchEbooks, type Ebook } from '$lib/api';
let ebooks: Ebook[] = [];
let loading = true;
let error: string | null = null;
onMount(async () => {
try {
ebooks = await fetchEbooks();
} catch (err) {
error = err instanceof Error ? err.message : 'Failed to load ebooks';
} finally {
loading = false;
}
});
</script>
<main class="container mx-auto px-4 py-8">
<div class="mb-12 text-center">
<h1 class="text-4xl font-bold text-tokyo-fg mb-2 animate-fade-in">📚 Your Ebook Library</h1>
<p class="text-tokyo-fg-dark text-lg">Discover and organize your digital reading collection</p>
</div>
{#if loading}
<div class="flex flex-col items-center justify-center py-16">
<div class="animate-spin rounded-full h-12 w-12 border-4 border-tokyo-bg-highlight border-t-tokyo-cyan mb-4"></div>
<p class="text-tokyo-fg-dark animate-pulse">Loading your ebooks...</p>
</div>
{:else if error}
<div class="card max-w-md mx-auto text-center">
<div class="card-body">
<div class="text-4xl mb-4">⚠️</div>
<h3 class="text-xl font-semibold text-tokyo-red mb-2">Oops! Something went wrong</h3>
<p class="text-tokyo-fg-dark">{error}</p>
</div>
</div>
{:else if ebooks.length === 0}
<div class="card max-w-lg mx-auto text-center">
<div class="card-body">
<div class="text-6xl mb-4">📖</div>
<h3 class="text-2xl font-semibold text-tokyo-fg mb-2">Welcome to your library!</h3>
<p class="text-tokyo-fg-dark mb-6">You haven't added any ebooks yet. Start building your collection by uploading your favorite books.</p>
<button class="btn-primary">Add Your First Ebook</button>
</div>
</div>
{:else}
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-8">
{#each ebooks as ebook (ebook.id)}
<div class="card group hover:scale-105 transition-all duration-300 animate-slide-in">
<div class="aspect-[3/4] bg-gradient-to-br from-tokyo-bg-highlight to-tokyo-bg-selection flex items-center justify-center relative overflow-hidden">
{#if ebook.cover_image_path}
<img
src={ebook.cover_image_path}
alt="{ebook.title} cover"
class="w-full h-full object-cover group-hover:scale-110 transition-transform duration-300"
/>
<div class="absolute inset-0 bg-gradient-to-t from-tokyo-bg-dark/50 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-300"></div>
{:else}
<div class="text-tokyo-fg-dark text-center p-6">
<div class="text-4xl mb-3">📚</div>
<div class="text-tokyo-fg text-sm font-medium">No Cover</div>
</div>
{/if}
</div>
<div class="card-body">
<h3 class="font-bold text-lg text-tokyo-fg mb-2 line-clamp-2 group-hover:text-tokyo-cyan transition-colors duration-200">
{ebook.title}
</h3>
{#if ebook.author}
<p class="text-tokyo-cyan text-sm mb-3 font-medium">by {ebook.author}</p>
{/if}
{#if ebook.description}
<p class="text-tokyo-fg-dark text-sm line-clamp-3 leading-relaxed">{ebook.description}</p>
{/if}
<div class="mt-4 pt-3 border-t border-tokyo-bg-highlight">
<div class="flex justify-between items-center text-xs text-tokyo-fg-dark">
<span>{new Date(ebook.created_at).toLocaleDateString()}</span>
{#if ebook.file_size}
<span>{Math.round(ebook.file_size / 1024 / 1024)}MB</span>
{/if}
</div>
</div>
</div>
</div>
{/each}
</div>
{/if}
</main>
<style>
.line-clamp-2 {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.line-clamp-3 {
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
</style>
+105
View File
@@ -0,0 +1,105 @@
<script lang="ts">
import { authStore } from '$lib/auth';
import { loginUser } from '$lib/api';
import { goto } from '$app/navigation';
let username = '';
let password = '';
let loading = false;
let error = '';
async function handleSubmit() {
if (!username || !password) return;
loading = true;
error = '';
try {
const response = await loginUser(username, password);
authStore.login(response.token, response.user);
goto('/');
} catch (err) {
error = err instanceof Error ? err.message : 'Login failed';
} finally {
loading = false;
}
}
</script>
<svelte:head>
<title>Login - Ebook Reader</title>
</svelte:head>
<div class="min-h-screen flex items-center justify-center bg-gradient-to-br from-tokyo-bg to-tokyo-bg-dark py-12 px-4 sm:px-6 lg:px-8">
<div class="max-w-md w-full animate-slide-in">
<div class="card">
<div class="card-header text-center">
<div class="text-4xl mb-4">🔐</div>
<h2 class="text-2xl font-bold text-tokyo-fg">
Welcome Back
</h2>
<p class="text-tokyo-fg-dark mt-2">Sign in to access your library</p>
</div>
<div class="card-body">
<form class="space-y-6" on:submit|preventDefault={handleSubmit}>
<div class="space-y-4">
<div>
<label for="username" class="block text-sm font-medium text-tokyo-fg mb-2">
Username or Email
</label>
<input
id="username"
name="username"
type="text"
required
class="input-field w-full"
placeholder="Enter your username or email"
bind:value={username}
/>
</div>
<div>
<label for="password" class="block text-sm font-medium text-tokyo-fg mb-2">
Password
</label>
<input
id="password"
name="password"
type="password"
required
class="input-field w-full"
placeholder="Enter your password"
bind:value={password}
/>
</div>
</div>
{#if error}
<div class="bg-tokyo-red/10 border border-tokyo-red/20 rounded-lg p-4">
<div class="flex items-center">
<div class="text-tokyo-red mr-2">⚠️</div>
<div class="text-sm text-tokyo-red">{error}</div>
</div>
</div>
{/if}
<button
type="submit"
disabled={loading}
class="btn-primary w-full flex justify-center items-center disabled:opacity-50 disabled:cursor-not-allowed"
>
{#if loading}
<div class="animate-spin rounded-full h-4 w-4 border-2 border-white border-t-transparent mr-2"></div>
{/if}
{loading ? 'Signing in...' : 'Sign In'}
</button>
<div class="text-center">
<a href="/register" class="text-tokyo-blue hover:text-tokyo-cyan transition-colors duration-200 text-sm">
Don't have an account? Create one here
</a>
</div>
</form>
</div>
</div>
</div>
</div>
@@ -0,0 +1,38 @@
import { superValidate } from 'sveltekit-superforms/server';
import { z } from 'zod';
import { registerUser } from '$lib/api';
import { redirect, fail } from '@sveltejs/kit';
const schema = z.object({
email: z.string().email('Invalid email address'),
username: z.string().min(3, 'Username must be at least 3 characters'),
password: z.string().min(8, 'Password must be at least 8 characters').regex(/^(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]{8,}$/, 'Password must include at least one uppercase letter, one number, and one symbol'),
confirmPassword: z.string(),
}).refine(data => data.password === data.confirmPassword, {
message: "Passwords do not match",
path: ["confirmPassword"],
});
type FormData = z.infer<typeof schema>;
export const load = async () => {
const form = await superValidate(schema);
return { form };
};
export const actions = {
default: async ({ request }) => {
const form = await superValidate(request, schema);
if (!form.valid) {
return fail(400, { form });
}
const data = form.data as FormData;
try {
await registerUser(data.email, data.username, data.password);
throw redirect(302, '/login');
} catch (err) {
if (err instanceof Response) throw err;
return fail(500, { form, error: err instanceof Error ? err.message : 'Registration failed' });
}
},
};
+124
View File
@@ -0,0 +1,124 @@
<script lang="ts">
import { superForm } from 'sveltekit-superforms';
import { page } from '$app/stores';
let { form, errors, enhance, submitting } = superForm($page.data.form);
</script>
<svelte:head>
<title>Register - Ebook Reader</title>
</svelte:head>
<div class="min-h-screen flex items-center justify-center bg-gradient-to-br from-tokyo-bg to-tokyo-bg-dark py-12 px-4 sm:px-6 lg:px-8">
<div class="max-w-md w-full animate-slide-in">
<div class="card">
<div class="card-header text-center">
<div class="text-4xl mb-4"></div>
<h2 class="text-2xl font-bold text-tokyo-fg">
Join Our Library
</h2>
<p class="text-tokyo-fg-dark mt-2">Create your account to start reading</p>
</div>
<div class="card-body">
<form class="space-y-6" method="POST" use:enhance>
<div class="space-y-4">
<div>
<label for="email" class="block text-sm font-medium text-tokyo-fg mb-2">
Email Address
</label>
<input
id="email"
name="email"
type="email"
required
class="input-field w-full"
placeholder="Enter your email"
bind:value={$form.email}
/>
{#if $errors.email}
<p class="text-tokyo-red text-sm mt-1">{$errors.email}</p>
{/if}
</div>
<div>
<label for="username" class="block text-sm font-medium text-tokyo-fg mb-2">
Username
</label>
<input
id="username"
name="username"
type="text"
required
class="input-field w-full"
placeholder="Choose a username"
bind:value={$form.username}
/>
{#if $errors.username}
<p class="text-tokyo-red text-sm mt-1">{$errors.username}</p>
{/if}
</div>
<div>
<label for="password" class="block text-sm font-medium text-tokyo-fg mb-2">
Password
</label>
<input
id="password"
name="password"
type="password"
required
class="input-field w-full"
placeholder="Create a password"
bind:value={$form.password}
/>
{#if $errors.password}
<p class="text-tokyo-red text-sm mt-1">{$errors.password}</p>
{/if}
</div>
<div>
<label for="confirmPassword" class="block text-sm font-medium text-tokyo-fg mb-2">
Confirm Password
</label>
<input
id="confirmPassword"
name="confirmPassword"
type="password"
required
class="input-field w-full"
placeholder="Confirm your password"
bind:value={$form.confirmPassword}
/>
{#if $errors.confirmPassword}
<p class="text-tokyo-red text-sm mt-1">{$errors.confirmPassword}</p>
{/if}
</div>
</div>
{#if $page.data.error}
<div class="bg-tokyo-red/10 border border-tokyo-red/20 rounded-lg p-4">
<div class="flex items-center">
<div class="text-tokyo-red mr-2">⚠️</div>
<div class="text-sm text-tokyo-red">{$page.data.error}</div>
</div>
</div>
{/if}
<button
type="submit"
disabled={$submitting}
class="btn-primary w-full flex justify-center items-center disabled:opacity-50 disabled:cursor-not-allowed"
>
{#if $submitting}
<div class="animate-spin rounded-full h-4 w-4 border-2 border-white border-t-transparent mr-2"></div>
{/if}
{$submitting ? 'Creating account...' : 'Create Account'}
</button>
<div class="text-center">
<a href="/login" class="text-tokyo-blue hover:text-tokyo-cyan transition-colors duration-200 text-sm">
Already have an account? Sign in here
</a>
</div>
</form>
</div>
</div>
</div>
</div>
+3
View File
@@ -0,0 +1,3 @@
# allow crawling everything by default
User-agent: *
Disallow:
+16
View File
@@ -0,0 +1,16 @@
import adapter from '@sveltejs/adapter-static';
/** @type {import('@sveltejs/kit').Config} */
const config = {
kit: {
adapter: adapter({
pages: 'build',
assets: 'build',
fallback: 'index.html',
precompress: false,
strict: true
})
}
};
export default config;
+45
View File
@@ -0,0 +1,45 @@
/** @type {import('tailwindcss').Config} */
export default {
content: ['./src/**/*.{html,js,svelte,ts}'],
theme: {
extend: {
colors: {
tokyo: {
bg: '#1a1b26',
'bg-dark': '#16161e',
'bg-highlight': '#292e42',
'bg-selection': '#364a82',
fg: '#a9b1d6',
'fg-dark': '#565f89',
'fg-light': '#c0caf5',
red: '#f7768e',
orange: '#ff9e64',
yellow: '#e0af68',
green: '#9ece6a',
cyan: '#7dcfff',
blue: '#7aa2f7',
purple: '#bb9af7',
magenta: '#c0caf5',
},
},
fontFamily: {
sans: ['Inter', 'system-ui', 'sans-serif'],
},
animation: {
'fade-in': 'fadeIn 0.5s ease-in-out',
'slide-in': 'slideIn 0.3s ease-out',
},
keyframes: {
fadeIn: {
'0%': { opacity: '0' },
'100%': { opacity: '1' },
},
slideIn: {
'0%': { transform: 'translateY(-10px)', opacity: '0' },
'100%': { transform: 'translateY(0)', opacity: '1' },
},
},
},
},
plugins: [],
}
+20
View File
@@ -0,0 +1,20 @@
{
"extends": "./.svelte-kit/tsconfig.json",
"compilerOptions": {
"rewriteRelativeImportExtensions": true,
"allowJs": true,
"checkJs": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"sourceMap": true,
"strict": true,
"moduleResolution": "bundler"
}
// Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias
// except $lib which is handled by https://svelte.dev/docs/kit/configuration#files
//
// To make changes to top-level options such as include and exclude, we recommend extending
// the generated config; see https://svelte.dev/docs/kit/configuration#typescript
}
+6
View File
@@ -0,0 +1,6 @@
import { sveltekit } from '@sveltejs/kit/vite';
import { defineConfig } from 'vite';
export default defineConfig({
plugins: [sveltekit()]
});
+3 -2
View File
@@ -1,9 +1,10 @@
module bookmann module bookmann
go 1.25.5 go 1.25
require github.com/labstack/echo/v4 v4.15.0
require ( require (
github.com/labstack/echo/v4 v4.15.0 // indirect
github.com/labstack/gommon v0.4.2 // indirect github.com/labstack/gommon v0.4.2 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-isatty v0.0.20 // indirect
+8
View File
@@ -1,3 +1,5 @@
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/labstack/echo/v4 v4.15.0 h1:hoRTKWcnR5STXZFe9BmYun9AMTNeSbjHi2vtDuADJ24= github.com/labstack/echo/v4 v4.15.0 h1:hoRTKWcnR5STXZFe9BmYun9AMTNeSbjHi2vtDuADJ24=
github.com/labstack/echo/v4 v4.15.0/go.mod h1:xmw1clThob0BSVRX1CRQkGQ/vjwcpOMjQZSZa9fKA/c= github.com/labstack/echo/v4 v4.15.0/go.mod h1:xmw1clThob0BSVRX1CRQkGQ/vjwcpOMjQZSZa9fKA/c=
github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0= github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0=
@@ -6,6 +8,10 @@ github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHP
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo= github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo=
@@ -19,3 +25,5 @@ golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU= golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY= golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=